diff --git a/src/js/node/wasi.ts b/src/js/node/wasi.ts index f0f65b9723fc..5e867cf0b3aa 100644 --- a/src/js/node/wasi.ts +++ b/src/js/node/wasi.ts @@ -1517,8 +1517,16 @@ class WASI { return WASI_ESUCCESS; }), poll_oneoff: (sin, sout, nsubscriptions, neventsPtr) => { + // wasi-libc poll() relies on this error when it has nothing to wait for: + // https://github.com/nodejs/node/blob/v26.3.0/deps/uvwasi/src/uvwasi.c#L2534-L2537 + if (nsubscriptions == 0) { + return WASI_EINVAL; + } const startNs = BigInt(bindings.hrtime()); - let nevents = 0; + const events: { userdata: bigint; error: number; type: number }[] = []; + // Of the clock subscriptions, only the one with the earliest deadline fires. The first one wins a tie: + // https://github.com/nodejs/node/blob/v26.3.0/deps/uvwasi/src/uvwasi.c#L2564-L2568 + let timerEvent = -1; let waitTimeNs = BigInt(0); let fd = -1; let fd_type = "read"; @@ -1546,26 +1554,24 @@ class WASI { if (!absolute) { fd_timeout_ms = timeout / BigInt(1e6); } - let e = WASI_ESUCCESS; const t = now(clockid); if (t == null) { - e = WASI_EINVAL; + events.push({ userdata, error: WASI_EINVAL, type }); } else { const tNS = BigInt(t); const end = absolute ? timeout : tNS + timeout; + // Negative for an absolute deadline in the past, so that clock fires at once. uvwasi subtracts + // unsigned values there and waits for centuries (uvwasi.c#L2559). const waitNs = end - tNS; - if (waitNs > waitTimeNs) { + if (timerEvent === -1 || waitNs < waitTimeNs) { + if (timerEvent !== -1) { + events.splice(timerEvent, 1); + } + timerEvent = events.length; + events.push({ userdata, error: WASI_ESUCCESS, type }); waitTimeNs = waitNs; } } - this.view.setBigUint64(sout, userdata, true); - sout += 8; - this.view.setUint16(sout, e, true); - sout += 2; - this.view.setUint8(sout, WASI_EVENTTYPE_CLOCK); - sout += 1; - sout += 5; - nevents += 1; break; } case WASI_EVENTTYPE_FD_READ: @@ -1574,14 +1580,7 @@ class WASI { fd_type = type == WASI_EVENTTYPE_FD_READ ? "read" : "write"; sin += 4; sin += 28; - this.view.setBigUint64(sout, userdata, true); - sout += 8; - this.view.setUint16(sout, WASI_ENOSYS, true); - sout += 2; - this.view.setUint8(sout, type); - sout += 1; - sout += 5; - nevents += 1; + events.push({ userdata, error: WASI_ENOSYS, type }); if (fd == WASI_STDIN_FILENO && WASI_EVENTTYPE_FD_READ == type) { this.shortPause(); } @@ -1600,8 +1599,18 @@ class WASI { } last_sin = sin; } - this.view.setUint32(neventsPtr, nevents, true); - if (nevents == 2 && fd >= 0) { + // event: userdata u64 @0, error u16 @8, type u8 @10, fd_readwrite { nbytes u64 @16, flags u16 @24 }. 32 bytes: + // https://github.com/nodejs/node/blob/v26.3.0/deps/uvwasi/include/wasi_serdes.h#L123 + for (const { userdata, error, type } of events) { + this.view.setBigUint64(sout, userdata, true); + this.view.setUint16(sout + 8, error, true); + this.view.setUint8(sout + 10, type); + this.view.setBigUint64(sout + 16, BigInt(0), true); + this.view.setUint16(sout + 24, 0, true); + sout += 32; + } + this.view.setUint32(neventsPtr, events.length, true); + if (nsubscriptions == 2 && fd >= 0) { const r = this.wasiImport.sock_pollSocket(fd, fd_type, fd_timeout_ms); if (r != WASI_ENOSYS) { return r; diff --git a/test/js/bun/wasm/wasi.test.js b/test/js/bun/wasm/wasi.test.js index 8d1bd1c02bab..2979d12934ca 100644 --- a/test/js/bun/wasm/wasi.test.js +++ b/test/js/bun/wasm/wasi.test.js @@ -1,5 +1,5 @@ import { spawnSync } from "bun"; -import { expect, it } from "bun:test"; +import { describe, expect, it } from "bun:test"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import fs from "node:fs"; import path from "node:path"; @@ -205,6 +205,143 @@ it("poll_oneoff waits on a clock subscription and reports the event", () => { }); }); +describe("poll_oneoff", () => { + const WASI_ESUCCESS = 0; + const WASI_EINVAL = 28; + const WASI_ENOSYS = 52; + const WASI_EVENTTYPE_CLOCK = 0; + const WASI_EVENTTYPE_FD_READ = 1; + const WASI_EVENTTYPE_FD_WRITE = 2; + const WASI_CLOCK_MONOTONIC = 1; + const WASI_STDIN_FILENO = 0; + const WASI_STDOUT_FILENO = 1; + const SUBSCRIPTION_SIZE = 48; + const EVENT_SIZE = 32; + const subscriptionPtr = 1024; + const eventPtr = 2048; + const neventsPtr = 4096; + const untouched = new Uint8Array(EVENT_SIZE).fill(0xaa); + + const clock = (userdata, timeout, clockid = WASI_CLOCK_MONOTONIC) => ({ + userdata, + type: WASI_EVENTTYPE_CLOCK, + clockid, + timeout, + }); + const fdRead = (userdata, fd) => ({ userdata, type: WASI_EVENTTYPE_FD_READ, fd }); + const fdWrite = (userdata, fd) => ({ userdata, type: WASI_EVENTTYPE_FD_WRITE, fd }); + + // One poll_oneoff call on a new WASI. The injected sleep() records the requested wait and returns at once, so a long + // timeout costs nothing. + function poll(subscriptions) { + const sleeps = []; + const wasi = new WASI({ sleep: ms => sleeps.push(ms) }); + wasi.setMemory(new WebAssembly.Memory({ initial: 1 })); + const view = new DataView(wasi.memory.buffer); + const bytes = new Uint8Array(wasi.memory.buffer); + + subscriptions.forEach((subscription, i) => { + // subscription: userdata u64 @0, type u8 @8, then clock { id u32 @16, timeout u64 (ns) @24, precision u64 @32, + // flags u16 @40 } or fd_readwrite { fd u32 @16 } + const at = subscriptionPtr + i * SUBSCRIPTION_SIZE; + view.setBigUint64(at, subscription.userdata, true); + view.setUint8(at + 8, subscription.type); + if (subscription.type === WASI_EVENTTYPE_CLOCK) { + view.setUint32(at + 16, subscription.clockid, true); + view.setBigUint64(at + 24, subscription.timeout, true); + } else { + view.setUint32(at + 16, subscription.fd, true); + } + }); + // 0xaa shows each byte that poll_oneoff does not write: the nevents slot, and one spare record after the records + // of all the subscriptions. + bytes.fill(0xaa, neventsPtr, neventsPtr + 4); + bytes.fill(0xaa, eventPtr, eventPtr + (subscriptions.length + 1) * EVENT_SIZE); + + const errno = wasi.wasiImport.poll_oneoff(subscriptionPtr, eventPtr, subscriptions.length, neventsPtr); + const nevents = view.getUint32(neventsPtr, true); + // event: userdata u64 @0, error u16 @8, type u8 @10, fd_readwrite { nbytes u64 @16, flags u16 @24 } + const events = Array.from({ length: Math.min(nevents, subscriptions.length) }, (_, i) => { + const at = eventPtr + i * EVENT_SIZE; + return { + userdata: view.getBigUint64(at, true), + error: view.getUint16(at + 8, true), + type: view.getUint8(at + 10), + nbytes: view.getBigUint64(at + 16, true), + flags: view.getUint16(at + 24, true), + }; + }); + const spare = eventPtr + subscriptions.length * EVENT_SIZE; + return { errno, sleeps, nevents, events, spare: bytes.slice(spare, spare + EVENT_SIZE) }; + } + + it("waits for the earliest clock and reports only that clock", () => { + // The shortest timeout is neither the first nor the last subscription. + const { errno, sleeps, events } = poll([ + clock(0x1111n, 60_000_000_000n), + clock(0x2222n, 30_000_000_000n), + clock(0x3333n, 90_000_000_000n), + ]); + expect(errno).toBe(WASI_ESUCCESS); + expect(sleeps).toHaveLength(1); + expect(sleeps[0]).toBeGreaterThan(29_000); + expect(sleeps[0]).toBeLessThanOrEqual(30_000); + expect(events).toEqual([ + { userdata: 0x2222n, error: WASI_ESUCCESS, type: WASI_EVENTTYPE_CLOCK, nbytes: 0n, flags: 0 }, + ]); + }); + + it("reports the first of two clocks with the same timeout", () => { + const { errno, events } = poll([clock(0x1111n, 30_000_000_000n), clock(0x2222n, 30_000_000_000n)]); + expect(errno).toBe(WASI_ESUCCESS); + expect(events).toEqual([ + { userdata: 0x1111n, error: WASI_ESUCCESS, type: WASI_EVENTTYPE_CLOCK, nbytes: 0n, flags: 0 }, + ]); + }); + + it("writes one 32-byte event record per reported subscription", () => { + // A guest reads events[i] from out + 32 * i. fd polling is not implemented, so each fd subscription reports ENOSYS, + // and a clock id that does not exist reports EINVAL. Node polls the fds and ignores the clock id. + const { errno, events, spare } = poll([ + fdRead(1n, WASI_STDIN_FILENO), + clock(2n, 0n), + fdWrite(3n, WASI_STDOUT_FILENO), + clock(4n, 0n, 1234), + ]); + expect(errno).toBe(WASI_ESUCCESS); + expect(events).toEqual([ + { userdata: 1n, error: WASI_ENOSYS, type: WASI_EVENTTYPE_FD_READ, nbytes: 0n, flags: 0 }, + { userdata: 2n, error: WASI_ESUCCESS, type: WASI_EVENTTYPE_CLOCK, nbytes: 0n, flags: 0 }, + { userdata: 3n, error: WASI_ENOSYS, type: WASI_EVENTTYPE_FD_WRITE, nbytes: 0n, flags: 0 }, + { userdata: 4n, error: WASI_EINVAL, type: WASI_EVENTTYPE_CLOCK, nbytes: 0n, flags: 0 }, + ]); + expect(spare).toEqual(untouched); + }); + + it("reports an fd subscription next to the clock that fired", () => { + const { errno, sleeps, events } = poll([ + clock(0x1111n, 60_000_000_000n), + fdWrite(0x2222n, WASI_STDOUT_FILENO), + clock(0x3333n, 30_000_000_000n), + ]); + expect(errno).toBe(WASI_ESUCCESS); + // The ENOSYS event of the fd subscription does not end the wait. Node returns at once with the ready fd only. + expect(sleeps).toHaveLength(1); + expect(sleeps[0]).toBeGreaterThan(29_000); + expect(sleeps[0]).toBeLessThanOrEqual(30_000); + expect(events).toEqual([ + { userdata: 0x2222n, error: WASI_ENOSYS, type: WASI_EVENTTYPE_FD_WRITE, nbytes: 0n, flags: 0 }, + { userdata: 0x3333n, error: WASI_ESUCCESS, type: WASI_EVENTTYPE_CLOCK, nbytes: 0n, flags: 0 }, + ]); + }); + + it("returns EINVAL for zero subscriptions and writes nothing", () => { + const { errno, sleeps, nevents, spare } = poll([]); + expect({ errno, sleeps, nevents }).toEqual({ errno: WASI_EINVAL, sleeps: [], nevents: 0xaaaaaaaa }); + expect(spare).toEqual(untouched); + }); +}); + it("fd_pread counts each byte once and reads the next iov from where the last one ended", () => { using dir = tempDir("wasi-fd-pread", { "data.txt": "0123456789abcdef" }); const wasi = new WASI({ preopens: { "/": String(dir) } });