Skip to content

node:wasi: fix poll_oneoff with more than one subscription - #43749

Open
robobun wants to merge 1 commit into
mainfrom
robobun/b9451688/wasi-poll-oneoff-earliest-clock
Open

robobun wants to merge 1 commit into
mainfrom
robobun/b9451688/wasi-poll-oneoff-earliest-clock

Conversation

@robobun

@robobun robobun commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • poll_oneoff with several clocks waits for the longest timeout and reports every clock. For 5 ms and 2 s, Bun returns after 2001 ms with nevents=2, Node v26.3.0 after 5 ms with nevents=1. The clock arm (src/js/node/wasi.ts:1557) keeps the maximum wait.
  • Event records are 16 bytes apart. A preview1 event is 32 bytes. wasi-libc poll() with a timeout reads events[1] from bytes that nothing wrote.
  • Zero subscriptions return success. uvwasi returns EINVAL, which wasi-libc poll() relies on.

Fix

  • Keep the clock with the earliest deadline, sleep until then, and write one clock event. The first wins a tie, as in uvwasi_poll_oneoff.
  • Write the events after the loop, 32 bytes apart, with fd_readwrite zeroed. This replaces node:wasi: write 32-byte poll_oneoff event records #39077. Return EINVAL for zero subscriptions, before any write.
  • Verified: test/js/bun/wasm/wasi.test.js. Five new tests fail on main.
  • Self-reviewed: 10 concerns raised, 10 addressed. Each asked for one PR for this function.

Background

  • node:wasi implements WASI preview1, the system call ABI of wasi-libc programs. Node uses the uvwasi C library.
  • poll_oneoff is its blocking call. A subscription is a clock timeout or an fd. The host writes an event for each one that occurs, and the count in nevents.
  • Bun does not poll fds. An fd subscription reports ENOSYS and the wait continues. Node reports a ready fd at 0 ms. The tests pin this.

Downsides

  • wasi-libc poll() with no fds and no timeout now fails with ENOTSUP, as on Node. It returned 0 before.
  • poll() on a ready fd still waits the full timeout.
Notes

How this was found: by a read of poll_oneoff next to uvwasi_poll_oneoff, then a run under Node. No user reported it. wasi-libc sleep(), poll() and select(), Rust std and mio send at most one clock subscription, so the clock selection only matters to a guest that sends several timers in one call. The record layout matters to every guest that sends two or more subscriptions.

No release contains a working clock wait. Before #43649, every positive clock timeout threw the TypeError of #20857, and no tag contains #43649. So no released program depends on the wait for the longest clock.

End to end check. A byte-crafted preview1 module re-exports poll_oneoff. Real sleep, relative monotonic clocks, Linux x64. Records are read at out + 32 * i.

subscriptions                            node v26.3.0               main a2b69f7b0                     this PR
clock 60ms, clock 30ms, clock 90ms       30 ms, 1 event: 30ms       103 ms, nevents=3                  48 ms, 1 event: 30ms
clock 5ms (0x1111), clock 2s (0x2222)    5 ms, 1 event: 0x1111      2001 ms, nevents=2                 6 ms, 1 event: 0x1111
clock 30ms (0x1111), clock 30ms (0x2222) 30 ms, 1 event: 0x1111     30 ms, nevents=2                   30 ms, 1 event: 0x1111
clock 60ms, fd_write stdout, clock 30ms  0 ms, 1 event: fd, err 0   61 ms, nevents=3, [1] is a clock   30 ms, 2 events: fd ENOSYS, clock 30ms
fd_write stdout, clock 1s (libc poll)    0 ms, 1 event: fd, err 0   1000 ms, nevents=2, [1] unwritten  1002 ms, 2 events: fd ENOSYS, clock
zero subscriptions                       errno 28, nevents kept     errno 0, nevents=0                 errno 28, nevents kept

"[1] unwritten" means that the bytes at out + 32 still hold the 0xaa fill. The first call of a debug build is slow, which explains 48 ms in row 1.

