feat(fiber): new testo/fiber plugin — #[RunInFiber] cooperative-fiber tests - #268
Merged
Conversation
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)
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
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 cooperativeScheduler. - 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.
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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](namespaceTesto\Fiber):\Fiber::suspend()) is resumed by the scheduler;Schedule:Solo(default — each test alone in its own fiber, driven to completion, no interleaving), orRoundRobin/Randomcooperative interleaving to shake out order-dependent races.Schedule { Solo, RoundRobin, Random }— class-level scheduling policy.\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.RunInFiberInterceptorimplements bothTestRunInterceptor(method: wrap one test in a fiber; pass-through when a case is already scheduling) andTestCaseRunInterceptor(class: one fiber per test, driven perSchedule), atORDER_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).testo/fiberin the rootrequire(bundled), path-repo dev-alias,testo.phpsuites;resources/version.jsonseed0.0.0, release-please entry (componentfiber), split-publish triggerfiber-[0-9]*;skills/testo-fiberskill + README row;FEATURE_PARITY.mdrow (⛔ — 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 futuretesto/bridge-revolt(#[RunInRevolt]), not in this PR.Why?
Two distinct needs, cleanly split by execution context:
testo/fiberdoes, and it needs no event loop: Testo's scheduler owns the fibers and its guards cooperate with it.Both are opt-in per test/case and leave normal tests on the synchronous fast path.
Checklist
Fiber/Unit,Fiber/Feature,Fiber/Selfsuites — 17 green)SchedulerperSchedule, end-to-end interleaving / solo / method-level, status mapping)skills/testo-fiber,FEATURE_PARITY.md, attribute docblocks)