fix(promise): record error before signalling channel in all()#4
Conversation
The Swoole Coroutine adapter's all() rejection handler pushed to the coordination channel before assigning $error. The awaiting coroutine could drain the channel and evaluate the still-null $error, causing all() to resolve instead of reject — violating the documented "rejects when any input promise rejects" contract. Branches that reject after a coroutine yield (the realistic async case) reliably expose the ordering. Assign $error before pushing, matching the ordering already used by the success handler and by allSettled(). Adds an E2e regression covering an async branch that rejects after yielding alongside a slower-resolving sibling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile SummaryThis PR fixes a timing-dependent bug in
Confidence Score: 5/5Safe to merge — the change is a minimal, targeted reorder of two lines with a deterministic regression test that fails before the fix and passes after it. The all() fix is correct and tightly scoped. The incidental fixes (undefined PromiseException in race(), dead-PID cleanup in Process, Sync tick simplification) are all improvements with no regressions. The new E2e test exercises the exact failure mode described in the PR. No files require special attention. Important Files Changed
Reviews (2): Last reviewed commit: "fix: resolve PHPStan level-max errors in..." | Re-trigger Greptile |
Benchmark ResultsSwoole Adapters (Sync, Swoole Thread, Swoole Process, Amp, React)ext-parallel Adapter (Sync, Amp, React, ext-parallel)
|
During pool shutdown, workers that already exited and were reaped by Swoole's own signal handling are no longer waitable, so the wait loop ran to its 5s timeout and then SIGKILLed their stale PIDs. SwooleProcess::kill() emits an E_WARNING on a dead PID, and PHPUnit's error handler converts that into an uncaught NoTestCaseObjectOnCallStackException when it fires from __destruct()/the shutdown function (no TestCase on the call stack), crashing the Swoole Process suite. Probe liveness with kill($pid, 0) — which sends no signal and does not warn when the PID is gone — to drop already-reaped workers from the wait set and to guard the timeout SIGKILL. This removes the warning and also eliminates the wasted 5s timeout per shutdown (suite drops from ~2m to ~1s). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CodeQL job runs `composer check` (PHPStan level max) and reported 14 errors: - Coroutine::race() rejected with `new PromiseException(...)`, a class that does not exist — an empty-array race would fatal at runtime. Use the imported Utopia\Async\Exception\Promise, matching the other handlers. - Timer\Adapter uses `new static()` in getInstance(); annotate the base class @phpstan-consistent-constructor as Promise\Adapter already does. - Sync timer carried a $running array that was always in lockstep with $timers, so the analyzer proved its `?? false` guards redundant. Drop the redundant state and drive the tick loop off the existing doExists() check. - Timer tests asserted assertIsInt() on values already typed int and compared `$count >= 1` after `$count++` (always true). Remove the redundant assertions and clear the tick on first fire. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Problem
The Swoole
Coroutineadapter'sall()rejection handler signals the coordinationChannelbefore assigning$error:The awaiting coroutine's
while ($ticks--) $channel->pop()loop can drain the channel and evaluateif ($error !== null)while$erroris stillnull, soall()resolves instead of rejecting — violating the documented contract ("rejects when any input promise rejects").This is timing-dependent: branches that reject after a coroutine yield (the realistic async case, e.g. an API call that sleeps then throws) reliably expose it, especially under scheduler pressure from other concurrent coroutines.
Fix
Assign
$errorbefore pushing to the channel — matching the ordering already used by the success handler inall()and by both handlers inallSettled().Test
Adds an E2e regression (
testAllRejectsWhenABranchRejectsAfterYielding) with an async branch that sleeps then throws alongside a slower-resolving sibling. It fails deterministically against the old ordering (all()resolves) and passes with the fix. Full Swoole Promise E2e suite: 24/24 green. Pint PSR-12 clean.🤖 Generated with Claude Code