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] 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? {