From b2b44446c717d787b295309d0e8b0674c89bc785 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:17:09 +0000 Subject: [PATCH 1/2] event loop: count queued concurrent_tasks in is_event_loop_alive ScriptExecutionContext::postTaskTo (used by the small-input crypto.subtle digest fast path and every WebCrypto work-queue completion) enqueues into EventLoop.concurrent_tasks without a paired ref_concurrently(). The liveness check only looked at the drained el.tasks FIFO and has_pending_refs(), so a process could see zero and exit with a crypto.subtle promise still pending in concurrent_tasks. This is the residual behind #11453: edgedb's rawConn does sock.ref()/await/sock.unref() around reads and runs SCRAM via crypto.subtle in between (Bun exposing a global crypto makes the client pick its browserCrypto adapter). With the TLS socket unref'd during the HMAC/digest work, the only thing holding the loop was the WebCrypto task, which the liveness check did not see. Fixes #11453 --- src/jsc/VirtualMachine.rs | 1 + test/regression/issue/11453.test.ts | 122 ++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 test/regression/issue/11453.test.ts diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 6c6925f80673..22c55985eae5 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1039,6 +1039,7 @@ impl VirtualMachine { + self.active_tasks + el.tasks.readable_length() + (el.has_pending_refs() as usize) + + (!el.concurrent_tasks.is_empty() as usize) > 0) } diff --git a/test/regression/issue/11453.test.ts b/test/regression/issue/11453.test.ts new file mode 100644 index 000000000000..fc4bb31c496c --- /dev/null +++ b/test/regression/issue/11453.test.ts @@ -0,0 +1,122 @@ +// https://github.com/oven-sh/bun/issues/11453 +// +// @edgedb/generate under Bun would sometimes exit 0 silently mid-connect. +// Root cause: edgedb's RawConnection does sock.ref()/await/sock.unref() around +// reads, and in between runs SCRAM via crypto.subtle (Bun exposes a global +// `crypto`, so the edgedb client picks its browserCrypto adapter). When the +// small-input digest fast path (or the completion leg of a work-queue crypto +// op) posts its result via ScriptExecutionContext::postTaskTo, the task lands +// in EventLoop.concurrent_tasks with no accompanying event-loop ref, and +// is_event_loop_alive() did not look at concurrent_tasks. With the socket +// unref'd, the liveness check saw zero and the process exited with the crypto +// result still queued. +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, normalizeBunSnapshot } from "harness"; +import { once } from "node:events"; +import net from "node:net"; + +describe("issue #11453: crypto.subtle keeps the event loop alive after a yield", () => { + // Deterministic: the <64-byte SHA digest fast path computes synchronously + // and posts the callback via postTaskTo with no work-queue ref. + test.concurrent("crypto.subtle.digest (small input) awaited after setImmediate", async () => { + const script = ` + (async () => { + await new Promise(r => setImmediate(r)); + await crypto.subtle.digest("SHA-256", new Uint8Array(32)); + console.log("resolved"); + })(); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(normalizeBunSnapshot(stderr)).toBe(""); + expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`"resolved"`); + expect(exitCode).toBe(0); + }); + + test.concurrent("crypto.subtle.digest (small input) awaited after setTimeout", async () => { + const script = ` + (async () => { + await new Promise(r => setTimeout(r, 0)); + await crypto.subtle.digest("SHA-256", new Uint8Array(32)); + console.log("resolved"); + })(); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(normalizeBunSnapshot(stderr)).toBe(""); + expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`"resolved"`); + expect(exitCode).toBe(0); + }); + + // The exact shape from edgedb's rawConn._waitForMessage: with the socket + // unref'd between reads, an awaited crypto.subtle op must keep the process + // alive until it resolves and the next sock.ref() runs. + test.concurrent("crypto.subtle between sock.unref() and sock.ref() on a net.Socket", async () => { + const server = net.createServer(socket => { + socket.setNoDelay(); + socket.on("data", () => { + setImmediate(() => { + try { + socket.write("R"); + } catch {} + }); + }); + socket.on("error", () => {}); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = (server.address() as net.AddressInfo).port; + + const script = ` + const net = require("net"); + const sock = net.createConnection(${port}, "127.0.0.1"); + let connR, dataR; + sock.on("connect", () => connR()); + sock.on("error", e => { console.error("err", e.message); process.exit(2); }); + sock.on("data", () => { if (dataR) { dataR(); dataR = null; } }); + (async () => { + await new Promise(r => (connR = r)); + + // round-trip: edgedb's ref() / await data / unref() pattern + sock.write("x"); + sock.ref(); + await new Promise(r => (dataR = r)); + sock.unref(); + + // Socket is now unref'd. Nothing else is ref'd. The awaited digest + // must keep the loop alive on its own. + await crypto.subtle.digest("SHA-256", new Uint8Array(32)); + + sock.write("y"); + sock.ref(); + await new Promise(r => (dataR = r)); + sock.unref(); + + console.log("resolved"); + sock.destroy(); + })(); + `; + + try { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(normalizeBunSnapshot(stderr)).toBe(""); + expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`"resolved"`); + expect(exitCode).toBe(0); + } finally { + await new Promise(r => server.close(() => r())); + } + }); +}); From c4ac23888d91a69db5585efa559909e01488c2a2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:26:44 +0000 Subject: [PATCH 2/2] test: trim comments to match regression file convention --- test/regression/issue/11453.test.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/test/regression/issue/11453.test.ts b/test/regression/issue/11453.test.ts index fc4bb31c496c..c79bfef431e1 100644 --- a/test/regression/issue/11453.test.ts +++ b/test/regression/issue/11453.test.ts @@ -1,23 +1,10 @@ // https://github.com/oven-sh/bun/issues/11453 -// -// @edgedb/generate under Bun would sometimes exit 0 silently mid-connect. -// Root cause: edgedb's RawConnection does sock.ref()/await/sock.unref() around -// reads, and in between runs SCRAM via crypto.subtle (Bun exposes a global -// `crypto`, so the edgedb client picks its browserCrypto adapter). When the -// small-input digest fast path (or the completion leg of a work-queue crypto -// op) posts its result via ScriptExecutionContext::postTaskTo, the task lands -// in EventLoop.concurrent_tasks with no accompanying event-loop ref, and -// is_event_loop_alive() did not look at concurrent_tasks. With the socket -// unref'd, the liveness check saw zero and the process exited with the crypto -// result still queued. import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, normalizeBunSnapshot } from "harness"; import { once } from "node:events"; import net from "node:net"; describe("issue #11453: crypto.subtle keeps the event loop alive after a yield", () => { - // Deterministic: the <64-byte SHA digest fast path computes synchronously - // and posts the callback via postTaskTo with no work-queue ref. test.concurrent("crypto.subtle.digest (small input) awaited after setImmediate", async () => { const script = ` (async () => { @@ -56,9 +43,6 @@ describe("issue #11453: crypto.subtle keeps the event loop alive after a yield", expect(exitCode).toBe(0); }); - // The exact shape from edgedb's rawConn._waitForMessage: with the socket - // unref'd between reads, an awaited crypto.subtle op must keep the process - // alive until it resolves and the next sock.ref() runs. test.concurrent("crypto.subtle between sock.unref() and sock.ref() on a net.Socket", async () => { const server = net.createServer(socket => { socket.setNoDelay(); @@ -91,8 +75,6 @@ describe("issue #11453: crypto.subtle keeps the event loop alive after a yield", await new Promise(r => (dataR = r)); sock.unref(); - // Socket is now unref'd. Nothing else is ref'd. The awaited digest - // must keep the loop alive on its own. await crypto.subtle.digest("SHA-256", new Uint8Array(32)); sock.write("y");