From 605f78e1400e6f17bec85ef973b7ba7a492978b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Bernhardt?= Date: Tue, 25 Aug 2026 10:25:45 +0200 Subject: [PATCH 1/2] JavaScriptEventLoop: release the isSpinning latch with defer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- Sources/JavaScriptEventLoop/JobQueue.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Sources/JavaScriptEventLoop/JobQueue.swift b/Sources/JavaScriptEventLoop/JobQueue.swift index a0f2c4bbb..6c91af25d 100644 --- a/Sources/JavaScriptEventLoop/JobQueue.swift +++ b/Sources/JavaScriptEventLoop/JobQueue.swift @@ -42,6 +42,15 @@ extension JavaScriptEventLoop { func runAllJobs() { assert(queueState.isSpinning) + // `defer`, so the latch is released even if a job throws. + // + // `runSynchronously` can unwind — a Swift runtime trap, or (under + // JavaScriptKit specifically) a JS exception crossing back into wasm. + // Clearing `isSpinning` only by falling off the end of the loop leaves + // 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 } while let job = self.claimNextFromQueue() { #if compiler(>=5.9) @@ -50,8 +59,6 @@ extension JavaScriptEventLoop { job._runSynchronously(on: self.asUnownedSerialExecutor()) #endif } - - queueState.isSpinning = false } func claimNextFromQueue() -> UnownedJob? { From 8c6ab0b784fa007bb3eabd359db560f6bc6f2944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Bernhardt?= Date: Sat, 29 Aug 2026 10:32:38 +0200 Subject: [PATCH 2/2] Release the isSpinning latch before running jobs, not after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/JavaScriptEventLoop/JobQueue.swift | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Sources/JavaScriptEventLoop/JobQueue.swift b/Sources/JavaScriptEventLoop/JobQueue.swift index 6c91af25d..e8a568594 100644 --- a/Sources/JavaScriptEventLoop/JobQueue.swift +++ b/Sources/JavaScriptEventLoop/JobQueue.swift @@ -42,15 +42,19 @@ extension JavaScriptEventLoop { func runAllJobs() { assert(queueState.isSpinning) - // `defer`, so the latch is released even if a job throws. + // Release the latch BEFORE running any job, so `isSpinning` means "a + // drain microtask is pending" rather than "a drain is in progress". // - // `runSynchronously` can unwind — a Swift runtime trap, or (under - // JavaScriptKit specifically) a JS exception crossing back into wasm. - // Clearing `isSpinning` only by falling off the end of the loop leaves - // 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 } + // `runSynchronously` can unwind without running Swift cleanup — a + // runtime trap lowers to `unreachable`, and a JS exception crossing + // back into wasm unwinds the frames outright. Neither runs a `defer`. + // Clearing the latch only after the loop therefore leaves it stuck at + // `true` on those paths, and `insertJobQueue` never schedules another + // drain: the executor is dead for the lifetime of the process, + // silently. Clearing it up front costs one extra drain microtask per + // batch that enqueues, and an unwinding job leaves the queue merely + // undrained rather than unreachable — the next enqueue picks it up. + queueState.isSpinning = false while let job = self.claimNextFromQueue() { #if compiler(>=5.9)