Skip to content

JavaScriptEventLoop: release the isSpinning latch with defer - #809

Open
mansbernhardt wants to merge 1 commit into
swiftwasm:mainfrom
mansbernhardt:fix/jobqueue-isspinning-defer
Open

JavaScriptEventLoop: release the isSpinning latch with defer#809
mansbernhardt wants to merge 1 commit into
swiftwasm:mainfrom
mansbernhardt:fix/jobqueue-isspinning-defer

Conversation

@mansbernhardt

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

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:

  • The same pattern is in the unmerged generalize-jobqueue branch (PriorityQueue.swift in that rewrite has the identical trailing assignment), so it would ship again unless fixed there too.
  • We couldn't find an existing issue for this — apologies if it duplicates something I missed.

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.

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

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