Skip to content

feat(bridge-revolt): new testo/bridge-revolt — #[RunInRevolt] async tests on the Revolt loop#269

Open
roxblnfk wants to merge 17 commits into
1.xfrom
bridge-revolt
Open

feat(bridge-revolt): new testo/bridge-revolt — #[RunInRevolt] async tests on the Revolt loop#269
roxblnfk wants to merge 17 commits into
1.xfrom
bridge-revolt

Conversation

@roxblnfk

Copy link
Copy Markdown
Member

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, namespace Testo\Bridge\Revolt) — runs the test body as a microtask on the loop and blocks the current fiber on a Revolt Suspension until it completes, so the test may await real async work (timers, streams, Future::await(), amphp libraries) without blocking the process. Self-wiring via Interceptable + #[FallbackInterceptor]; 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).
  • Hard-requires revolt/event-loop: ^1.0. Suspension must go 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 / release: 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]*.

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 (the testo/fiber plugin): 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).

Note: #[RunInRevolt]'s docblock references the sibling #[RunInFiber] (testo/fiber), which lands in a separate PR. The two are independent at runtime — the bridge has no dependency on the fiber plugin.

Checklist

  • No linked issue.
  • Tested
    • Tested manually (ran the Revolt/Self suite — 2 green: runs inside a fiber, awaits a real timer and resumes)
    • Unit tests added (Revolt/Self self-test)
  • Documentation (bridge/revolt/README.md, attribute docblocks)

@roxblnfk
roxblnfk requested a review from a team as a code owner July 21, 2026 20:12
@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

@roxblnfk
roxblnfk force-pushed the bridge-revolt branch 2 times, most recently from 1d9ce61 to e235780 Compare July 24, 2026 09:40
roxblnfk added 3 commits July 24, 2026 15:07
…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>
roxblnfk added 14 commits July 24, 2026 15:51
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>
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.

1 participant