Skip to content

feat(fiber): new testo/fiber plugin — #[RunInFiber] cooperative-fiber tests - #268

Merged
roxblnfk merged 12 commits into
1.xfrom
plugin-async
Jul 24, 2026
Merged

feat(fiber): new testo/fiber plugin — #[RunInFiber] cooperative-fiber tests#268
roxblnfk merged 12 commits into
1.xfrom
plugin-async

Conversation

@roxblnfk

@roxblnfk roxblnfk commented Jul 17, 2026

Copy link
Copy Markdown
Member

What was changed

New first-party plugin testo/fiber (ships with Testo) — run tests as plain PHP fibers driven by Testo's own cooperative scheduler (no event loop, no preemption).

  • #[RunInFiber] (namespace Testo\Fiber):
    • on a method — run that test in its own fiber, so cooperative code (\Fiber::suspend()) is resumed by the scheduler;
    • on a class — schedule the whole case per Schedule: Solo (default — each test alone in its own fiber, driven to completion, no interleaving), or RoundRobin / Random cooperative interleaving to shake out order-dependent races.
  • Schedule { Solo, RoundRobin, Random } — class-level scheduling policy.
  • Switching is cooperative and happens only where a fiber calls \Fiber::suspend() — there is no separate yield helper. In practice the async driver a test exercises already yields with a bare \Fiber::suspend(), and the scheduler drives the test fibers directly, so that suspend is the natural switch point.
  • RunInFiberInterceptor implements both TestRunInterceptor (method: wrap one test in a fiber; pass-through when a case is already scheduling) and TestCaseRunInterceptor (class: one fiber per test, driven per Schedule), at ORDER_CLOSE_TO_TEST. Switching runs on plain fibers, which cooperate with Testo's fiber-aware scoped-state guards (assert/messenger state stays isolated across an interleave).
  • Wiring / release / docs: testo/fiber in the root require (bundled), path-repo dev-alias, testo.php suites; resources/version.json seed 0.0.0, release-please entry (component fiber), split-publish trigger fiber-[0-9]*; skills/testo-fiber skill + README row; FEATURE_PARITY.md row (⛔ — no PHPUnit/Pest equivalent).

