JavaScriptEventLoop: release the isSpinning latch with defer - #809
Open
mansbernhardt wants to merge 1 commit into
Open
JavaScriptEventLoop: release the isSpinning latch with defer#809mansbernhardt wants to merge 1 commit into
mansbernhardt wants to merge 1 commit 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.
kateinoigakukun
requested changes
Aug 25, 2026
| // 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 } |
Member
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?
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.
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
defer { queueState.isSpinning = false }restores the invariant on every exit path. No behaviour change on the normal path.Two notes for whoever picks this up:
generalize-jobqueuebranch (PriorityQueue.swiftin that rewrite has the identical trailing assignment), so it would ship again unless fixed there too.Happy to add a regression test if you'd like one, and to shape it however you prefer — a job that traps is awkward to assert on portably, so I didn't want to guess at the house style.