diff --git a/Sources/JavaScriptEventLoop/JobQueue.swift b/Sources/JavaScriptEventLoop/JobQueue.swift index a0f2c4bbb..e8a568594 100644 --- a/Sources/JavaScriptEventLoop/JobQueue.swift +++ b/Sources/JavaScriptEventLoop/JobQueue.swift @@ -42,6 +42,19 @@ extension JavaScriptEventLoop { func runAllJobs() { assert(queueState.isSpinning) + // 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 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) @@ -50,8 +63,6 @@ extension JavaScriptEventLoop { job._runSynchronously(on: self.asUnownedSerialExecutor()) #endif } - - queueState.isSpinning = false } func claimNextFromQueue() -> UnownedJob? {