JavaScriptEventLoop: release the isSpinning latch before running jobs - #809
JavaScriptEventLoop: release the isSpinning latch before running jobs#809mansbernhardt wants to merge 2 commits into
Conversation
`runAllJobs()` clears `queueState.isSpinning` only as its final statement, so the flag survives as `true` if a job unwinds. `insertJobQueue` schedules a drain only when `!isSpinning`, so after one unwound job the queue is never drained again: every subsequent `enqueue` appends to a queue nothing will run, for the lifetime of the process. Nothing reports it. The failure is silent and total for asynchronous work, while synchronous calls into the module keep working normally — which makes it present as "async stopped" rather than as a crash. Wrapping the reset in `defer` restores the invariant on every exit path. No behaviour change on the normal path.
| // it latched `true` on that path, and `insertJobQueue` then never | ||
| // schedules another drain: the executor is dead for the lifetime of the | ||
| // process, silently. | ||
| defer { queueState.isSpinning = false } |
There was a problem hiding this comment.
I think for such scenarios where an unwind happens outside of Swift's throws mechanism, the defer block won't be executed, so I don't think this change solves the issue?
The previous revision used `defer`, which does not help: a `defer` runs for a Swift `throw`, but not for a wasm trap (`unreachable`) or a JS exception crossing back into wasm — the only two paths that can strand the latch. Measured on a standalone repro, the `defer` arm wedges exactly like unpatched 0.57.0. Releasing the latch before the drain loop makes `isSpinning` mean "a drain microtask is pending" rather than "a drain is in progress", so an unwinding job leaves the queue merely undrained and the next `insertJobQueue` schedules a fresh drain — recovering jobs already queued behind the one that unwound as well.
|
You're right. Measured it:
Standalone repro under Node, 5/5 runs per cell. And on wasm32 a Pushed the early-release version instead. No regression test, deliberately. Fixed and unfixed differ only under an unwind, and an unwind kills the test host — an XCTest case that throws through |
JobQueue.runAllJobs()clearsqueueState.isSpinningonly as its final statement:If
runSynchronouslyunwinds, that line never runs andisSpinningstaystrue.insertJobQueueschedules a drain only when!isSpinning:So after a single unwound job the queue is never drained again — every later
enqueueappends to a queue nothing will run, for the lifetime of the process.Why it is hard to spot
The failure is silent and total for async work while synchronous entry points keep behaving normally, so it presents as "async stopped" rather than as a crash. In a browser it is worse than that:
queueTaskis implemented aspromise.then { job() }, so the escaping error rejects a discarded promise — it surfaces as an unhandled rejection, never aswindow.onerror. A page in this state answers every synchronous export perfectly and looks healthy.How we hit it
We ship a Swift/wasm app on JavaScriptKit 0.57.0. A trap inside a job (in our case an
AsyncAlgorithmsmergeprecondition, but the origin doesn't matter) left every SwiftTaskpermanently dead while the module kept answering synchronous calls in ~4 ms. It cost us about nine days to attribute, because every measurement of the module said it was fine.We confirmed the mechanism by injection with a control arm: one self-reverting throw from a host import → the next intent call times out at 21,986 ms; without it → 12 ms. We can share that harness if useful.
The fix
Release the latch before the loop, so
isSpinningmeans "a drain microtask is pending" rather than "a drain is in progress". An unwinding job then leaves the queue undrained but unlatched, and the nextinsertJobQueueschedules a fresh drain — including for jobs already queued behind it.An earlier revision of this PR used
defer. That was wrong:deferruns for a Swiftthrow, not for a wasm trap or a JS exception crossing back into wasm — the only two paths that strand the latch. Thanks to @kateinoigakukun for catching it.Measured on a standalone repro (Node, no browser): enqueue one job that unwinds, then ask whether a
Taskenqueued afterwards ever runs. 5/5 runs per cell.deferCosts 2 extra drain microtasks per 20,000
awaithops (work enqueued during a drain is consumed by the running loop without returning, so drains stay rare); no measurable wall-clock change. Existing suite unchanged: 194/194 XCTest + 13/13 swift-testing.On a regression test
There isn't one, deliberately. Fixed and unfixed differ only under an unwind, and an unwind is fatal to any in-process host: an XCTest case that throws through
runAllJobstakes the Node process down and ~9 sibling tests with it, identically with and without the fix. Asserting the invariant directly doesn't work either — "isSpinning is false while a job runs" is false even when fixed, since any job that enqueues legitimately re-arms it.A real test needs an out-of-process harness: build a fixture, run it under Node with a host that tolerates the escaping exception, assert a later
Taskstill runs. That's a new shape for this repo (Runtime/testis JS-only against a stubbed instance;Examples/*are built but never executed), so I didn't want to invent it unasked. Happy to add one wherever you'd want it.The same trailing-assignment pattern is in the unmerged
generalize-jobqueuebranch (PriorityQueue.swift), so it would ship again unless fixed there too.