Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/jsc/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -996,7 +996,9 @@ impl EventLoop {
/// freshly-allocated or struct-embedded task — never null.
pub fn enqueue_task_concurrent(&self, task: core::ptr::NonNull<ConcurrentTaskItem>) {
if cfg!(debug_assertions) {
if self.vm_ref().has_terminated {
let vm = self.vm_ref();
// Main-thread VM box is never dealloc'd; only a worker push is UAF-adjacent.
if vm.has_terminated && !vm.is_main_thread() {
panic!("EventLoop.enqueueTaskConcurrent: VM has terminated");
}
}
Expand Down
102 changes: 102 additions & 0 deletions test/js/node/fs/fs-write-exit-race.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, isASAN, isLinux, tempDir } from "harness";

// A thread-pool `fs.write` that is still blocked in the kernel when
// `process.exit()` runs must not crash the process when it later completes.
// `BUN_DESTRUCT_VM_ON_EXIT=1` (set by the CI runner) makes `global_exit`
// call `VirtualMachine::destroy()` on the main-thread VM before `libc::exit`;
// a work-pool completion that lands after that posts to the event loop of a
// VM whose `has_terminated` is already true. The main-thread VM box is never
// freed, so the push is harmless and the debug assert was a false positive.
//
// Deterministically reproducing the race requires something to unblock the
// kernel write *after* `destroy()` has returned. The child registers a libc
// atexit handler (via bun:ffi's tinycc) that closes the FIFO reader and
// then sleeps, so the blocked `write()` returns EPIPE while the main thread
// is still inside the atexit chain.
//
// Linux + ASAN only: relies on mkfifo and blocking-pipe write semantics, and
// on `Global::exit` taking the `libc_exit()` branch so `__cxa_atexit` handlers
// run (non-ASAN Linux uses `quick_exit()`, which skips them). The guard being
// tested is `cfg!(debug_assertions)`-only, which ASAN builds enable.
test.skipIf(!isLinux || !isASAN)(
"process.exit with a thread-pool fs.write still blocked in the kernel exits cleanly",
async () => {
using dir = tempDir("fs-write-exit-race", {
"helper.c": `
extern int close(int);
extern int usleep(unsigned int);
extern int __cxa_atexit(void (*)(void *), void *, void *);
static int g_fd = -1;
static void do_close_and_sleep(void *unused) {
(void)unused;
if (g_fd >= 0) close(g_fd);
usleep(300000);
}
void schedule_close_at_exit(int fd) {
g_fd = fd;
__cxa_atexit(do_close_and_sleep, 0, 0);
}
`,
"child.js": `
const fs = require("node:fs");
const path = require("node:path");
const cp = require("node:child_process");
const { cc, FFIType } = require("bun:ffi");

const fifo = path.join(__dirname, "pipe");
cp.spawnSync("mkfifo", [fifo]);

const rd = fs.openSync(fifo, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
const wd = fs.openSync(fifo, fs.constants.O_WRONLY);

const { symbols } = cc({
source: path.join(__dirname, "helper.c"),
symbols: {
schedule_close_at_exit: { args: [FFIType.i32], returns: FFIType.void },
},
});
symbols.schedule_close_at_exit(rd);
Comment thread
robobun marked this conversation as resolved.

// Larger than any pipe capacity so write() blocks in the kernel until
// the reader fd closes.
fs.write(wd, Buffer.alloc(1 << 21, 0x41), () => {});

// Wait until the work-pool thread has entered the kernel write(): rd is
// O_NONBLOCK, so readSync returns >0 once bytes have landed in the pipe.
// Consuming one byte does not unblock the 2 MiB writer.
const buf = Buffer.alloc(1);
const deadline = Date.now() + 5000;
let sawBytes = false;
while (Date.now() < deadline) {
try { if (fs.readSync(rd, buf) > 0) { sawBytes = true; break; } } catch (e) { if (e.code !== "EAGAIN") throw e; }
Bun.sleepSync(1);
}
Comment thread
robobun marked this conversation as resolved.
if (!sawBytes) {
process.stderr.write("writer never entered kernel within 5s\\n");
process.exit(1);
}

process.stdout.write("alive\\n");
process.exit(0);
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "child.js"],
cwd: String(dir),
env: {
...bunEnv,
BUN_DESTRUCT_VM_ON_EXIT: "1",
ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=0"].filter(Boolean).join(":"),
},
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: "alive\n", stderr: "", exitCode: 0 });
},
// On an unfixed debug build the panic hook symbolizes the backtrace through
// llvm-symbolizer (~5s) before the process terminates.
15_000,
);
Loading