feat(fiber): coroutine scope — Coroutine::spawn()/await()/concurrently() - #277
Open
roxblnfk wants to merge 9 commits into
Open
feat(fiber): coroutine scope — Coroutine::spawn()/await()/concurrently()#277roxblnfk wants to merge 9 commits into
roxblnfk wants to merge 9 commits into
Conversation
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 25 out of 25 changed files in this pull request and generated no new comments.
Suppressed comments (2)
tests/Core/Pipeline/CacheTest.php:49
- Assertion argument order is inconsistent within this file (e.g.,
Assert::same([MockInterceptor::class], $result)vsAssert::same($result, [])). Even though equality is symmetric, inconsistent ordering makes failure output harder to interpret. Please standardize the argument order across these tests (following the project's convention) so diffs and assertion messages remain clear.
public function resolveAliasesWithFallbackInterceptorAttribute(): void
{
$result = Cache::resolveAliases(AttributeWithFallback::class);
Assert::same([MockInterceptor::class], $result);
}
/**
* A repeated FallbackInterceptor attribute wires every listed interceptor, in declaration order.
*/
public function resolveAliasesCollectsRepeatedFallbacks(): void
{
$result = Cache::resolveAliases(AttributeWithSeveralFallbacks::class);
Assert::same([MockInterceptor::class, SecondMockInterceptor::class], $result);
}
/**
* When resolveAliases is called with a class that has no FallbackInterceptor attribute,
* it should return an empty list.
*/
public function resolveAliasesWithoutFallbackInterceptorAttribute(): void
{
$result = Cache::resolveAliases(AttributeWithoutFallback::class);
Assert::same($result, []);
}
plugin/fiber/src/Internal/CoroutineScopeInterceptor.php:98
- Status handling is hard-coded to
Failed/Errorwhen deciding whether to preserve the body's status. Since earlier logic already usesStatus::isFailure(), consider reusing that here (or a dedicated 'severity' comparison) to avoid missing other failure-like statuses if they exist and to keep the logic consistent.
if ($errors !== []) {
// A failed body keeps its own failure as the root: chain it in front of the coroutine
// errors so nothing is dropped, and keep the harsher of the two statuses.
$result->failure === null or $errors = [$body->id => $result->failure] + $errors;
$failed = $result->status === Status::Failed || $result->status === Status::Error;
$result = $result
->with(status: $failed ? $result->status : Status::Error)
->withFailure(new CompositeException($errors));
}
An Interceptable attribute may now wire several interceptors, each with its own pipeline position: Cache::resolveAliases() collects every FallbackInterceptor attribute (walking the parent chain as before), and InterceptorProvider instantiates each resolved class with the attribute. Needed by testo/fiber, where #[RunInFiber] wraps the test pipeline in a fiber outside the scoped-state guards and opens the coroutine scope inside them. Assisted-By: Claude Fable 5 <noreply@anthropic.com>
Every #[RunInFiber] test now runs inside its own coroutine scope: the test body is task #0 of a per-test scheduler, and Coroutine::spawn() adds coroutines to the same round-robin schedule. Between rounds the scope relays control upward with a suspend of its own, so coroutines keep interleaving with the case's other tests under a class-level #[RunInFiber]. - Scheduler rewritten from a static one-shot into a dynamic instance: tasks may be spawned mid-drive, await parks a task until its target settles, and Scheduler::current() exposes the ambient scope to the Coroutine helpers. - The scope is structured: pending coroutines are driven after the body returns; a failed body cancels them (CancelledException is thrown into each pending fiber); an await cycle is broken with a DeadlockException raised at the first parked await(). - Coroutine failures always surface wrapped in CompositeException — even a single one — via await()/concurrently() or at scope close for unawaited ones (the test is marked Error). The body's own throw stays unwrapped. - The scope lives in the new CoroutineScopeInterceptor at ORDER_CLOSE_TO_TEST — inside the scoped-state guards, so coroutines are resumed with their test's assertion/messenger state swapped in — while RunInFiberInterceptor keeps the fiber wrap outside the guards; both are wired by the same attribute via the now-repeatable #[FallbackInterceptor]. Assisted-By: Claude Fable 5 <noreply@anthropic.com>
The pipeline below the coroutine scope captures test throwables into the TestResult, so a failed body settled with no error on its task and Scheduler::drive() never saw the failure — pending coroutines were driven to completion instead of being cancelled as documented. drive() now takes a failure predicate for the primary task, and CoroutineScopeInterceptor passes one that recognizes a failed result (Status::isFailure()), so the scope tears down as the docs promise. Assisted-By: Claude Fable 5 <noreply@anthropic.com>
An await cycle crossing schedulers — a scope's coroutine awaiting another scope's through a shared handle — was invisible to the local deadlock check: every scope saw its parked tasks as possibly unparkable by the outer schedule and relayed forever, livelocking the run. The check now walks the awaiting links themselves, which naturally cross scheduler boundaries: a chain that runs into a cycle can never be unparked by any schedule, so the first doomed task gets the DeadlockException; a chain that ends outside a cycle still relays. Each scheduler only ever throws into its own tasks, so coroutines are still resumed exclusively from their own scope's drive frame. The deadlock message marks foreign links as "another scope's" — task ids are per-scheduler and would collide unqualified. Assisted-By: Claude Fable 5 <noreply@anthropic.com>
A task spawned during the scope teardown — e.g. from a cancelled coroutine's finally block — silently joined a schedule nobody drives anymore: never stepped, never reported. The scheduler now marks itself closing when it starts cancelling pending tasks and rejects further spawns with a LogicException, which surfaces through the unwinding coroutine's error instead of vanishing. Assisted-By: Claude Fable 5 <noreply@anthropic.com>
A cancelled task settled with finished=true and a null error, so await() returned null — indistinguishable from a legitimate null result for a sibling's finally unwinding on the same cancellation. The task now remembers it was cancelled, and await() rethrows a CancelledException instead of forging a result. The exception is deliberately unwrapped: cancellation is the scope's control signal, not a failure raised by the coroutine, so the CompositeException contract does not apply — matching how the scope already keeps cancellations out of the surfaced errors. Assisted-By: Claude Fable 5 <noreply@anthropic.com>
concurrently() returned results keyed by the argument keys but bundled failures keyed by the scheduler's internal task ids — with named arguments the error's origin was untraceable, and with positional ones the ids looked like argument indices while being off by one (the test body is task #0). Each await() composite wraps exactly one task's error; concurrently() now unwraps it and re-keys it by the argument, making $errors symmetric to the results. CompositeException accepts string keys (named arguments) and prints them as-is in the message, keeping the #N form for int keys. Assisted-By: Claude Fable 5 <noreply@anthropic.com>
…ure() CacheTest mixed expected-first and actual-first Assert::same() calls; the facade's signature is same($actual, $expected), so the inverted calls produced swapped "expected X, got Y" messages on failure. All calls now pass the actual value first. CoroutineScopeInterceptor spelled out Failed/Error where an isFailure() call away the same interceptor already uses the enum's own check — one place to update if a failure-like status is ever added. Assisted-By: Claude Fable 5 <noreply@anthropic.com>
roxblnfk
force-pushed
the
feat/fiber-coroutines
branch
from
August 8, 2026 11:25
ffd69b2 to
5b38214
Compare
…apping tests Assisted-By: Claude Fable 5 <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
Every
#[RunInFiber]test now runs inside its own coroutine scope: the test body is task #0 of a per-test scheduler, andCoroutine::spawn()/->await()/Coroutine::concurrently()add coroutines to the same schedule. Between rounds the scope relays control outward, so coroutines keep interleaving with the case's other tests. The scope sits at the newORDER_ASYNC_COROUTINE— inside both the scoped-state guards and the coverage window — so a coroutine's assertions, messages and covered lines all belong to the test that spawned it. Supporting core change:#[FallbackInterceptor]is now repeatable, so one attribute can wire interceptors at two positions.See commit history for details.
Why?
Concurrency was only expressible between whole tests. Racing two workers, or a client against a server, inside one test meant driving fibers by hand — with no scheduling, no result plumbing and no failure reporting.
Checklist