No Revolt dependency: this plugin is pure PHP fibers. Real async I/O (awaiting timers, sockets, Future::await(), amphp) needs the Revolt event loop and is a separate concern — a future testo/bridge-revolt (#[RunInRevolt]), not in this PR.

Why?

Two distinct needs, cleanly split by execution context:

  • Cooperative fibers / race hunting — testing code that suspends on plain fibers, and interleaving a case's tests to surface order-dependent races. This is what testo/fiber does, and it needs no event loop: Testo's scheduler owns the fibers and its guards cooperate with it.
  • Real async I/O — awaiting external events, which fundamentally requires an event loop that owns the fibers (Revolt). Mixing the two in one package forces the impossible "hand-drive a fiber the loop also owns" case (guard/driver deadlock), so async-on-Revolt is deferred to its own bridge.

Both are opt-in per test/case and leave normal tests on the synchronous fast path.

Checklist

  • No linked issue.
  • Tested
    • Tested manually (ran the Fiber/Unit, Fiber/Feature, Fiber/Self suites — 17 green)
    • Unit tests added (attribute defaults + self-wiring, Scheduler per Schedule, end-to-end interleaving / solo / method-level, status mapping)
  • Documentation (skills/testo-fiber, FEATURE_PARITY.md, attribute docblocks)

roxblnfk added 3 commits July 17, 2026 19:59
Introduce the testo/async plugin: run tests as fibers/coroutines on the
process-global Revolt event loop so a test may suspend on plain fibers and
await real async work without blocking the process.

- #[Async] (method/class): run a single test as its own isolated coroutine;
  self-wiring via Interceptable + FallbackInterceptor(AsyncRunInterceptor).
- #[Concurrent(Strategy)] (class): run all tests of a case within one shared
  event-loop run; wired to ConcurrentRunInterceptor. v1 implements
  Strategy::Sequential; RoundRobin/Random fail loudly until the cooperative
  scheduler lands.

Package layout mirrors plugin/retry: attributes at the package root under
namespace Testo (loaded via composer "files"), implementation under
Testo\Async\*. Hard-requires revolt/event-loop as the single async substrate.

Assisted-By: Claude Opus 4.8 (1M context)
Complete the testo/async plugin scaffolded in the previous commit.

Root integration:
- Require testo/async in the root `require` (ships with Testo), add the
  path-repo dev-alias and the Tests\Async\ autoload-dev mapping.
- testo.php: exclude plugin/async/tests from src discovery and load its
  suites.php.

Behaviour fixes found while testing:
- AsyncRunInterceptor moved to ORDER_CLOSE_TO_TEST (was ORDER_DEFAULT). It must
  sit INSIDE the fiber-aware scoped-state guards (assertion collector, messenger
  scope): if a guard's nested-fiber path wraps the loop drive, Revolt resuming
  that same fiber deadlocks ("event loop terminated without resuming").
- #[Concurrent(Sequential)] is a pass-through for v1. A single shared loop run
  for the whole case would force those guards onto their nested-fiber path and
  deadlock on await; making them Revolt-compatible is the prerequisite for the
  shared-loop interleaving strategies. RoundRobin/Random still fail loudly.

Tests (Async/Unit, Async/Feature, Async/Self) — 10 green: an #[Async] test runs
inside a fiber and awaits a real timer; failures inside the coroutine propagate
to Status::Failed; #[Async] composes under #[Concurrent(Sequential)].

Release engineering (mirror retry/vcr): resources/version.json seed 0.0.0,
release-please package entry (component "async"), split-publish tag trigger
`async-[0-9]*` → php-testo/async.

Docs: skills/testo-async skill + README row; FEATURE_PARITY row marking
#[Async]/#[Concurrent] as ⛔ (no PHPUnit/Pest equivalent).

Assisted-By: Claude Opus 4.8 (1M context)
Interleaving strategies now run, on plain fibers (not Revolt), so a #[Concurrent]
case can shake out order-dependent races in cooperative code.

- Coroutine::reschedule() (public): cooperative yield point; no-op outside a
  #[Concurrent] interleaving run, so it is safe to sprinkle anywhere.
- Scheduler (internal): drives one fiber per test to completion — RoundRobin
  steps every ready test each round, Random steps a random ready test — switching
  at reschedule() points; captures per-fiber throwables by index.
- ConcurrentRunInterceptor moved to ORDER_CLOSE_TO_TEST (innermost case
  interceptor) and, for RR/Random, replaces CaseRunner::run's loop: a fiber per
  test via the core per-test runner, re-emitting the TestCase events and
  aggregating CaseResult. Sequential stays a pass-through.

Why plain fibers work where a shared Revolt loop deadlocked: Testo's fiber-aware
guards (assert collector, messenger scope) re-suspend to their parent and swap
scoped state around each switch — a protocol built to cooperate with a plain-fiber
parent scheduler. So per-test assertion state stays isolated across the interleave
(validated end-to-end). Awaiting real async I/O inside an interleaved test is still
unsupported — that needs a shared Revolt loop, which remains blocked on making the
guards yield to the driver; use #[Async] for real async.

Tests: Scheduler unit (RR order, Random completion, throw capture, reschedule
no-op) + a Self test proving end-to-end RR interleaving with isolated assertions.
Async suites: 16 green. Strategy enum + Concurrent docs + skill updated.

Assisted-By: Claude Opus 4.8 (1M context)
@roxblnfk
roxblnfk requested a review from a team as a code owner July 17, 2026 16:56
@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new first-party testo/async plugin that adds opt-in coroutine-style execution to Testo tests, enabling (1) true async/await testing on the Revolt event loop and (2) cooperative interleaving of tests within a case to surface order-dependent race bugs.

Changes:

  • Add public attributes #[Async] and #[Concurrent(Strategy)] plus runtime interceptors (AsyncRunInterceptor, ConcurrentRunInterceptor) and a cooperative Scheduler.
  • Add unit/feature/self tests for async execution and interleaving, and wire the new suites into testo.php.
  • Add packaging/release wiring (root composer.json, split-publish tag trigger, release-please component), plus skills/docs updates.

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
testo.php Registers async plugin test suites in the main Testo suite configuration.
composer.json Bundles testo/async and adds dev path-repo version mapping + test namespace.
resources/version.json Seeds the new plugin version entry for release tooling.
plugin/async/composer.json Defines the standalone package metadata and dependencies for testo/async.
plugin/async/README.md Adds plugin README (needs wording alignment for #[Concurrent]).
plugin/async/Async.php Introduces #[Async] attribute (doc comment currently conflicts with implementation/limits).
plugin/async/Concurrent.php Introduces #[Concurrent] attribute and documents cooperative interleaving constraints.
plugin/async/src/Strategy.php Adds the scheduling strategy enum for concurrent case execution.
plugin/async/src/Coroutine.php Adds Coroutine::reschedule() helper for cooperative yield points.
plugin/async/src/Internal/AsyncRunInterceptor.php Drives an #[Async] test on the Revolt event loop via a Suspension.
plugin/async/src/Internal/Scheduler.php Implements cooperative fiber scheduling for RoundRobin/Random.
plugin/async/src/Internal/ConcurrentRunInterceptor.php Replaces the case test loop for interleaving strategies (has a case-status aggregation bug).
plugin/async/tests/suites.php Adds Async/Unit, Async/Feature, Async/Self suite definitions.
plugin/async/tests/Unit/SchedulerTest.php Unit tests for scheduler behavior and error capturing.
plugin/async/tests/Unit/AsyncAttributesTest.php Unit tests for attribute defaults and self-wiring contract.
plugin/async/tests/Feature/AsyncStatusTest.php End-to-end status mapping tests via TestRunner.
plugin/async/tests/Stub/AsyncScenarios.php Stub scenarios used by feature tests.
plugin/async/tests/Self/AsyncSelfTest.php Self-tests for Revolt loop execution and timer await behavior.
plugin/async/tests/Self/ConcurrentSequentialTest.php Self-test for sequential strategy + #[Async] composition.
plugin/async/tests/Self/ConcurrentInterleaveTest.php Self-test proving interleaving at Coroutine::reschedule() points.
skills/testo-async/SKILL.md Adds a new skill for async/coroutine testing guidance (examples need missing imports fixed).
skills/README.md Lists the new testo-async skill.
bridge/rector/FEATURE_PARITY.md Documents no conversion parity for #[Async] / #[Concurrent].
.github/workflows/split-publish.yml Adds split-publish trigger for async-* tags.
.github/.release-please-config.json Adds release-please component configuration for plugin/async.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread plugin/fiber/src/Internal/RunInFiberInterceptor.php Outdated
Comment thread plugin/async/Async.php Outdated
Comment thread plugin/async/README.md Outdated
Comment thread skills/testo-async/SKILL.md Outdated
Comment thread skills/testo-async/SKILL.md Outdated
roxblnfk added 3 commits July 18, 2026 11:54
Address PR #268 review: the interleaving strategies run on plain fibers, not a
shared Revolt event-loop run.

- Async.php: drop the stale "pulled out of the shared interleaving" claim; state
  the actual rule — do not mix #[Async] with a non-sequential #[Concurrent].
- README.md: #[Concurrent] schedules a case's tests (sequential / cooperative
  RoundRobin/Random interleaving on plain fibers), not "one shared event-loop run".
- testo-async skill: add the missing `use Testo\Assert;` to both examples.

Not changed: the interleaved case-status aggregation faithfully mirrors core
CaseRunner::run (Error/Failed both fail the case); diverging only here would
desync the sequential and interleaved paths.

Assisted-By: Claude Opus 4.8 (1M context)
…s into src/

Rename the public attributes and relocate them from the package root into the
`Testo\Async\` namespace under `src/`:

- `Testo\Async`      -> `Testo\Async\RunInCoroutine`
- `Testo\Concurrent` -> `Testo\Async\Concurrent`
- `AsyncRunInterceptor` -> `RunInCoroutineInterceptor` (internal)

Rationale: the plugin exposes two orthogonal axes and they must stay separate
attributes. `RunInCoroutine` is the execution-context axis (reserving a `Run`
prefix for future `RunInSeparatedProcess` / `RunInThread`); `Concurrent` is the
scheduling axis, so it stays un-prefixed and composes with any execution context
rather than duplicating strategy enums into each `Run*` attribute. "Each test in
its own coroutine, sequentially" is already class-level `#[RunInCoroutine]`, not a
new `Concurrent` strategy.

Both attributes now load via PSR-4 (`Testo\Async\`), dropping the composer
`files` autoload entries. Skills, README, Rector parity table and the RU docs are
updated to the new names. All 16 async suites pass.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…duler only

Recast the plugin around Testo's own cooperative fiber scheduler and drop the
Revolt dependency. Revolt (real event-loop async I/O) is a separate concern and
moves to a future `testo/bridge-revolt` (`#[RunInRevolt]`), not shipped here.

Public surface (namespace `Testo\Fiber`):

- `#[RunInFiber]` (method | class) — run a test, or a whole case, inside plain PHP
  fibers driven by Testo's scheduler. Replaces both `#[Async]`/`#[RunInCoroutine]`
  and `#[Concurrent]`.
- `Schedule { OneByOne (default), RoundRobin, Random }` — class-level scheduling.
  Replaces `Strategy` (`Sequential` -> `OneByOne`).
- `Coroutine::reschedule()` — cooperative yield point (unchanged).

Why: fibers driven by our scheduler cooperate with Testo's fiber-aware guards
(assert/messenger state stays isolated across an interleave) and need no event
loop. Switching is cooperative (no preemption), only at `\Fiber::suspend()` /
`reschedule()` points. This is for fiber/coroutine code and race hunting — real
async I/O (timers, sockets, `Future::await()`) belongs on the Revolt loop.

`RunInFiberInterceptor` implements both `TestRunInterceptor` (method-level: wrap one
test in a fiber; pass-through when a case is already scheduling) and
`TestCaseRunInterceptor` (class-level: one fiber per test, driven per `Schedule`).

Also updates root wiring (composer, testo.php, version.json, release-please,
split-publish), the Rector parity table and the `testo-fiber` skill. All Fiber
suites pass (17 tests).

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@roxblnfk roxblnfk changed the title feat(async): new testo/async plugin — #[Async] / #[Concurrent] coroutine tests feat(fiber): new testo/fiber plugin — #[RunInFiber] cooperative-fiber tests Jul 21, 2026
roxblnfk added 6 commits July 21, 2026 23:44
Solo reads better against the other cases as "runs alone, uninterrupted" (vs
RoundRobin/Random which interleave). Renames the enum case, the attribute default,
the interceptor's single-fiber wrap, tests (OneByOneTest -> SoloTest), README and
the testo-fiber skill.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r::suspend()

The helper was a thin wrapper over \Fiber::suspend() that no-op'd outside a
scheduler run. In practice the code under test (async drivers) already yields with
a bare \Fiber::suspend() (guarded by \Fiber::getCurrent()), and Testo's scheduler
drives the test fibers directly — so a bare suspend is the natural switch point and
the wrapper is redundant. Removes the Coroutine class; tests/examples/docs switch to
\Fiber::suspend(). Scheduler::active() stays (gates the interceptor's pass-through).

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e case

RunInFiberInterceptor no longer swaps a runner through a container scope. At the
class level runTestCase sets a fiber batch runner on the case
(CaseInfo::withBatchRunner()); CaseRunner reads it and hands over the per-test
handlers. Method-level runTest (wrap one test in a fiber, pass-through under an
active scheduler) is unchanged.

FiberTestBatchRunner is a plain invokable: given the case's handlers it wraps each
in its own \Fiber and drives them on the cooperative Scheduler per the class-level
Schedule.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prefix each case's docblock with a short, plain-language line (run one at a
time / interleave in order / interleave in random order) so the intent reads at
a glance before the detailed description.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The batch runner used to throw only the first throwable the scheduler surfaced,
silently dropping the rest when several test fibers fail in an interleaved run.

Add a public CompositeException that bundles every throwable (keyed by the fiber
/ test index that raised it), builds a summary message listing all of them, and
chains the earliest as `previous` so ordinary renderers still show a root cause.
FiberTestBatchRunner now throws it for any non-empty error set — a single failure
is wrapped too, keeping the surfaced type uniform.

Testo's per-test pipeline never throws (failures become results), so this path
is a safety net for breakage below the pipeline; a Unit test covers the multi-
and single-failure aggregation.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@roxblnfk
roxblnfk merged commit a109282 into 1.x Jul 24, 2026
24 of 26 checks passed
@roxblnfk
roxblnfk deleted the plugin-async branch July 24, 2026 09:38
@roxblnfk roxblnfk mentioned this pull request Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants