Skip to content

JavaScriptEventLoop: release the isSpinning latch before running jobs - #809

Open
mansbernhardt wants to merge 2 commits into
swiftwasm:mainfrom
mansbernhardt:fix/jobqueue-isspinning-defer
Open

JavaScriptEventLoop: release the isSpinning latch before running jobs#809
mansbernhardt wants to merge 2 commits into
swiftwasm:mainfrom
mansbernhardt:fix/jobqueue-isspinning-defer

Conversation

@mansbernhardt

@mansbernhardt mansbernhardt commented Aug 25, 2026

Copy link
Copy Markdown

JobQueue.runAllJobs() clears queueState.isSpinning only as its final statement:

func runAllJobs() {
    assert(queueState.isSpinning)

    while let job = self.claimNextFromQueue() {
        job.runSynchronously(on: self.asUnownedSerialExecutor())
    }

    queueState.isSpinning = false   // ← not reached if a job unwinds
}

If runSynchronously unwinds, that line never runs and isSpinning stays true. insertJobQueue schedules a drain only when !isSpinning:

if !queueState.isSpinning {
    self.queueState.isSpinning = true
    JavaScriptEventLoop.shared.queueMicrotask { self.runAllJobs() }
}

So after a single unwound job the queue is never drained again — every later enqueue appends 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: queueTask is implemented as promise.then { job() }, so the escaping error rejects a discarded promise — it surfaces as an unhandled rejection, never as window.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 AsyncAlgorithms merge precondition, but the origin doesn't matter) left every Swift Task permanently 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 isSpinning means "a drain microtask is pending" rather than "a drain is in progress". An unwinding job then leaves the queue undrained but unlatched, and the next insertJobQueue schedules a fresh drain — including for jobs already queued behind it.

An earlier revision of this PR used defer. That was wrong: defer runs for a Swift throw, 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 Task enqueued afterwards ever runs. 5/5 runs per cell.

JobQueue control JS exception Swift trap
0.57.0 as-is alive dead dead
+ defer alive dead dead
release before the loop alive alive alive

Costs 2 extra drain microtasks per 20,000 await hops (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 runAllJobs takes 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 Task still runs. That's a new shape for this repo (Runtime/test is 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-jobqueue branch (PriorityQueue.swift), so it would ship again unless fixed there too.

`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 }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@mansbernhardt mansbernhardt changed the title JavaScriptEventLoop: release the isSpinning latch with defer JavaScriptEventLoop: release the isSpinning latch before running jobs Aug 29, 2026
@mansbernhardt

Copy link
Copy Markdown
Author

You're right. Measured it:

JobQueue control JS exception Swift trap
0.57.0 alive dead dead
+ defer alive dead dead
release latch before the loop alive alive alive

Standalone repro under Node, 5/5 runs per cell. And on wasm32 a defer runs for a Swift throw but not for a precondition failure — so it was simply the wrong instrument.

Pushed the early-release version instead. isSpinning now means "a drain microtask is pending" rather than "a drain is running", so an unwind leaves the queue unlatched and the next insertJobQueue reschedules. Recovers jobs queued behind the unwinding one too. Costs 2 extra microtasks per 20k await hops; suite unchanged at 194/194 + 13/13.

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 runAllJobs takes the Node process down and ~9 sibling tests with it, identically with and without the fix. Asserting the invariant directly fails too: "isSpinning is false while a job runs" is false even when fixed, since any enqueuing job legitimately re-arms it. A real test needs an out-of-process harness — Runtime/test is JS-only against a stubbed instance, Examples/* are built but never run. Happy to add one wherever you'd want it; the repro is ~50 lines of Swift plus a ~40-line Node driver.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants