130 lines
4.5 KiB
TypeScript
130 lines
4.5 KiB
TypeScript
import { readFileSync, renameSync, rmSync, statSync, utimesSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
|
|
const root = process.cwd();
|
|
const watchedPath = resolve(root, "scripts/caserun-native-fixtures.ts");
|
|
const displacedPath = `${watchedPath}.reload-smoke`;
|
|
const originalStat = statSync(watchedPath);
|
|
const port = Number(process.env.HWLAB_CASERUN_RELOAD_SMOKE_PORT || 14316);
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
const reloadEvents: Array<{ pid?: number; reason?: string }> = [];
|
|
let output = "";
|
|
let displaced = false;
|
|
|
|
function requireCondition(condition: unknown, message: string): asserts condition {
|
|
if (!condition) throw new Error(message);
|
|
}
|
|
|
|
async function waitFor(description: string, condition: () => boolean | Promise<boolean>, timeoutMs = 5000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (await condition()) return;
|
|
await Bun.sleep(50);
|
|
}
|
|
throw new Error(`timeout waiting for ${description}`);
|
|
}
|
|
|
|
async function readOutput(stream: ReadableStream<Uint8Array>): Promise<void> {
|
|
const reader = stream.getReader();
|
|
const decoder = new TextDecoder();
|
|
let pending = "";
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
const chunk = decoder.decode(value, { stream: true });
|
|
output += chunk;
|
|
pending += chunk;
|
|
const lines = pending.split("\n");
|
|
pending = lines.pop() || "";
|
|
for (const line of lines) {
|
|
if (!line.includes('"event":"caserun-native-reload"')) continue;
|
|
const event = JSON.parse(line) as { pid?: number; reason?: string };
|
|
if (!reloadEvents.some((existing) => existing.pid === event.pid)) reloadEvents.push(event);
|
|
}
|
|
}
|
|
const finalChunk = decoder.decode();
|
|
output += finalChunk;
|
|
pending += finalChunk;
|
|
if (pending.includes('"event":"caserun-native-reload"')) {
|
|
const event = JSON.parse(pending) as { pid?: number; reason?: string };
|
|
if (!reloadEvents.some((existing) => existing.pid === event.pid)) reloadEvents.push(event);
|
|
}
|
|
}
|
|
|
|
async function healthAvailable(): Promise<boolean> {
|
|
try {
|
|
const response = await fetch(`${baseUrl}/health`);
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function childPids(supervisorPid: number): number[] {
|
|
try {
|
|
return readFileSync(`/proc/${supervisorPid}/task/${supervisorPid}/children`, "utf8")
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.map(Number);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
rmSync(displacedPath, { force: true });
|
|
const supervisor = Bun.spawn([process.execPath, "scripts/caserun-native-supervisor.ts"], {
|
|
cwd: root,
|
|
env: { ...process.env, HWLAB_CASERUN_NATIVE_PORT: String(port) },
|
|
stdin: "ignore",
|
|
stdout: "pipe",
|
|
stderr: "pipe"
|
|
});
|
|
const stdoutTask = readOutput(supervisor.stdout);
|
|
const stderrTask = readOutput(supervisor.stderr);
|
|
|
|
try {
|
|
await waitFor("initial native health", healthAvailable);
|
|
|
|
renameSync(watchedPath, displacedPath);
|
|
displaced = true;
|
|
await Bun.sleep(300);
|
|
requireCondition(supervisor.exitCode === null, `supervisor exited during atomic save window: ${supervisor.exitCode}`);
|
|
renameSync(displacedPath, watchedPath);
|
|
displaced = false;
|
|
const restoredAt = new Date(Date.now() + 1000);
|
|
utimesSync(watchedPath, restoredAt, restoredAt);
|
|
await waitFor("reload after atomic save", () => reloadEvents.length >= 2);
|
|
|
|
for (let index = 0; index < 5; index += 1) {
|
|
const changedAt = new Date(Date.now() + (index + 1) * 1000);
|
|
utimesSync(watchedPath, changedAt, changedAt);
|
|
await Bun.sleep(120);
|
|
}
|
|
|
|
await waitFor("final native health", healthAvailable);
|
|
await Bun.sleep(300);
|
|
const liveChildren = childPids(supervisor.pid);
|
|
requireCondition(liveChildren.length === 1, `expected one native server child, found ${liveChildren.join(",") || "none"}`);
|
|
requireCondition(reloadEvents.length >= 3, `expected repeated reloads, found ${reloadEvents.length}`);
|
|
requireCondition(!output.includes("EADDRINUSE"), "native reload logged EADDRINUSE");
|
|
|
|
console.log(JSON.stringify({
|
|
ok: true,
|
|
mode: "native-test",
|
|
supervisorPid: supervisor.pid,
|
|
serverPid: liveChildren[0],
|
|
reloadCount: reloadEvents.length,
|
|
reloadPids: reloadEvents.map((event) => event.pid),
|
|
atomicSaveMissingFileTolerated: true,
|
|
eaddrinuse: false
|
|
}));
|
|
} finally {
|
|
if (displaced) renameSync(displacedPath, watchedPath);
|
|
rmSync(displacedPath, { force: true });
|
|
utimesSync(watchedPath, originalStat.atime, originalStat.mtime);
|
|
if (supervisor.exitCode === null) supervisor.kill("SIGTERM");
|
|
await supervisor.exited;
|
|
await Promise.all([stdoutTask, stderrTask]);
|
|
}
|