deno
49c38256 - fix(core): prevent shared-buffer timer expiry race from losing timers (#35312)

Commit
71 days ago
fix(core): prevent shared-buffer timer expiry race from losing timers (#35312) This restores the timer expiry handoff changed in #32844 from the shared `Float64Array` buffer back to the older return-value protocol. The shared-buffer protocol wrote the next timer expiry during `__eventLoopTick(...)`, then continued resolving completed async ops. Promise hooks or op-resolution side effects could synchronously arm or mutate timers after that snapshot was written. Rust then read the stale snapshot after the JS call returned, allowing older timer state to clear, delay, ref, or unref newer timer state. With this change, Rust calls `Deno.core.__processTimers(now)`, immediately commits the returned timer expiry, and only then resolves completed async ops. This removes the stale shared-buffer overwrite class. ## What changed - Restored `Deno.core.__processTimers(now)`. - Simplified `Deno.core.__eventLoopTick(...)` so it only resolves completed async op results. - Removed the shared `Float64Array` timer expiry handoff. - Restored Rust-side scheduling from the `__processTimers(now)` return value: - positive value: next refed timer expiry - negative value: next unrefed-only timer expiry - `0`: no timers remain - Added regression coverage for stale timer expiry states caused by promise hooks during op resolution. - Added coverage for timers armed while `processTimers()` drains microtasks via `runNextTicks()`. ## Why return to the return-value protocol? The shared-buffer design made the JS -> Rust handoff depend on a snapshot that could become stale before Rust consumed it. Covering all timer transitions correctly (`setTimeout`, `clearTimeout`, `refTimer`, and `unrefTimer`) would require the shared state to represent both the next deadline and ref/unref liveness without being overwritten by older snapshots. That is hard to make robust when JS can synchronously mutate timers while async op promises are being resolved in the same event-loop turn. The return-value protocol restores a simpler ordering guarantee: JS processes the currently expired timers, returns the current next expiry, and Rust commits that value immediately before resolving completed async ops. That keeps JS and Rust timer state synchronized at the handoff boundary. The performance tradeoff also looks acceptable. The extra Rust -> JS call can occur only in turns where both a native user timer and completed async ops are ready, and the overlap-focused benchmarks below did not show a stable, perceivable overhead from using the return-value protocol. ## What kind of code could trigger this? ### Reduced repro This uses `Deno.core.setPromiseHooks(...)` only to make the race deterministic: it lets the repro run JS synchronously while an async op promise is being resolved. ```js const core = Deno[Deno.internal]?.core ?? Deno.core; let armHook = false; let scheduled = false; // Run synchronously when a promise is resolved. The hook is initially inert; // the first timer below enables it immediately before the file op resolves. core.setPromiseHooks(null, null, null, () => { if (!armHook || scheduled) return; scheduled = true; console.log("SCHEDULED"); // This is the timer that the old shared-buffer protocol could lose. setTimeout(() => { console.log("FIRED"); }, 1); }); // This expired timer is processed first. In the broken case, it can publish // a stale `0` snapshot via the shared Float64Array: "no next timer is armed". setTimeout(() => { console.log("EXPIRED"); armHook = true; }, 0); // Arrange for an async op promise to resolve in the same event-loop turn. Deno.stat(import.meta.filename).then(() => { console.log("OP_THEN"); }); // Let the zero-delay timer and file op become ready before yielding. const start = performance.now(); while (performance.now() - start < 25) {} ``` On Deno 2.8.3, this locally reproduced the stale-`0` failure shape. In runs where the hook armed the short timer, the broken output was: ```text EXPIRED SCHEDULED OP_THEN # FIRED is missing ``` In a 50-run local sample, this `SCHEDULED`-without-`FIRED` output occurred 34 times. The expired timer publishes `timerExpiry[0] = 0` before `Deno.stat(...)` is resolved. The promise hook then schedules `setTimeout(..., 1)`, but Rust applies the older `0` snapshot after returning from JS and clears the native timer. The newly scheduled timer is left without a native wakeup. With this change, the expected output is: ```text EXPIRED SCHEDULED OP_THEN FIRED ``` ### Real-world scenario A production-shaped scenario was a Deno Deploy build timeout while running concurrent Slidev/Vite/esbuild builds. Several child processes printed successful build output, but the parent sometimes stayed stuck waiting for child status until the build timed out. The core of the relevant application pattern is: ```js const child = new Deno.Command(Deno.execPath(), { cwd: path, args: ["run", "build", "--base", base, "--out", outDir], stdout: "piped", stderr: "piped", }).spawn(); console.log("waiting for child status"); const status = await child.status; console.log(`child status resolved: ${status.code}`); ``` This is not using `setPromiseHooks` directly. It is ordinary child-process orchestration with async op completions under concurrency. Hook-like synchronous side effects may come from runtime internals, Node compatibility, async context, observability, APM, or a test/build harness. If such code arms or mutates a timer while Deno is resolving an async op in the same turn as an expired timer, the old shared-buffer protocol could apply an older timer snapshot afterwards and lose or delay the newer timer state. ## Why this fixes the race Old shared-buffer ordering: ```mermaid sequenceDiagram participant Rust participant JS participant Shared as shared Float64Array Rust->>JS: __eventLoopTick(timerNow, completed op results...) JS->>JS: process expired timers JS->>Shared: write next-expiry snapshot Note over Shared: This snapshot is only current until JS mutates timers again. JS->>JS: resolve completed async ops JS->>JS: promise hooks / op-resolution side effects arm or mutate timers Note over JS,Shared: Any timer armed or changed here can make<br/> the shared snapshot stale. JS-->>Rust: return Rust->>Shared: read older next-expiry snapshot Rust->>Rust: schedule/ref/unref from stale state ``` Restored return-value ordering: ```mermaid sequenceDiagram participant Rust participant JS Rust->>JS: __processTimers(now) JS->>JS: process expired timers JS-->>Rust: return current next expiry Rust->>Rust: immediately schedule/ref/unref from return value Rust->>JS: __eventLoopTick(completed op results...) JS->>JS: resolve completed async ops JS->>JS: promise hooks / op-resolution side effects arm or mutate timers JS-->>Rust: return ``` If op resolution or promise hooks arm new timers, those updates happen after the previous expiry has already been committed, so they cannot be overwritten by a stale shared-buffer snapshot. ## Performance The return protocol can add an extra Rust -> JS call only when both are true in the same event-loop turn: - the native user timer is ready - completed async ops are ready I benchmarked overlap-focused workloads that intentionally exercise that shape. Benchmark environment: - OS: Linux 6.18.24, x86_64 - CPU: Ryzen 9 7940HS - Shared-buffer baseline: 039bcdc6d before the local return-protocol edits - Return-protocol candidate: 039bcdc6d plus the local return-protocol edits <details> <summary>Benchmark workload details (click to expand)</summary> Each benchmark sample ran in a fresh Deno process. The full suite used 3 warmups and 11 measured runs. `mixed_barrier_batch` - 200 rounds. - Each round schedules 100 zero-delay timers and 100 `Deno.stat(...)` async ops. - Total per sample: 20,000 timers and 20,000 async ops. - Purpose: maximize same-turn timer/op overlap. `timeout_guarded_stat` - Two variants per sample: - `long_guard`: 10,000 `Deno.stat(...)` calls guarded by a 1000 ms timeout. - `short_guard`: 10,000 `Deno.stat(...)` calls guarded by a 1 ms timeout. - Total per sample: 20,000 async ops and 20,000 timers created. - Purpose: model production-shaped timeout guards around async I/O. - The table below uses a focused rerun for this row: 5 warmups and 15 measured runs. The original full-suite run showed a median slowdown that did not reproduce in the focused rerun, with similar high run-to-run spread in both candidates. `ready_op_plus_due_timer` - 500 rounds. - Each round schedules 20 zero-delay timers and 20 `Deno.stat(...)` async ops. - Total per sample: 10,000 timers and 10,000 async ops. - Purpose: use more rounds with smaller batches to expose per-turn overhead. `timer_op_chain` - 5,000 iterations. - Each iteration awaits a zero-delay timer, then awaits `Deno.stat(...)`. - Purpose: stress repeated timer/async-op phase transitions. `mixed_high_concurrency_tail` - 10,000 tasks. - Concurrency: 200. - Each task awaits both a zero-delay timer and `Deno.stat(...)`. - Purpose: measure throughput and per-task p50/p95/p99 tail latency under mixed concurrency. Every measured sample completed the expected timer and async-op counts. </details> | benchmark | shared median | return median | delta | shared p95 | return p95 | p95 delta | | --- | ---: | ---: | ---: | ---: | ---: | ---: | | `mixed_barrier_batch` | 621.922 ms | 605.315 ms | -2.67% | 647.809 ms | 645.313 ms | -0.39% | | `timeout_guarded_stat` | 631.525 ms | 610.266 ms | -3.37% | 669.597 ms | 682.193 ms | +1.88% | | `ready_op_plus_due_timer` | 1251.606 ms | 1256.044 ms | +0.35% | 1259.849 ms | 1272.934 ms | +1.04% | | `timer_op_chain` | 11104.208 ms | 11101.130 ms | -0.03% | 11161.269 ms | 11171.279 ms | +0.09% | | `mixed_high_concurrency_tail` | 353.346 ms | 352.605 ms | -0.21% | 374.607 ms | 363.297 ms | -3.02% | These overlap-focused benchmarks do not show a stable, perceivable overhead from the return-value protocol in the mixed timer/async-op shape where the extra JS call can occur.
Author
Parents
Loading