Differences from Node that stay after this PR:

  • fd subscriptions. Bun reports ENOSYS for each one and still waits for the clock. Node polls the fd, reports a ready fd with error 0, and then does not report the clock. fd polling is a separate feature.
  • Clock id. Bun reports EINVAL in the event of a clock subscription with an unknown clock id. uvwasi ignores the clock id.
  • Absolute deadline in the past. Bun does not wait, and that clock is the earliest. uvwasi computes timeout - now on unsigned values (uvwasi.c#L2559), so the wait wraps to centuries and a later relative clock fires first.
  • Two absolute clocks with the same deadline. Both implementations read the clock once per subscription. Bun reads nanoseconds, so the later subscription has the smaller wait and wins (200 of 200 runs). uvwasi reads microseconds, so the two waits are usually equal and the first subscription wins (17 of 20 runs). The rule that the first subscription wins a tie holds for relative timeouts.
  • fd_readwrite of a clock event. uvwasi writes these bytes only for fd events. Bun zeroes them for every event, so each record is deterministic. A guest must ignore the field for a clock event.
  • Unused records. The binding of Node writes all nsubscriptions records, zeroed past nevents (src/node_wasi.cc). Bun writes only the nevents records.

The sock_pollSocket hook condition changes from nevents == 2 to nsubscriptions == 2. Before this PR the two values were always equal at that point, so the hook runs for the same inputs.

tsc --noEmit -p src/js/tsconfig.json, oxlint and prettier --check pass on the two files.

Zero subscriptions: wasi-libc ppoll() calls poll_oneoff even when it built no subscription (no fds, no timeout). It expects EINVAL from the host and turns it into ENOTSUP (libc-bottom-half/sources/ppoll.c). With success and nevents=0, that poll() returned 0 at once.

Cost per call: one array, and one small object per reported event. poll_oneoff is a blocking call, so this cost does not show.

Related PRs:


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/wasm/wasi.test.js

poll_oneoff kept the longest wait of all the clock subscriptions and wrote a
success event for every one of them. One call with a 5 ms and a 2 s timer
blocked for 2 s and reported two events. Node (uvwasi) blocks for 5 ms and
reports the 5 ms timer only. Keep the clock subscription with the earliest
deadline, sleep until that deadline, and write one clock event.

The event records were 16 bytes apart. A preview1 event is 32 bytes, so a
guest read events[1] from memory that poll_oneoff never wrote. Write the
records 32 bytes apart and zero fd_readwrite.

Return EINVAL for zero subscriptions, as uvwasi does. wasi-libc poll() relies
on that error when it has nothing to wait for.
@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduction: run the script below with node --no-warnings repro.mjs and with bun repro.mjs. It byte-crafts a preview1 module that re-exports poll_oneoff, because Node has no public way to set the memory of a WASI instance.

Output of the row "clocks 5ms/2s" on Linux x64:

node v26.3.0     errno=0 elapsedMs=5    nevents=1 events=[{ud 0x1111, err 0, type 0}]
main a2b69f7b0   errno=0 elapsedMs=2001 nevents=2
this PR          errno=0 elapsedMs=6    nevents=1 events=[{ud 0x1111, err 0, type 0}]

The PR body has the table for all six rows.

Tests: bun bd test test/js/bun/wasm/wasi.test.js. The five new poll_oneoff tests fail with the src/js/node/wasi.ts of main and pass on this branch.

repro.mjs
// Runs under `node --no-warnings` and under bun. Byte-crafts a preview1 module that re-exports poll_oneoff as f0.
import { WASI } from "node:wasi";
function craft(imps) {
  const u = x => { const a = []; do { let c = x & 127; x >>>= 7; if (x) c |= 128; a.push(c); } while (x); return a; };
  const str = s => [...u(s.length), ...[...s].map(c => c.charCodeAt(0))];
  const sec = (id, b) => [id, ...u(b.length), ...b];
  const N = imps.length, T = { i: 0x7f, j: 0x7e };
  const types = [...u(N + 1), ...imps.flatMap(([, s]) => [0x60, s.length, ...[...s].map(c => T[c]), 1, 0x7f]), 0x60, 0, 0];
  const imports = [...u(N), ...imps.flatMap(([n], k) => [...str("wasi_snapshot_preview1"), ...str(n), 0, k])];
  const funcs = [...u(N + 1), ...imps.map((_, k) => k), N];
  const exps = [...u(N + 2), ...imps.flatMap((_, k) => [...str("f" + k), 0, N + k]), ...str("_start"), 0, 2 * N, ...str("memory"), 2, 0];
  const codes = imps.map(([, s], k) => { const c = [0, ...[...s].flatMap((_, j) => [0x20, j]), 0x10, k, 0x0b]; return [...u(c.length), ...c]; });
  const code = [...u(N + 1), ...codes.flat(), 2, 0, 0x0b];
  return new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, ...sec(1, types), ...sec(2, imports), ...sec(3, funcs), ...sec(5, [1, 0, 1]), ...sec(7, exps), ...sec(10, code)]);
}
const w = new WASI({ version: "preview1" });
const i = new WebAssembly.Instance(new WebAssembly.Module(craft([["poll_oneoff", "iiii"]])),
  typeof Bun !== "undefined" ? { wasi_snapshot_preview1: w.wasiImport } : w.getImportObject());
