From b6b724a430a0ed475182191c181e04cf59e0734a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:23:32 +0000 Subject: [PATCH 1/7] test(worker): wait for the flood to reach the parent before taking timer turns The "message flood does not starve the parent's event loop" test asserted received > 0 after three 10ms timer turns that started at Worker construction, so it also asserted that the worker had booted and posted within ~30ms. On a debug+ASAN build the first message lands ~150ms after construction and the test fails deterministically with received === 0, which says nothing about starvation. Wait for the first message before taking the timer turns, and after each turn wait for the next message, so the turns are taken while the flood is known to be running and the test no longer depends on boot latency. Under an unbounded drain the timers still never fire, so the test still covers the property it was added for. --- test/js/web/workers/worker.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index 49e7a197ed19..87b6a3d1afe7 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -414,9 +414,17 @@ describe("web worker", () => { const w = new Worker(URL.createObjectURL(new Blob([src]))); let received = 0; w.onmessage = () => received++; + // Booting the worker is not the property under test (on a debug build it takes + // far longer than the timer turns below): start once the flood has reached the parent. + await once(w, "message"); // Three timer turns while the flood is running is the property; not the timing. - for (let i = 0; i < 3; i++) await new Promise(r => setTimeout(r, 10)); - expect(received).toBeGreaterThan(0); + // A message landing after each turn shows the flood was still running during it. + for (let i = 0; i < 3; i++) { + const before = received; + await new Promise(r => setTimeout(r, 10)); + await once(w, "message"); + expect(received).toBeGreaterThan(before); + } w.terminate(); await once(w, "close"); }); From 82687bc5812339c14a45643dbaceacd5ea866e47 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:52:15 +0000 Subject: [PATCH 2/7] test(worker): drop the vacuous received count from the flood test After `await once(w, "message")` the count has necessarily grown, so the assertion could not fail. The turns coming back and a message following each of them is the whole check; the message events are dispatched (and deserialized) by the drain whether or not a handler is installed. --- test/js/web/workers/worker.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index 87b6a3d1afe7..74c0a72a901b 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -412,18 +412,15 @@ describe("web worker", () => { const src = `const p = { s: Buffer.alloc(200, "x").toString(), a: [1, 2, 3], n: 0 }; (function burst() { for (let i = 0; i < 2000; i++) { p.n++; postMessage(p) } setImmediate(burst) })()`; const w = new Worker(URL.createObjectURL(new Blob([src]))); - let received = 0; - w.onmessage = () => received++; // Booting the worker is not the property under test (on a debug build it takes // far longer than the timer turns below): start once the flood has reached the parent. await once(w, "message"); // Three timer turns while the flood is running is the property; not the timing. - // A message landing after each turn shows the flood was still running during it. + // Pinned inside one drain, the parent never gets to the timer; the message awaited + // after each turn shows the flood was still running while the turn was taken. for (let i = 0; i < 3; i++) { - const before = received; await new Promise(r => setTimeout(r, 10)); await once(w, "message"); - expect(received).toBeGreaterThan(before); } w.terminate(); await once(w, "close"); From c09efb20b1cf6a7f5a049fafdc683bf6824ad17c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:16:44 +0000 Subject: [PATCH 3/7] ci: retrigger From d2a836ed7f1e28b6d8364905a3aab730f29a7229 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:10:03 +0000 Subject: [PATCH 4/7] test(worker): build the flood test's backlog up front instead of racing the worker Waiting for the first message removed the boot-latency dependence, but the test still only reached a bounded drain when the worker happened to outrun the parent: never on a debug build (a debug worker posts ~3k messages/s and the parent keeps up), and only some of the time on a release+ASAN build. On those builds a drain with no bound passed it, and where it did detect one the failure was the whole file hanging, since the test runner's timeout lives on the loop being pinned. Have the worker post more messages than one drain task may deliver while the parent is blocked in Atomics.wait, so the first drain task starts with more than its budget on every build. Resuming inside that task and arming an immediate observes exactly what it delivered before yielding: the immediate runs when the task ends, before the continuation the task posted is promoted, and microtasks drain right after it. The flood is finite, so a drain that does not yield now fails as an ordinary assertion (received equals the total) on a debug build too, verified by making the drain unbounded locally, and the rest of the messages arriving proves the yielded continuation still delivers them. --- test/js/web/workers/worker.test.ts | 35 ++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index 74c0a72a901b..c44d35e03cbf 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -407,21 +407,32 @@ describe("web worker", () => { }); // A worker posting faster than the parent can deserialize must not pin the - // parent inside one drain: its timers and I/O still get their turn. + // parent inside one drain: a drain task delivers a bounded batch and posts the + // rest to the next loop iteration, so timers and I/O get their turn in between. test("a message flood from a worker does not starve the parent's event loop", async () => { - const src = `const p = { s: Buffer.alloc(200, "x").toString(), a: [1, 2, 3], n: 0 }; - (function burst() { for (let i = 0; i < 2000; i++) { p.n++; postMessage(p) } setImmediate(burst) })()`; + const total = 1500; // more than one drain task's budget (drainBatchLimit in WorkerMessagingProxy.cpp) + const flag = new Int32Array(new SharedArrayBuffer(4)); + const src = `onmessage = ({ data: flag }) => { + for (let i = 0; i < ${total}; i++) postMessage(i); + Atomics.store(flag, 0, 1); Atomics.notify(flag, 0); + }`; const w = new Worker(URL.createObjectURL(new Blob([src]))); - // Booting the worker is not the property under test (on a debug build it takes - // far longer than the timer turns below): start once the flood has reached the parent. + let received = 0; + const delivered = Promise.withResolvers(); + w.onmessage = () => { + if (++received === total) delivered.resolve(); + }; + w.postMessage(flag); + // Block until the whole flood sits in the parent's inbox, so the first drain task + // starts out with more than its budget no matter how fast either thread is. + expect(Atomics.wait(flag, 0, 0, 30_000)).toBe("ok"); + // Resumes inside the first drain task. An immediate armed there runs as soon as + // that task ends, before the continuation it posted delivers the next batch. await once(w, "message"); - // Three timer turns while the flood is running is the property; not the timing. - // Pinned inside one drain, the parent never gets to the timer; the message awaited - // after each turn shows the flood was still running while the turn was taken. - for (let i = 0; i < 3; i++) { - await new Promise(r => setTimeout(r, 10)); - await once(w, "message"); - } + await new Promise(r => setImmediate(r)); + expect(received).toBeGreaterThan(0); + expect(received).toBeLessThan(total); + await delivered.promise; w.terminate(); await once(w, "close"); }); From 221aa811f60301eabf9cc0474c13a9749ae8fb69 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:33:09 +0000 Subject: [PATCH 5/7] test(worker): tighten the flood test's two checks Assert the flag itself after Atomics.wait: the wait returns "not-equal" rather than "ok" if the worker finished before the parent got there, and that state satisfies the precondition just the same. Drop the > 0 check, which cannot fail after awaiting a message; received < total is the check. --- test/js/web/workers/worker.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index c44d35e03cbf..52687bc6c281 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -425,12 +425,12 @@ describe("web worker", () => { w.postMessage(flag); // Block until the whole flood sits in the parent's inbox, so the first drain task // starts out with more than its budget no matter how fast either thread is. - expect(Atomics.wait(flag, 0, 0, 30_000)).toBe("ok"); + Atomics.wait(flag, 0, 0, 30_000); + expect(Atomics.load(flag, 0)).toBe(1); // Resumes inside the first drain task. An immediate armed there runs as soon as // that task ends, before the continuation it posted delivers the next batch. await once(w, "message"); await new Promise(r => setImmediate(r)); - expect(received).toBeGreaterThan(0); expect(received).toBeLessThan(total); await delivered.promise; w.terminate(); From 8b04a99c4249f7ad9e684abf4183b944099aae8c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:28:52 +0000 Subject: [PATCH 6/7] test(worker): cover the continuation's budget and delivery order in the flood test With 1500 messages only the first drain task hit its budget: the one continuation found 476 left and delivered them in one go, so a continuation with no budget (or one that never re-posts the rest) passed the test on every build. Post two budgets plus one, and record the count after each drain task by re-arming an immediate until a tick delivers nothing, which gives [1024, 2048, 2049, 2049] on every build. The first task and its continuation must each stop short of the total, and the messages must all arrive in order; making either drain unbounded, or dropping the re-post, now fails one of those assertions on a debug build instead of passing or hanging. --- test/js/web/workers/worker.test.ts | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index 52687bc6c281..9983cd45387d 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -410,29 +410,37 @@ describe("web worker", () => { // parent inside one drain: a drain task delivers a bounded batch and posts the // rest to the next loop iteration, so timers and I/O get their turn in between. test("a message flood from a worker does not starve the parent's event loop", async () => { - const total = 1500; // more than one drain task's budget (drainBatchLimit in WorkerMessagingProxy.cpp) + const budget = 1024; // drainBatchLimit in WorkerMessagingProxy.cpp + // Two budgets plus one: the continuation the first task posts has to yield and + // post another one as well. + const total = 2 * budget + 1; const flag = new Int32Array(new SharedArrayBuffer(4)); const src = `onmessage = ({ data: flag }) => { for (let i = 0; i < ${total}; i++) postMessage(i); Atomics.store(flag, 0, 1); Atomics.notify(flag, 0); }`; const w = new Worker(URL.createObjectURL(new Blob([src]))); - let received = 0; - const delivered = Promise.withResolvers(); - w.onmessage = () => { - if (++received === total) delivered.resolve(); - }; + const got: number[] = []; + w.onmessage = e => got.push(e.data); w.postMessage(flag); // Block until the whole flood sits in the parent's inbox, so the first drain task // starts out with more than its budget no matter how fast either thread is. Atomics.wait(flag, 0, 0, 30_000); expect(Atomics.load(flag, 0)).toBe(1); - // Resumes inside the first drain task. An immediate armed there runs as soon as - // that task ends, before the continuation it posted delivers the next batch. + // Resumes inside the first drain task. An immediate armed there runs as soon as the + // task ends and before the continuation it posted runs, so each immediate sees what + // exactly one more task delivered; two in a row seeing the same count is the end. await once(w, "message"); - await new Promise(r => setImmediate(r)); - expect(received).toBeLessThan(total); - await delivered.promise; + const afterEachTask: number[] = []; + do { + await new Promise(r => setImmediate(r)); + afterEachTask.push(got.length); + } while (afterEachTask.at(-1) !== afterEachTask.at(-2)); + // [1024, 2048, 2049, 2049]: neither the first task nor its continuation delivered + // everything, and everything still arrived, in order. + expect(afterEachTask[0]).toBeLessThan(total); + expect(afterEachTask[1]).toBeLessThan(total); + expect(got).toEqual(Array.from({ length: total }, (_, i) => i)); w.terminate(); await once(w, "close"); }); From 8946b9b7a6367bb41549996db1c7243256a32e4d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:36:22 +0000 Subject: [PATCH 7/7] test(worker): assert the exact per-task counts in the flood test The test already derives its total from the budget, so comparing the whole sequence against [budget, 2 * budget, total, total] costs no extra coupling and also pins the batch size; the failure output then shows which task misbehaved. --- test/js/web/workers/worker.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index 9983cd45387d..babb92f85469 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -436,10 +436,9 @@ describe("web worker", () => { await new Promise(r => setImmediate(r)); afterEachTask.push(got.length); } while (afterEachTask.at(-1) !== afterEachTask.at(-2)); - // [1024, 2048, 2049, 2049]: neither the first task nor its continuation delivered - // everything, and everything still arrived, in order. - expect(afterEachTask[0]).toBeLessThan(total); - expect(afterEachTask[1]).toBeLessThan(total); + // The first task and its continuation each stop at the budget, the third delivers + // the one left over, and everything arrives in order. + expect(afterEachTask).toEqual([budget, 2 * budget, total, total]); expect(got).toEqual(Array.from({ length: total }, (_, i) => i)); w.terminate(); await once(w, "close");