diff --git a/wasi-shims/src/io.ts b/wasi-shims/src/io.ts index 6965010..8f7252e 100644 --- a/wasi-shims/src/io.ts +++ b/wasi-shims/src/io.ts @@ -42,6 +42,11 @@ import { suspending, WitError } from "@deltic/runtime/embedder"; +/** The engine setTimeout ceiling: delays above 2^31-1 ms are clamped to + * ~0 (node/Deno warn and fire at 1 ms). `Pollable.timer` sleeps in + * chunks of at most this and re-checks the clock at each chunk end. */ +const TIMER_CHUNK_MAX_MS = 2 ** 31 - 1; + /** A p2 `stream-error` value (variant): `closed` or `last-operation-failed`. */ export type StreamErrorValue = | { tag: "closed" } @@ -98,16 +103,28 @@ export class Pollable { /** * A pollable that becomes ready at `deadline` (nanoseconds on the - * caller's clock). The timer is armed lazily on first wait and shared - * across waiters; `ready()` consults the clock, so it is exact even if - * the setTimeout fires early/late by a tick. + * caller's clock). One in-flight sleep is shared by concurrent waiters + * and RE-ARMED after every settle with the delta recomputed: + * `ready()` consults the clock, so an early-firing sleep (timer slop, + * or the engine's setTimeout ceiling below) hands the wait loop a + * fresh sleep for the remainder instead of a permanently-resolved + * promise — the cached-forever arm was a hot microtask livelock for + * any deadline past the ceiling (block/poll re-check `ready()` and + * re-await; awaiting an already-settled promise never yields to the + * timer that would make it ready). + * + * Engines clamp setTimeout delays above 2^31-1 ms to ~0 (node/Deno + * warn and use 1 ms), so far deadlines sleep in ceiling-sized chunks; + * each chunk end re-checks the clock and re-arms. */ static timer(deadlineNs: bigint, nowNs: () => bigint): Pollable { let armed: Promise | undefined; const wait = (): Promise => { - return armed ??= new Promise((resolve) => { + return armed ??= new Promise((resolve) => { const deltaMs = Number(deadlineNs - nowNs()) / 1e6; - setTimeout(resolve, Math.max(0, deltaMs)); + setTimeout(resolve, Math.min(Math.max(0, deltaMs), TIMER_CHUNK_MAX_MS)); + }).then(() => { + armed = undefined; }); }; return new Pollable(() => nowNs() >= deadlineNs, wait); diff --git a/wasi-shims/tests/blocking_test.ts b/wasi-shims/tests/blocking_test.ts index 8c48ba4..4d7a85f 100644 --- a/wasi-shims/tests/blocking_test.ts +++ b/wasi-shims/tests/blocking_test.ts @@ -86,3 +86,50 @@ Deno.test("kernel: poll on an empty list traps (unbranded throw)", () => { assertTrue(raised instanceof Error, `expected a throw, got ${raised}`); assertTrue(!(raised instanceof Promise), "trap is synchronous"); }); + +Deno.test({ + name: "kernel: a timer's wait re-arms after an early fire (no resolved-promise spin)", + // The re-armed 5ms sleep (and nothing to cancel it through the WIT + // surface) outlives the test on purpose; timers are fire-and-forget. + sanitizeOps: false, + fn: async () => { + // Force an early fire deterministically with an injected clock: the + // sleep is computed from `nowNs` (5ms) but the clock never advances, + // so the chunk ends with `ready()` still false — the shape of timer + // slop and of the setTimeout ceiling clamp. Pre-fix, the armed promise + // was cached forever: the second wait() returned it already-settled + // and block()'s re-check loop degenerated to a hot microtask spin. + const frozen = 1_000_000n; + const p = Pollable.timer(frozen + 5_000_000n, () => frozen); // +5ms, clock frozen + await p.waitPromise(); // first arm fires with ready() still false + assertEq(p.ready(), false, "clock is frozen: still unready"); + const second = p.waitPromise(); + let settled = false; + second.then(() => (settled = true)); + await sleep(0); // drain microtasks: a cached resolved promise settles here + assertEq(settled, false, "second wait() is a fresh, pending sleep"); + }, +}); + +Deno.test({ + name: "kernel: a far deadline sleeps in chunks instead of spinning on the clamp", + // The in-flight ceiling-sized chunk sleep outlives the test on purpose. + sanitizeOps: false, + fn: async () => { + // Past the ~2^31-1 ms setTimeout ceiling engines clamp the delay to + // ~0. Pre-fix (clamp + cached arm) the wait was permanently settled a + // tick after the first arm — block()/poll() then spun the microtask + // queue for the full 60 days. Post-fix each chunk is a real sleep, so + // the wait promise is still pending well after the clamp would have + // fired. (60 days keeps Number(bigint) exact; u64-sentinel deadlines + // take the same path via Math.min.) + const farNs = nowNs() + 60n * 24n * 3600n * 1_000_000_000n; // +60 days + const p = Pollable.timer(farNs, nowNs); + assertEq(p.ready(), false); + const wait = p.waitPromise(); + let settled = false; + wait.then(() => (settled = true)); + await sleep(30); // >> the ~1ms clamp fire + assertEq(settled, false, "far-deadline wait is still parked after 30ms"); + }, +});