w.start(i);
const D = new DataView(i.exports.memory.buffer);
const B = new Uint8Array(i.exports.memory.buffer);
const sin = 512, sout = 1024, neventsPtr = 128;
const clock = (userdata, timeoutNs, { flags = 0, clockid = 1 } = {}) => o => {
  D.setBigUint64(o, userdata, true);
  D.setUint8(o + 8, 0);
  D.setUint32(o + 16, clockid, true);
  D.setBigUint64(o + 24, timeoutNs, true);
  D.setBigUint64(o + 32, 0n, true);
  D.setUint16(o + 40, flags, true);
};
const fdsub = (userdata, type, fd) => o => {
  D.setBigUint64(o, userdata, true);
  D.setUint8(o + 8, type);
  D.setUint32(o + 16, fd, true);
};
function run(name, subs) {
  B.fill(0, sin, sin + 48 * (subs.length + 1));
  B.fill(0xaa, sout, sout + 32 * (subs.length + 1));
  B.fill(0xaa, neventsPtr, neventsPtr + 4);
  subs.forEach((s, k) => s(sin + 48 * k));
  const t0 = performance.now();
  const errno = i.exports.f0(sin, sout, subs.length, neventsPtr);
  const elapsedMs = Math.round(performance.now() - t0);
  const nevents = D.getUint32(neventsPtr, true);
  const events = [];
  for (let k = 0; k < Math.min(nevents, subs.length); k++) {
    const at = sout + 32 * k;
    events.push(`{ud 0x${D.getBigUint64(at, true).toString(16)}, err ${D.getUint16(at + 8, true)}, type ${D.getUint8(at + 10)}}`);
  }
  console.log(`${name}: errno=${errno} elapsedMs=${elapsedMs} nevents=${nevents === 0xaaaaaaaa ? "untouched" : nevents} events=[${events.join(", ")}]`);
}
const ms = n => BigInt(n) * 1_000_000n;
run("clocks 60ms/30ms/90ms", [clock(0x1111n, ms(60)), clock(0x2222n, ms(30)), clock(0x3333n, ms(90))]);
run("clocks 5ms/2s", [clock(0x1111n, ms(5)), clock(0x2222n, ms(2000))]);
run("tie 30ms/30ms", [clock(0x1111n, ms(30)), clock(0x2222n, ms(30))]);
run("clock 60ms, fd_write stdout, clock 30ms", [clock(0x1111n, ms(60)), fdsub(0x2222n, 2, 1), clock(0x3333n, ms(30))]);
run("libc poll(): fd_write stdout, clock 1s", [fdsub(0x1n, 2, 1), clock(0x2n, ms(1000))]);
run("zero subscriptions", []);

@coderabbitai

coderabbitai Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: da2304ba-73cb-48b1-9216-857f2903e543

📥 Commits

Reviewing files that changed from the base of the PR and between 2f9bf19 and 39c2143.

📒 Files selected for processing (2)
  • src/js/node/wasi.ts
  • test/js/bun/wasm/wasi.test.js

Included review availability: Your plan provides up to 10 included reviews per hour; 2 remain after this review.


Walkthrough

