feat(bridge-revolt): new testo/bridge-revolt — #[RunInRevolt] async tests on the Revolt loop#269
Open
roxblnfk wants to merge 17 commits into
Open
feat(bridge-revolt): new testo/bridge-revolt — #[RunInRevolt] async tests on the Revolt loop#269roxblnfk wants to merge 17 commits into
roxblnfk wants to merge 17 commits into
Conversation
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
roxblnfk
force-pushed
the
bridge-revolt
branch
2 times, most recently
from
July 24, 2026 09:40
1d9ce61 to
e235780
Compare
…ests on the Revolt loop New bridge that runs a test on the process-global Revolt event loop, so the test body may await real async work (timers, streams, Future::await(), amphp) and resume without blocking the process. - `#[RunInRevolt]` (method | class, namespace `Testo\Bridge\Revolt`) — self-wiring via `Interceptable` + `#[FallbackInterceptor]`; the interceptor drives the test as a microtask on the loop and blocks the current fiber on a Revolt `Suspension` until it completes. Sits at `ORDER_CLOSE_TO_TEST` so Testo's fiber-aware scoped-state guards stay on their synchronous main-fiber path (otherwise the Revolt driver deadlocks resuming a guard-owned fiber). - Requires `revolt/event-loop`. Suspension goes through a Revolt `Suspension` bound to a watcher (I/O, timer); a bare `\Fiber::suspend()` has no resumer on the loop — that cooperative model is the separate `testo/fiber` `#[RunInFiber]` plugin. Wiring: `testo/bridge-revolt` require-dev + path-repo dev-alias; `testo.php` suite + exclude; `resources/version.json` seed `0.0.0`; release-please entry (component `bridge-revolt`); split-publish trigger `bridge-revolt-[0-9]*`. Known limitation (documented): only a single test runs on the loop at a time. Interleaving several guarded tests on one shared loop is blocked until Testo's fiber-aware guards move to fiber-local storage — a separate follow-up. Self-test (`Revolt/Self`) awaits a real timer under `#[RunInRevolt]` and resumes — 2 green. Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…atch runner Adopt the 1.x batch-runner seam (CaseInfo::$batchRunner) and expose two strategies on #[RunInRevolt] through a new Strategy enum: - Strategy::PerTest (default) — each test enters the loop individually, from inside Testo's fiber-aware scoped-state guards (runTest, ORDER_CLOSE_TO_TEST), so the guards stay on their synchronous main-fiber path and only the test body reaches the loop. This is the behaviour that works today. - Strategy::PerCase — runTestCase hands the case a RevoltTestBatchRunner (via CaseInfo::withBatchRunner()) that drives the whole batch on one loop run. This puts the per-test pipeline, guards included, inside a loop fiber; the guards cannot cooperate with the Revolt driver yet, so PerCase currently deadlocks and becomes usable once the guards move to fiber-local storage. RunInRevoltInterceptor now implements TestCaseRunInterceptor too, takes the RunInRevolt attribute as options, and its runTest passes through when a case runner already drove the test onto the loop. RevoltTestBatchRunner is the shared loop-dispatch primitive (also used by the per-test path). README + docblocks document the strategies and the PerCase limitation; a Unit suite covers the strategy wiring without entering the loop. Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RevoltTestBatchRunner::__invoke no longer runs handlers one-by-one — it launches every test as its own coroutine on the loop at once and blocks until all finish, so PerCase is genuinely parallel (verified in isolation: concurrent awaiting handlers interleave and return in input order). With the current fiber-aware guards this deadlocks: a guard bare-suspends its loop fiber, the Revolt driver has no resumer, the strand is abandoned and the run's suspension returns with tests missing. That used to make __invoke return an empty list — the case silently produced zero tests and the run stayed green, the worst possible outcome. Add a completeness check that throws loudly when the loop unwinds before every handler finished, naming the cause. On this branch PerCase therefore FAILS ON PURPOSE (see the PerCaseParallelTest self-test): exit 1 + an explicit "PerCase needs fiber-local guards" error. The fix — moving the guards to fiber-local storage so the loop can drive the test fibers directly — lands on the main branch, not here; this branch keeps the blocker visible. Docblocks + README updated to describe PerCase as concurrent. Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Holds a value per fiber (keyed via WeakMap on \Fiber::getCurrent()), with a separate main-thread slot. scope() saves/restores the current context's slot around a callback, replacing the hand-driven fiber trampoline that the scoped- state guards used — a trampoline that deadlocks once a real event loop resumes the exact suspended fiber directly. Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the MutableContainer state slot and the child-fiber trampoline in scope()/fork() with a FiberLocal<State>. Each scope entered inside a test fiber stays isolated to that fiber, so interleaved tests on one event loop keep separate message buffers without swapping a global slot at every suspension. MutableContainer (used only here) is removed. Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
StaticState now holds the active TestState in a FiberLocal<?TestState>, and AssertCollectorInterceptor installs it via StaticState::scope() instead of the swap()/child-fiber trampoline. Each test fiber reads its own collector, so concurrent tests on one event loop keep separate assertion histories; the synchronous (no-fiber) path is unchanged. Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move RunInRevoltInterceptor outer to the fiber-aware guards (order just outside ORDER_DATA_PROVIDER) so the whole per-test pipeline — assertion collector, messenger scope, test body — runs inside the loop fiber. With the guards now fiber-local, both strategies work: PerTest runs each pipeline on the loop to completion; PerCase launches them concurrently and each test keeps its own scoped state across the interleave. PerCaseParallelTest is now a real passing concurrency test; docs no longer describe PerCase as deadlocking. Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move RunInFiberInterceptor outer to the fiber-aware guards (order just outside ORDER_DATA_PROVIDER) so a method-level #[RunInFiber] wraps the whole per-test pipeline — assertion collector, messenger scope, test body — inside the fiber. With the guards now fiber-local, they must run in the same fiber as the body or scoped state would not cross from the main fiber; this mirrors the bridge-revolt ordering. The class-level batch-runner path is unaffected. Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FiberLocal moves out of core (Testo\Common) into its own foundation package Internal\Fiber, wired like internal/container (psr-4 + replace + autoload-dev, its own tests/suites.php → 'Fiber/Internal/Unit'). This keeps the primitive a low-level dependency both core and the container can share without a core→container cycle, and lets it split into its own repo later. Also refine the API now that it is a package surface: drop the unused set() (all consumers use scope()/get()), add an optional $destroy hook to scope() that runs in the finally after the binding is restored — MessengerHub::scope() uses it and drops its own try/finally. Consumers import Internal\Fiber\FiberLocal. Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the child-fiber trampoline in ObjectContainer::scope() with the shared Internal\Fiber\FiberLocal: the active State is held per fiber, so a scope entered inside a fiber stays isolated to it and survives a real event-loop suspension (the loop resumes that exact fiber directly, which the trampoline could not cooperate with). The child state is torn down via scope()'s destroy hook. The synchronous (no-fiber) path is unchanged. Adds the Container/Unit suite exercising scope under Revolt (a real await inside scope keeps its state) and under interleaved fibers (two scopes keep separate instances), plus a sync isolation/nesting baseline. Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a ResolutionTest for the synchronous surface — caching vs make(), has/set, autowiring, every bind() form (closure, class alias, array args, null self-bind, Factoriable via create()), first-time arguments, inflectors, self-binding, destroy of managed Destroyable services, and the not-found/invalid-alias errors. Extend the scope tests: return value, cloned-copy inheritance of cached services, shared readonly services, inherited parent bindings, non-leaking scope bindings, parent restoration after an exception, and deep nesting. Add scope isolation through the real pipeline under #[RunInFiber(RoundRobin)] and concurrently under #[RunInRevolt(PerCase)], plus hand-driven fiber cases adapted from Spiral's Core scope tests (an exception injected at a suspension point caught inside a scope, an uncaught one propagating out, and many fibers interleaving on a shared container). Supporting stubs and a small fiber driver included. Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mockery's mock container is process-global static state, so — unlike Testo's fiber-local guards — it cannot be isolated per fiber. Drop the child-fiber trampoline (which deadlocked against a real event loop) and run the test directly: one-at-a-time execution keeps the global container unambiguous, so Mockery now works under #[RunInFiber(Solo)] and across a real #[RunInRevolt] await. Interleaving stays unsupported: a static in-flight flag detects a second Mockery test starting while another is parked mid-mock and rejects it with a new MockeryConcurrencyException, rather than letting siblings clobber each other's mocks. Adds self-tests for the supported fiber/loop cases and a Feature test that the interleaved case aborts while the serial one passes. Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fibers/Revolt Prove the fiber-local guards stay isolated when many tests interleave with more than two suspensions each — the property the async migration relies on. - Assert: new stub suites interleave three tests recording distinct assertion counts across per-assertion suspends, on the fiber scheduler (RoundRobin and Random) and on a real Revolt loop (PerCase); the Feature suite reads each test's TestState back off the result and checks the exact count is its own. - Messenger: stubs inject the Messenger, log distinct per-test messages across many suspends, and verify their own buffer both in-fiber and via the captured TestResult messages, under RoundRobin and Revolt PerCase. - Container: the existing scope-isolation cases grow from two tests / one suspend to three tests each re-reading their scoped instance across three suspends. Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Several tests share one Revolt loop (Strategy::PerCase) and each does a few work-and-sleep steps of a different duration, so they interleave and complete in a staggered order. Sleep step lengths live in a class constant. Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Concurrent tests interleave their lifecycle events and output on one stream, and reporters had no way to tell whose output is whose. Introduce a TestIdentity DTO (a random numeric id for now) carried by TestInfo, and thread it to the messenger so every MessageReceived is stamped with the identity of the test it belongs to. - TestIdentity: new @api DTO; TestInfo mints one when none is supplied and keeps it across with(). - MessageReceived gains an optional ?TestIdentity (null for messages that belong to no test). - Messenger::scope() takes an optional identity; MessengerHub binds it on the per-test State, which stamps it on each dispatched MessageReceived and passes it down to forks. OutputInterceptor supplies $info->identity. Because the State is fiber-local, an interleaving test dispatches from its own State and carries its own identity — attribution is settled at the source, not guessed from ordering downstream. Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Under interleaving execution (fibers, a Revolt loop) several tests are in flight at once, and the TeamCity reporter kept a single "current test" scalar — so a sibling starting mid-test clobbered attribution and interleaved testStarted / testStdOut / testFinished collapsed into garbage. Key each reporter's per-test state (pending start, current name, in-batch flag) by TestIdentity::$id instead of a single scalar, attribute streamed output via the identity now carried on MessageReceived, and stamp a flowId (the identity id) on every test- and batch-level service message. TeamCity/IDEs use flowId to demultiplex parallel flows, so interleaved tests are tracked independently. 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 bridge
testo/bridge-revolt— run tests on the process-global Revolt event loop for real async I/O.#[RunInRevolt](method | class, namespaceTesto\Bridge\Revolt) — runs the test body as a microtask on the loop and blocks the current fiber on a RevoltSuspensionuntil it completes, so the test mayawaitreal async work (timers, streams,Future::await(), amphp libraries) without blocking the process. Self-wiring viaInterceptable+#[FallbackInterceptor]; sits atORDER_CLOSE_TO_TESTso Testo's fiber-aware scoped-state guards stay on their synchronous main-fiber path (otherwise the Revolt driver deadlocks resuming a guard-owned fiber).revolt/event-loop: ^1.0. Suspension must go through a RevoltSuspensionbound to a watcher (I/O, timer) — a bare\Fiber::suspend()has no resumer on the loop (that cooperative model is the separatetesto/fiber#[RunInFiber]plugin).testo/bridge-revoltrequire-dev + path-repo dev-alias,testo.phpsuite + exclude;resources/version.jsonseed0.0.0, release-please entry (componentbridge-revolt), split-publish triggerbridge-revolt-[0-9]*.Why?
Testing async code needs a real event loop that owns the fibers so the test can await external events (sockets, timers,
Futures). This is fundamentally different from cooperatively interleaving plain fibers (thetesto/fiberplugin): mixing both in one package forces the impossible "hand-drive a fiber the loop also owns" case (guard/driver deadlock). So the two execution contexts are split —#[RunInFiber](plain fibers, Testo's scheduler) vs#[RunInRevolt](Revolt event loop). This PR adds the latter.Known limitation (documented): only a single test runs on the loop at a time — guards stay synchronous. Interleaving several guarded tests on one shared loop is blocked until Testo's fiber-aware scoped-state guards move to fiber-local storage; that is a separate follow-up (the fiber-local migration).
Checklist
Revolt/Selfsuite — 2 green: runs inside a fiber, awaits a real timer and resumes)Revolt/Selfself-test)bridge/revolt/README.md, attribute docblocks)