import type { Subprocess } from "bun"; import { spawn } from "bun"; import { afterEach, expect, it } from "bun:test"; import { bunEnv, bunExe, isBroken, isLinux, isWindows, tempDir, tmpdirSync } from "harness"; import { readdirSync, rmSync } from "node:fs"; import { join } from "node:path"; let watchee: Subprocess; function stdoutWaiter(proc: Subprocess<"ignore", "pipe", any>) { const reader = proc.stdout.getReader(); const decoder = new TextDecoder(); let output = ""; return { waitFor: async (needle: string) => { while (!output.includes(needle)) { const { value, done } = await reader.read(); if (done) throw new Error(`stream closed, output so far: ${JSON.stringify(output)}`); output += decoder.decode(value, { stream: true }); } }, release: () => reader.releaseLock(), }; } for (const dir of ["dir", "©️"]) { it.todoIf(isBroken && isWindows)( `should watch files${dir === "dir" ? "" : " (non-ascii path)"}`, async () => { const cwd = join(tmpdirSync(), dir); const path = join(cwd, "watchee.js"); const updateFile = async (i: number) => { await Bun.write(path, `console.log(${i}, __dirname);`); }; let i = 0; await updateFile(i); await Bun.sleep(1000); watchee = spawn({ cwd, cmd: [bunExe(), "--watch", "watchee.js"], env: bunEnv, stdout: "pipe", stderr: "inherit", stdin: "ignore", }); for await (const line of watchee.stdout) { if (i == 10) break; var str = new TextDecoder().decode(line); expect(str).toContain(`${i} ${cwd}`); i++; await updateFile(i); } rmSync(path); }, 10000, ); } afterEach(async () => { // SIGKILL, not the default SIGTERM: the wedge fixtures below register a // SIGTERM handler and never yield, so SIGTERM can't kill them — a leaked // pair spins at full CPU until someone notices. Await the exit so a test // failure can't strand the child past the suite. if (watchee) { watchee.kill("SIGKILL"); await watchee.exited; watchee = undefined; } }); it.skipIf(isWindows)( "process.exit() in a watch kill-signal handler never returns to JS", async () => { using dir = tempDir("watch-exit-in-sigterm", { "exiter.js": `process.on("SIGTERM", () => { process.exit(0); require("fs").writeFileSync("should-not-write.txt", "hello"); }); process.on("SIGTERM", () => { require("fs").writeFileSync("second-listener-ran.txt", "hello"); }); console.log("started"); setInterval(() => {}, 1000); `, }); const cwd = String(dir); const path = join(cwd, "exiter.js"); watchee = spawn({ cwd, cmd: [bunExe(), "--watch", "exiter.js"], env: bunEnv, stdout: "pipe", stderr: "inherit", stdin: "ignore", }); let starts = 0; let touched = false; const decoder = new TextDecoder(); await (async () => { // Output written before this reader attaches waits in the kernel pipe // buffer, so no start can be missed. Lines are reassembled across chunk // boundaries before matching. let buffered = ""; for await (const chunk of watchee.stdout) { buffered += decoder.decode(chunk); let newline; while ((newline = buffered.indexOf("\n")) !== -1) { const line = buffered.slice(0, newline); buffered = buffered.slice(newline + 1); if (line.includes("started") && ++starts === 2) return; if (starts === 1 && !touched) { touched = true; // First boot seen: touch the file to trigger the kill-signal reload. await Bun.write(path, (await Bun.file(path).text()) + "\n// touched"); } } } // The child exiting before the reload start is a failure of the watch // path itself; without this the absent-file expects below pass vacuously. throw new Error(`watchee stdout ended after ${starts} start(s); expected 2`); })(); expect(await Bun.file(join(cwd, "should-not-write.txt")).exists()).toBe(false); expect(await Bun.file(join(cwd, "second-listener-ran.txt")).exists()).toBe(false); }, 10000, ); // Watcher::start() must propagate a failed thread spawn as an Err through its // Result return instead of aborting inside start() with `.expect()`. An // LD_PRELOAD shim arms on inotify_init1 (which Watcher::init() calls on Linux // immediately before start()) and fails the very next pthread_create. const cc = Bun.which("cc") || Bun.which("gcc") || Bun.which("clang"); it.skipIf(!isLinux || !cc)("propagates FileWatcher thread spawn failure instead of panicking in start()", async () => { const SHIM_C = /* c */ ` #define _GNU_SOURCE #include #include #include #include static int (*real_inotify_init1)(int); static int (*real_pthread_create)(pthread_t *, const pthread_attr_t *, void *(*)(void *), void *); static volatile int armed = 0; /* The child is expected to abort; suppress the core file so CI's runner does * not flag it as a crash. RLIMIT_CORE survives execvp. */ __attribute__((constructor)) static void no_core(void) { struct rlimit rl = {0, 0}; setrlimit(RLIMIT_CORE, &rl); } int inotify_init1(int flags) { if (!real_inotify_init1) real_inotify_init1 = dlsym(RTLD_NEXT, "inotify_init1"); /* Watcher::start() retries once on EAGAIN; fail both attempts. */ armed = 2; return real_inotify_init1(flags); } int pthread_create(pthread_t *t, const pthread_attr_t *a, void *(*f)(void *), void *arg) { if (!real_pthread_create) real_pthread_create = dlsym(RTLD_NEXT, "pthread_create"); if (armed) { armed--; return EAGAIN; } return real_pthread_create(t, a, f, arg); } `; using dir = tempDir("watch-spawn-fail", { "shim.c": SHIM_C, "watchee.js": "console.log('unreachable');\n", }); const shimPath = join(String(dir), "shim.so"); await using ccProc = Bun.spawn({ cmd: [cc!, "-shared", "-fPIC", "-o", shimPath, join(String(dir), "shim.c"), "-ldl", "-lpthread"], env: bunEnv, stderr: "pipe", stdout: "pipe", }); const [ccOut, ccErr, ccExit] = await Promise.all([ccProc.stdout.text(), ccProc.stderr.text(), ccProc.exited]); if (ccExit !== 0) throw new Error(`shim compile failed: ${ccErr || ccOut}`); const existing = bunEnv.LD_PRELOAD; await using proc = Bun.spawn({ // --debug-crash-handler-use-trace-string skips the debug build's slow // backtrace symbolication so the child exits promptly. cmd: [bunExe(), "--debug-crash-handler-use-trace-string", "--watch", "watchee.js"], cwd: String(dir), env: { ...bunEnv, LD_PRELOAD: existing ? `${shimPath}:${existing}` : shimPath }, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); // The .expect("spawn FileWatcher thread") panic inside start() must be gone; // the error now reaches the caller, which reports it by errno name. expect(stderr).not.toContain("spawn FileWatcher thread"); expect(stderr).toContain("Failed to start File Watcher: EAGAIN"); expect(stdout).not.toContain("unreachable"); expect(exitCode).not.toBe(0); }); // A script that registers a SIGTERM handler and then spins in synchronous // code must still restart on file change: the watcher thread posts the reload // to the JS thread first (so listeners can run), but forces the reload itself // after a bounded grace window when the JS thread never drains the task. it("--watch forces a restart when the kill-signal listener thread is stuck in sync code", async () => { using dir = tempDir("watch-busy-sigterm", { "busy.js": ` process.on("SIGTERM", () => {}); console.log("iter first"); // The busy loop never yields to the event loop, so the posted // WatchReloadTask cannot run and the watcher-thread fallback must // fire. Self-limiting: it spins far past the 500ms fallback window // but exits on its own, so a leaked watch pair cannot burn CPU // forever if the test dies before killing it. const end = Date.now() + 30_000; while (Date.now() < end) {} process.exit(1); `, }); watchee = spawn({ cmd: [bunExe(), "--watch", "busy.js"], cwd: String(dir), env: bunEnv, stdout: "pipe", stderr: "inherit", }); const { waitFor, release } = stdoutWaiter(watchee); await waitFor("iter first"); await Bun.write( join(String(dir), "busy.js"), `process.on("SIGTERM", () => {}); console.log("iter second"); process.exit(0);`, ); await waitFor("iter second"); release(); watchee.kill("SIGKILL"); await watchee.exited; }, 30000); // Same fallback, but the wedge is *inside* the handler rather than before // the posted task drains: the emit-flag stays true for the handler's full // synchronous duration, and the grace thread must still force the reload // when the handler never returns. it("--watch forces a restart when the kill-signal handler itself never returns", async () => { using dir = tempDir("watch-sigterm-wedged-handler", { "busy.js": ` process.on("SIGTERM", () => { const end = Date.now() + 30_000; while (Date.now() < end) {} process.exit(1); }); console.log("iter first"); setInterval(() => {}, 1000); `, }); watchee = spawn({ cmd: [bunExe(), "--watch", "busy.js"], cwd: String(dir), env: bunEnv, stdout: "pipe", stderr: "inherit", }); const { waitFor, release } = stdoutWaiter(watchee); await waitFor("iter first"); await Bun.write( join(String(dir), "busy.js"), `console.log("iter second"); process.exit(0);`, ); await waitFor("iter second"); release(); watchee.kill("SIGKILL"); await watchee.exited; }, 30000); // execve replaces the process without reaching on_exit(), so the compile // cache must be flushed explicitly on the reload path; otherwise // NODE_COMPILE_CACHE never writes anything under --watch. it("NODE_COMPILE_CACHE persists across a --watch reload", async () => { using dir = tempDir("watch-compile-cache", { "dep.js": `module.exports = 1;`, "app.js": `require("./dep.js"); console.log("iter first");`, }); const cacheDir = join(String(dir), ".cc"); watchee = spawn({ cmd: [bunExe(), "--watch", "app.js"], cwd: String(dir), env: { ...bunEnv, NODE_COMPILE_CACHE: cacheDir }, stdout: "pipe", stderr: "inherit", }); const { waitFor, release } = stdoutWaiter(watchee); await waitFor("iter first"); await Bun.write(join(String(dir), "app.js"), `require("./dep.js"); console.log("iter second");`); await waitFor("iter second"); release(); watchee.kill("SIGKILL"); await watchee.exited; // The first iteration persisted before execve; the / // files must now exist on disk. const entries = readdirSync(cacheDir, { recursive: true, withFileTypes: true }); expect(entries.filter(e => e.isFile()).length).toBeGreaterThan(0); }, 30000); // NODE_CHANNEL_FD survives in environ across execve; the fd it names must // survive too, so the reloaded image re-attaches to a live socket instead // of a closed one and the parent keeps receiving 'message' events. it.skipIf(isWindows)( "IPC to the parent survives a --watch reload", async () => { using dir = tempDir("watch-ipc-reload", { "app.js": `process.send?.("iter first"); setInterval(() => {}, 1000);`, }); const messages: string[] = []; watchee = spawn({ cmd: [bunExe(), "--watch", "app.js"], cwd: String(dir), env: bunEnv, stdout: "inherit", stderr: "inherit", ipc(message) { messages.push(String(message)); }, }); const deadline = Date.now() + 20000; while (!messages.includes("iter first") && Date.now() < deadline) await Bun.sleep(10); expect(messages).toContain("iter first"); await Bun.write(join(String(dir), "app.js"), `process.send?.("iter second");`); while (!messages.includes("iter second") && Date.now() < deadline) await Bun.sleep(10); expect(messages).toContain("iter second"); watchee.kill("SIGKILL"); await watchee.exited; }, 30000, );