poll_oneoff now validates subscriptions, queues clock and file descriptor events, selects the earliest clock deadline, serializes 32-byte event records, and preserves output memory for zero subscriptions. Tests cover ordering, layouts, errors, mixed subscriptions, and unchanged memory.

Changes

WASI poll_oneoff behavior

Layer / File(s) Summary
Subscription validation and event collection
src/js/node/wasi.ts
poll_oneoff rejects zero subscriptions, selects the earliest clock deadline, preserves invalid-clock errors, and queues FD events as WASI_ENOSYS.
Event serialization and polling
src/js/node/wasi.ts
Queued events are serialized as 32-byte WASI records. The event count is written from the queue. Socket polling checks for exactly two subscriptions.
Behavioral test coverage
test/js/bun/wasm/wasi.test.js
Tests cover clock ordering, event layouts, FD and clock errors, mixed subscriptions, zero subscriptions, and unchanged output memory.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 39c21

This updates WASI poll_oneoff behavior and adds coverage for the changed event handling without any identified merge-blocking risk.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: fixing node:wasi poll_oneoff behavior with multiple subscriptions.
Description check ✅ Passed The description clearly explains the problem, implementation, scope, testing, verification commands, and known limitations. It does not use the template headings verbatim, but it provides the required…

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs; since it changes the observable behavior of poll_oneoff for existing guests (zero subscriptions now returns EINVAL, multi-clock calls now fire only the earliest clock) and I could not run the test file here, a human look is still worthwhile.

What was reviewed:

  • Traced the rewritten clock-selection and event-serialization path (src/js/node/wasi.ts:1519-1637) against all five new test inputs by hand — each yields exactly the asserted events and sleep durations; the relative-clock tie is deterministic because (tNS + timeout) - tNS is exact BigInt arithmetic.
  • Checked the sock_pollSocket gate change from nevents == 2 to nsubscriptions == 2 — before this PR every non-EINVAL arm pushed one event, so the two were always equal at that line.
  • Checked the early default: return WASI_EINVAL and the unchanged wait loop — no partial writes to guest memory happen before an EINVAL return now, which is stricter than before.
  • The 32-byte record layout and first-wins tie rule match the cited uvwasi source lines; the one deliberate divergence (negative wait for a past absolute deadline) is documented inline.
Extended reasoning...

The change rewrites the poll_oneoff import in the built-in node:wasi module (about 40 lines of logic) and adds five unit tests that hand-encode subscriptions into a WebAssembly.Memory and assert exact event records. It touches no security-sensitive surface; guest-memory writes are bounded by the DataView, which throws RangeError on out-of-range offsets exactly as the old inline writes did. The code matches the cited uvwasi behavior and the tests are precise and can fail. Deferring rather than approving because the PR introduces user-visible behavior changes for existing guests (EINVAL on zero subscriptions turns a wasi-libc poll() with nothing to wait on from returning 0 into ENOTSUP), because the PR's own evidence note says the test file was not executed before pushing, and because I could not execute it in this environment either.

@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

The evidence note in the PR body defers the test proof to CI. The test file did run before the push. These are the results on Linux x64 (debug build with ASAN) at 39c2143.

With src/js/node/wasi.ts from main (git checkout origin/main -- src/js/node/wasi.ts), then bun bd test test/js/bun/wasm/wasi.test.js:

(pass) poll_oneoff waits on a clock subscription and reports the event
(fail) poll_oneoff > waits for the earliest clock and reports only that clock
(fail) poll_oneoff > reports the first of two clocks with the same timeout
(fail) poll_oneoff > writes one 32-byte event record per reported subscription
(fail) poll_oneoff > reports an fd subscription next to the clock that fired
(fail) poll_oneoff > returns EINVAL for zero subscriptions and writes nothing
 7 pass
 5 fail

With the branch:

 12 pass
 0 fail

The two behavior changes for existing guests:

@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:48 PM PT - Sep 21st, 2026

✅ @robobun, your commit 39c2143505a0eec3d6e23ab8c9709966029c2d0d passed in Build #119520! 🎉


🧪   To try this PR locally:

bunx bun-pr 43749

That installs a local version of the PR into your bun-43749 executable, so you can run:

bun-43749 --bun

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant