Skip to content

node:wasi: write 32-byte poll_oneoff event records - #39077

Closed
robobun wants to merge 1 commit into
mainfrom
farm/67f9f5ec/wasi-poll-oneoff-event-size
Closed

robobun wants to merge 1 commit into
mainfrom
farm/67f9f5ec/wasi-poll-oneoff-event-size

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • node:wasi's poll_oneoff writes each event as userdata (8) + errno (2) + type (1) + 5 bytes of padding and advances the output pointer by 16 (src/js/node/wasi.ts, the sout += 8/2/1/5 sequences in both the clock arm and the fd_read/fd_write arm, lines 1538 to 1560 on main).
  • A preview1 event is 32 bytes, so the guest reads events[i] from out + 32 * i. With two or more subscriptions, event 1 is written at out + 16, which is events[0].fd_readwrite, and the memory of events[1] is never written: the guest sees whatever was there before.
  • Two or more subscriptions is the normal case: wasi-libc's poll() and select() submit the fd subscriptions plus one clock subscription for the timeout. A single subscription (sleep()) hides the bug, which is why hello-wasi.wasm style tests never caught it.
  • Subscriptions are already read with the correct 48-byte stride; only the event records are wrong.

Fix

  • The two arms of the subscription switch now only compute the errno for the event; one block after the switch writes the whole record (userdata @0, errno @8, type @10, fd_readwrite.nbytes @16 and .flags @24, both zero) and advances sout by 32.
  • 32 is the size of event in the preview1 ABI (sizeof(__wasi_event_t) == 32 in wasi-libc, UVWASI_SERDES_SIZE_event_t == 32 in the uvwasi serdes Node uses to write these records), so this is also what Node's node:wasi produces.
  • Zeroing fd_readwrite matches what uvwasi reports for an fd event that carries an errno (this implementation always reports fd events as ENOSYS); clock events ignore the field, so zeroing it there only makes the output deterministic.
  • Writing the record once after the switch is behavior preserving: both arms wrote userdata, errno and type and bumped nevents, and the clock arm's type byte was the case label, i.e. type. It also leaves one place that encodes the record layout.
  • Test: test/js/bun/wasm/wasi.test.js, "poll_oneoff writes one 32-byte preview1 event record per subscription". Four subscriptions (fd_read, clock, fd_write, clock with an invalid clockid) cover both arms and the EINVAL clock path; the event area plus one spare record is pre-filled with 0xaa, then the four decoded records are compared with one toEqual and the spare record is checked to still be 0xaa. Fails on the released build (event 0 reports nbytes: 2n, slots 2 and 3 are still 0xaa), passes with this change.
  • Also checked end to end with a freestanding wasm guest that submits an fd_read plus clock subscription and dumps the event buffer: fixed build puts event 1 at +32 with fd_readwrite zeroed (output below).
  • The waitTimeNs -= Bun.nanoseconds() - timeOrigin BigInt TypeError a few lines further down is a separate bug, fixed in node:wasi: fix poll_oneoff TypeError on clock subscriptions #34470; this change does not touch those lines. node: implement node:wasi v26 WASI class surface (+23 tests) #35709 (a larger node:wasi rework, currently a draft against a non-main branch) contains the same stride fix as part of its diff.

Background

  • WASI preview1 (wasi_snapshot_preview1) is the syscall ABI that wasm programs built with wasi-libc, Rust's wasm32-wasip1 target, etc. import; node:wasi implements those imports on top of the host. Structs are passed through the guest's linear memory at fixed little-endian offsets, so the host has to write exactly the layout the guest's C headers describe.
  • poll_oneoff(in, out, nsubscriptions, nevents) is the one blocking primitive in preview1: in points at nsubscriptions 48-byte subscription records (userdata u64, type u8, then a clock or fd payload), out at a guest-allocated array of nsubscriptions 32-byte event records (userdata u64 @0, errno u16 @8, type u8 @10, fd_readwrite { nbytes u64 @16, flags u16 @24 }), and the host stores the number of records it filled in through nevents. wasi-libc builds sleep(), poll() and select() on top of it.
  • Bun's implementation reports every subscription as an event immediately: clock subscriptions as success (sleeping for the longest timeout afterwards), fd subscriptions as ENOSYS. This PR does not change that; it only fixes the shape of what gets written.
Freestanding wasm guest (fd_read on fd 0 + monotonic clock, timeout 0), event buffer pre-filled with 0xaa

Released 1.4.0:

nevents: 2
event[0] @+0:  { userdata: 1n, error: 52, type: 1, nbytes: 2n, flags: 0 }
event[1] @+32: { userdata: 12297829382473034410n, error: 43690, type: 170, nbytes: 12297829382473034410n, flags: 43690 }

With this change:

nevents: 2
event[0] @+0:  { userdata: 1n, error: 52, type: 1, nbytes: 0n, flags: 0 }
event[1] @+32: { userdata: 2n, error: 0, type: 0, nbytes: 0n, flags: 0 }

Node 26 running the same guest also lays its records out 32 bytes apart (it reports only the clock event, since uvwasi actually polls the fd; that difference is pre-existing and out of scope here).

poll_oneoff wrote each event as userdata, errno and type followed by
5 bytes of padding and advanced the output pointer by 16 bytes. A
preview1 event is 32 bytes (fd_readwrite follows at offset 16), and
guests read events[i] at out + 32 * i, so with more than one
subscription the second event was written into the first event's
fd_readwrite field and the second event's slot was never written.

Write the whole record (with fd_readwrite zeroed) in one place after
the subscription switch and advance by 32.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9b12ad43-ec51-46f6-87c2-a4ed664b7cd7

📥 Commits

Reviewing files that changed from the base of the PR and between 88a6398 and 69aa9cb.

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

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix and test pushed, waiting on CI.

Reproduced on the released build by calling wasiImport.poll_oneoff with an fd_read plus a clock subscription: event 0's fd_readwrite.nbytes came back as event 1's userdata (2n) and the slot at out + 32 was untouched. Same result end to end with a freestanding wasm guest. test/js/bun/wasm/wasi.test.js ("poll_oneoff writes one 32-byte preview1 event record per subscription") fails on the released build and passes with this branch.

@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.

LGTM — corrects the poll_oneoff event stride from 16 to the preview1-mandated 32 bytes and consolidates the two duplicated write blocks into one.

What was reviewed:

  • Verified the refactor is behavior-preserving apart from the stride: the clock arm's type byte was already WASI_EVENTTYPE_CLOCK, the default arm still early-returns before the shared write, and moving shortPause() before the write is inert.
  • Checked field offsets (userdata@0, errno@8, type@10, nbytes@16, flags@24, size 32) against __wasi_event_t / uvwasi serdes.
  • Confirmed the test avoids the separate waitTimeNs BigInt/Number bug by using a 0ns relative timeout, and avoids the 50ms stdin pause via a no-op sleep.
  • Padding bytes (11-15, 26-31) are left as-is rather than zeroed — that's fine per ABI (guests don't read padding) and not a regression.
Extended reasoning...

Overview

This PR fixes a layout bug in src/js/node/wasi.ts's poll_oneoff implementation: event records were being written with a 16-byte stride instead of the 32 bytes the WASI preview1 ABI specifies for __wasi_event_t. With ≥2 subscriptions (the common case for wasi-libc poll()/select()), event N+1 was written into event N's fd_readwrite field and the guest read garbage from the actual event N+1 slot. The fix hoists the record write out of the two switch arms into one block that writes all five fields at their spec offsets and advances sout by 32. A new test in test/js/bun/wasm/wasi.test.js exercises four subscriptions covering fd_read, fd_write, a valid clock, and an invalid clockid, with the output area pre-filled with 0xaa so both under-write and over-write show up.

Security risks

None. This is a serialization-layout fix inside the JS-implemented WASI shim. The guest already controls the sout pointer into its own linear memory; the change only alters which offsets within the guest-provided buffer are written and by how much the pointer advances. No host filesystem, credential, or sandbox-escape surface is touched.

Level of scrutiny

Low-to-medium. The diff is ~20 net lines in a self-contained function, mechanically derived from the published preview1 struct layout (cross-referenced against wasi-libc's sizeof(__wasi_event_t) == 32 and uvwasi's UVWASI_SERDES_SIZE_event_t == 32). I traced each removed write against the new shared block: the clock arm previously wrote WASI_EVENTTYPE_CLOCK as the type byte, which equals type inside that case; the fd arm previously wrote WASI_ENOSYS as errno, now assigned to e; nevents += 1 and the sin - last_sin != 48 sanity check are preserved; the default: early-return means e is never read uninitialized. Reordering shortPause() to before the write is harmless — it only calls this.sleep and touches no memory or pointers.

Other factors

  • The test is well-constructed: 0ns relative timeout keeps waitTimeNs at 0 so the known BigInt-minus-Number bug a few lines below (explicitly called out as separate, #34470) is not tripped; sleep() {} neutralizes the stdin shortPause(); the spare 32-byte record after the last event guards against overrun; assertions are exact-value toEqual on all five decoded fields.
  • No CODEOWNERS entry covers src/js/node/wasi.ts.
  • The only stylistic nit is that padding bytes at offsets 11-15 and 26-31 remain whatever the guest left there rather than being zeroed; the ABI treats them as padding and the old code left far more garbage, so this is not a regression and not worth blocking on.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

On the padding bytes (11 to 15 and 26 to 31): leaving them alone is deliberate. The ABI treats them as padding, and Node's node:wasi (uvwasi serdes) also writes only the fields; dumping the raw event buffer after running the same guest under Node 26 shows the pre-fill pattern still in those bytes, just like with this branch. Zeroing them would be extra writes without a guest that could observe the difference.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:14 PM PT - Aug 15th, 2026

🔄 @robobun, the build for your commit 69aa9cb0 (Build #98170) was cancelled — waiting for the next build...

@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #43749 replaces this PR. It carries the same change (the 32-byte event records, and the test of this PR) on the current src/js/node/wasi.ts. It also fixes the clock wait in the same function. This branch conflicts with main since #43649 moved every line of that file.

@robobun robobun closed this Sep 22, 2026
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