fix(signals): settle async-generator actions on uncaught errors instead of freezing - #2847
fix(signals): settle async-generator actions on uncaught errors instead of freezing#2847brenelz wants to merge 1 commit into
Conversation
…ad of freezing When an async generator's body throws, `it.next()` returns a rejected promise with the generator already completed. The action runner responded by calling `it.throw(e)`, which on a completed async generator just returns another rejected promise — producing an infinite microtask loop that starved the event loop and left the iterator in the transition's `_actions` forever. A rejected iterator result is terminal (async generators run user try/catch/finally internally before rejecting), so settle the action via `done(undefined, e)` instead: the returned promise rejects, the iterator is removed from `_actions`, and the transition can complete. Fixes solidjs#2841 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
|
Diagnosis and fix are both exactly right — a rejected iterator-result promise from an async generator is terminal (the body's error already escaped, user We verified the freeze on the current tree (the repro genuinely hangs a vitest worker), confirmed no conflict with the unhandled-error-halts philosophy (this is the action promise contract, which sync generators already honored — not an effect-queue error), and landed your diff as-is with a changeset, credited as co-author: |
…ad of freezing (#2841, from #2847) A rejected iterator-result promise (async generators) is terminal: the error already escaped the generator body and it is completed, so it.throw() only returns another rejected promise — the old handler re-threw forever, starving the event loop in a microtask loop and never removing the iterator from the transition's _actions. Settle via done() instead, mirroring the sync catch path. User try/catch inside async generators still works (the generator machinery handles it before the runner ever sees a rejection) and the sync-generator throw path for yielded promise rejections is unchanged. Co-authored-by: Brenley Dueck <brenelz@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Fixes #2841 — an uncaught error inside an async-generator
action()froze the JS thread: the returned promise never settled, the event loop was starved by an infinite microtask loop, and the transition never completed because the iterator was never removed from_actions.Root cause
When an async generator's body throws,
it.next()returns a rejected promise with the generator already in the completed state. The action runner responded to that rejection by callingit.throw(e)— butthrow()on a completed async generator just returns another rejected promise, whose rejection handler calledit.throw(e)again, forever. Each iteration is a microtask, so the loop starves the event loop (frozen tab / hung process). Sync generators were unaffected because theirit.next()throws synchronously, which was already handled.Fix
A rejected iterator-result promise is terminal: async generators run user
try/catch/finallyinternally before the rejection ever reaches the runner. So the rejection handler now settles the action viadone(undefined, e)— mirroring how the fulfilled path handlesdone: true. The returned promise rejects, the iterator is removed from the transition's_actions, and the transition can complete.try/catcharoundyield/awaitinside async generators still works (handled by the generator machinery itself), and the sync-generatorit.throwpath for yielded promise rejections is unchanged.Tests
Four regression tests in
action.test.ts:await/yieldrejectstry/catchinside an async generator around an awaited rejection recovers and continuesFull solid-signals suite passes (818 tests, 49 files).
🤖 Generated with Claude Code