358 lines
12 KiB
TypeScript
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,
|
||
|
|
);
|