Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions wasi-shims/src/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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<void> | undefined;
const wait = (): Promise<void> => {
return armed ??= new Promise((resolve) => {
return armed ??= new Promise<void>((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);
Expand Down
47 changes: 47 additions & 0 deletions wasi-shims/tests/blocking_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
},
});
Loading