Files
bun-src/test/cli/watch/watch.test.ts
T
2026-08-27 21:09:14 +00:00

358 lines
12 KiB
TypeScript

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 <dlfcn.h>
#include <errno.h>
#include <pthread.h>
#include <sys/resource.h>
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 <version-tag>/<hash>
// 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,
);