Repository navigation
Conversation
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.
|
Reproduction: run the script below with Output of the row "clocks 5ms/2s" on Linux x64: The PR body has the table for all six rows. Tests: 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", []); |
|
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 configurationConfiguration used: Repository: oven-sh/bun/.coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 10 included reviews per hour; 2 remain after this review. Walkthrough
ChangesWASI poll_oneoff behavior
Suggested reviewers: Priority: ⬇️ Low Merge Risk: ⚪ Minimal · up to 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)
Comment |
There was a problem hiding this comment.
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) - tNSis exact BigInt arithmetic. - Checked the
sock_pollSocketgate change fromnevents == 2tonsubscriptions == 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_EINVALand 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.
|
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 With the branch: The two behavior changes for existing guests:
|
|
Updated 8:48 PM PT - Sep 21st, 2026
✅ @robobun, your commit 39c2143505a0eec3d6e23ab8c9709966029c2d0d passed in 🧪 To try this PR locally: bunx bun-pr 43749That installs a local version of the PR into your bun-43749 --bun |
Problem
poll_oneoffwith several clocks waits for the longest timeout and reports every clock. For 5 ms and 2 s, Bun returns after 2001 ms withnevents=2, Node v26.3.0 after 5 ms withnevents=1. The clock arm (src/js/node/wasi.ts:1557) keeps the maximum wait.eventis 32 bytes. wasi-libcpoll()with a timeout readsevents[1]from bytes that nothing wrote.EINVAL, which wasi-libcpoll()relies on.Fix
uvwasi_poll_oneoff.fd_readwritezeroed. This replaces node:wasi: write 32-byte poll_oneoff event records #39077. ReturnEINVALfor zero subscriptions, before any write.test/js/bun/wasm/wasi.test.js. Five new tests fail on main.Background
node:wasiimplements WASI preview1, the system call ABI of wasi-libc programs. Node uses the uvwasi C library.poll_oneoffis its blocking call. Asubscriptionis a clock timeout or an fd. The host writes aneventfor each one that occurs, and the count innevents.ENOSYSand the wait continues. Node reports a ready fd at 0 ms. The tests pin this.Downsides
poll()with no fds and no timeout now fails withENOTSUP, 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_oneoffnext touvwasi_poll_oneoff, then a run under Node. No user reported it. wasi-libcsleep(),poll()andselect(), 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
TypeErrorof #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 atout + 32 * i."[1] unwritten" means that the bytes at
out + 32still 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:
ENOSYSfor 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.EINVALin the event of a clock subscription with an unknown clock id. uvwasi ignores the clock id.timeout - nowon unsigned values (uvwasi.c#L2559), so the wait wraps to centuries and a later relative clock fires first.fd_readwriteof 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.nsubscriptionsrecords, zeroed pastnevents(src/node_wasi.cc). Bun writes only theneventsrecords.The
sock_pollSockethook condition changes fromnevents == 2tonsubscriptions == 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,oxlintandprettier --checkpass on the two files.Zero subscriptions: wasi-libc
ppoll()callspoll_oneoffeven when it built no subscription (no fds, no timeout). It expectsEINVALfrom the host and turns it intoENOTSUP(libc-bottom-half/sources/ppoll.c). With success andnevents=0, thatpoll()returned 0 at once.Cost per call: one array, and one small object per reported event.
poll_oneoffis a blocking call, so this cost does not show.Related PRs:
wasi.tsfrom before Typecheck the built-in modules (src/js) in CI #43649, where every line of the file moved. Its test is here as "writes one 32-byte event record per reported subscription".neventsto be 2. Node reports 1 event for that input, and so does this PR.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