siltrun 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -0
- package/bin/silt.mjs +128 -0
- package/package.json +32 -0
- package/src/args.test.ts +93 -0
- package/src/args.ts +117 -0
- package/src/banner.ts +55 -0
- package/src/bundle.test.ts +80 -0
- package/src/bundle.ts +53 -0
- package/src/cli.ts +95 -0
- package/src/credentials.ts +73 -0
- package/src/deploy-client.ts +143 -0
- package/src/deploy.test.ts +330 -0
- package/src/deploy.ts +396 -0
- package/src/dev.test.ts +59 -0
- package/src/dev.ts +264 -0
- package/src/doctor.test.ts +31 -0
- package/src/doctor.ts +74 -0
- package/src/log.ts +39 -0
- package/src/login.test.ts +307 -0
- package/src/login.ts +263 -0
- package/src/paths.ts +92 -0
- package/src/room-info.test.ts +80 -0
- package/src/room-info.ts +70 -0
- package/src/server-build.ts +78 -0
- package/src/silt-shim.test.ts +54 -0
- package/src/supervisor.test.ts +23 -0
- package/src/supervisor.ts +218 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { test, expect, describe, afterEach } from "bun:test";
|
|
2
|
+
import { startRoomInfoServer, WELL_KNOWN_PATH, type RoomInfoServer } from "./room-info.ts";
|
|
3
|
+
|
|
4
|
+
let s: RoomInfoServer | null = null;
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
s?.stop();
|
|
7
|
+
s = null;
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe("room-info endpoint (SEAM §4)", () => {
|
|
11
|
+
test("GET /.well-known/silt returns the pinned shape", async () => {
|
|
12
|
+
s = startRoomInfoServer({
|
|
13
|
+
port: 0,
|
|
14
|
+
getInfo: () => ({
|
|
15
|
+
wtEndpoint: "https://127.0.0.1:4433/room/hello-room",
|
|
16
|
+
certHash: "abc123",
|
|
17
|
+
mode: "compute",
|
|
18
|
+
}),
|
|
19
|
+
});
|
|
20
|
+
const res = await fetch(`${s.url}${WELL_KNOWN_PATH}`);
|
|
21
|
+
expect(res.status).toBe(200);
|
|
22
|
+
expect(res.headers.get("content-type")).toContain("application/json");
|
|
23
|
+
const body = await res.json() as any;
|
|
24
|
+
// exact key set — the client (owner C) destructures these three
|
|
25
|
+
expect(Object.keys(body).sort()).toEqual(["certHash", "mode", "wtEndpoint"]);
|
|
26
|
+
expect(body.wtEndpoint).toBe("https://127.0.0.1:4433/room/hello-room");
|
|
27
|
+
expect(body.certHash).toBe("abc123");
|
|
28
|
+
expect(body.mode).toBe("compute");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("certHash may be null (deployed rooms omit the hash)", async () => {
|
|
32
|
+
s = startRoomInfoServer({
|
|
33
|
+
port: 0,
|
|
34
|
+
getInfo: () => ({ wtEndpoint: "https://x/room/y", certHash: null, mode: "compute" }),
|
|
35
|
+
});
|
|
36
|
+
const body = await (await fetch(`${s.url}${WELL_KNOWN_PATH}`)).json() as any;
|
|
37
|
+
expect(body.certHash).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("info is read per-request (cert refresh across restarts is visible)", async () => {
|
|
41
|
+
let hash = "first";
|
|
42
|
+
s = startRoomInfoServer({
|
|
43
|
+
port: 0,
|
|
44
|
+
getInfo: () => ({ wtEndpoint: "https://x/room/y", certHash: hash, mode: "compute" }),
|
|
45
|
+
});
|
|
46
|
+
let body = await (await fetch(`${s.url}${WELL_KNOWN_PATH}`)).json() as any;
|
|
47
|
+
expect(body.certHash).toBe("first");
|
|
48
|
+
hash = "second";
|
|
49
|
+
body = await (await fetch(`${s.url}${WELL_KNOWN_PATH}`)).json() as any;
|
|
50
|
+
expect(body.certHash).toBe("second");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("permissive CORS so a Vite app on another port can fetch it", async () => {
|
|
54
|
+
s = startRoomInfoServer({
|
|
55
|
+
port: 0,
|
|
56
|
+
getInfo: () => ({ wtEndpoint: "https://x/room/y", certHash: "h", mode: "compute" }),
|
|
57
|
+
});
|
|
58
|
+
const res = await fetch(`${s.url}${WELL_KNOWN_PATH}`);
|
|
59
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("OPTIONS preflight is answered with CORS", async () => {
|
|
63
|
+
s = startRoomInfoServer({
|
|
64
|
+
port: 0,
|
|
65
|
+
getInfo: () => ({ wtEndpoint: "https://x/room/y", certHash: "h", mode: "compute" }),
|
|
66
|
+
});
|
|
67
|
+
const res = await fetch(`${s.url}${WELL_KNOWN_PATH}`, { method: "OPTIONS" });
|
|
68
|
+
expect(res.status).toBe(204);
|
|
69
|
+
expect(res.headers.get("access-control-allow-methods")).toContain("GET");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("unknown paths 404", async () => {
|
|
73
|
+
s = startRoomInfoServer({
|
|
74
|
+
port: 0,
|
|
75
|
+
getInfo: () => ({ wtEndpoint: "https://x/room/y", certHash: "h", mode: "compute" }),
|
|
76
|
+
});
|
|
77
|
+
const res = await fetch(`${s.url}/nope`);
|
|
78
|
+
expect(res.status).toBe(404);
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/room-info.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The room-info discovery endpoint (SEAM §4, owner E; consumed by @siltrun/client, owner C).
|
|
2
|
+
//
|
|
3
|
+
// GET <url>/.well-known/silt
|
|
4
|
+
// -> { wtEndpoint, certHash, mode }
|
|
5
|
+
//
|
|
6
|
+
// `useRoom("http://localhost:4000")` fetches this to discover the WebTransport endpoint
|
|
7
|
+
// and the dev cert hash, then dials WT with the hash pinned — so the dev never touches
|
|
8
|
+
// serverCertificateHashes. Permissive CORS because a Vite app on another port fetches it.
|
|
9
|
+
|
|
10
|
+
export interface RoomInfo {
|
|
11
|
+
wtEndpoint: string;
|
|
12
|
+
certHash: string | null;
|
|
13
|
+
mode: "compute" | "relay";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const CORS = {
|
|
17
|
+
"Access-Control-Allow-Origin": "*",
|
|
18
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
19
|
+
"Access-Control-Allow-Headers": "*",
|
|
20
|
+
"Access-Control-Max-Age": "86400",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const WELL_KNOWN_PATH = "/.well-known/silt";
|
|
24
|
+
|
|
25
|
+
export interface RoomInfoServer {
|
|
26
|
+
server: ReturnType<typeof Bun.serve>;
|
|
27
|
+
port: number;
|
|
28
|
+
url: string;
|
|
29
|
+
stop: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Serve room-info on `port`. `getInfo` is called per request so the served cert hash
|
|
34
|
+
* tracks the live room-server across restarts (the cert changes each boot).
|
|
35
|
+
*/
|
|
36
|
+
export function startRoomInfoServer(opts: {
|
|
37
|
+
port: number;
|
|
38
|
+
hostname?: string;
|
|
39
|
+
getInfo: () => RoomInfo;
|
|
40
|
+
}): RoomInfoServer {
|
|
41
|
+
const hostname = opts.hostname ?? "127.0.0.1";
|
|
42
|
+
const server = Bun.serve({
|
|
43
|
+
port: opts.port,
|
|
44
|
+
hostname,
|
|
45
|
+
fetch(req) {
|
|
46
|
+
const { pathname } = new URL(req.url);
|
|
47
|
+
|
|
48
|
+
if (req.method === "OPTIONS") {
|
|
49
|
+
return new Response(null, { status: 204, headers: CORS });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (pathname === WELL_KNOWN_PATH) {
|
|
53
|
+
if (req.method !== "GET") {
|
|
54
|
+
return new Response("method not allowed", { status: 405, headers: CORS });
|
|
55
|
+
}
|
|
56
|
+
return Response.json(opts.getInfo(), { headers: CORS });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return new Response("not found", { status: 404, headers: CORS });
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const port = server.port ?? opts.port;
|
|
64
|
+
return {
|
|
65
|
+
server,
|
|
66
|
+
port,
|
|
67
|
+
url: `http://${hostname === "0.0.0.0" ? "localhost" : hostname}:${port}`,
|
|
68
|
+
stop: () => server.stop(true),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Locates or builds the Go room-server binary and reports where it landed.
|
|
2
|
+
//
|
|
3
|
+
// Installed (npm) path: a prebuilt binary ships in the platform package
|
|
4
|
+
// (@siltrun/room-server-<os>-<arch>) — paths.ts resolves it, no Go toolchain needed.
|
|
5
|
+
// In-repo path: build from source (same approach as demo/run.sh: `go build -o <bin> .`
|
|
6
|
+
// in the room-server dir) so local .go edits are always honored.
|
|
7
|
+
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { existsSync, statSync } from "node:fs";
|
|
10
|
+
import { readdir } from "node:fs/promises";
|
|
11
|
+
import { paths, platformKey } from "./paths.ts";
|
|
12
|
+
import { log } from "./log.ts";
|
|
13
|
+
|
|
14
|
+
export class ServerBuildError extends Error {
|
|
15
|
+
constructor(
|
|
16
|
+
message: string,
|
|
17
|
+
readonly detail = "",
|
|
18
|
+
) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "ServerBuildError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Newest mtime among the room-server's .go sources, for a staleness check. */
|
|
25
|
+
async function newestGoMtime(dir: string): Promise<number> {
|
|
26
|
+
const entries = await readdir(dir);
|
|
27
|
+
let newest = 0;
|
|
28
|
+
for (const name of entries) {
|
|
29
|
+
if (!name.endsWith(".go")) continue;
|
|
30
|
+
const m = statSync(join(dir, name)).mtimeMs;
|
|
31
|
+
if (m > newest) newest = m;
|
|
32
|
+
}
|
|
33
|
+
return newest;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Ensure a room-server binary exists; return its absolute path.
|
|
38
|
+
*
|
|
39
|
+
* Resolution: prebuilt binary (env override or installed platform package, via
|
|
40
|
+
* paths.ts) → in-repo `go build` (rebuilds when the binary is missing or older than
|
|
41
|
+
* any .go source). Throws ServerBuildError when neither is available or the build
|
|
42
|
+
* fails.
|
|
43
|
+
*/
|
|
44
|
+
export async function ensureRoomServer(): Promise<string> {
|
|
45
|
+
// Prebuilt path (installed from npm, or SILT_ROOM_SERVER_BIN): trust it as-is.
|
|
46
|
+
if (paths.roomServerBin) {
|
|
47
|
+
if (!existsSync(paths.roomServerBin)) {
|
|
48
|
+
throw new ServerBuildError(`room-server binary not found at ${paths.roomServerBin}`);
|
|
49
|
+
}
|
|
50
|
+
return paths.roomServerBin;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const dir = paths.roomServerDir;
|
|
54
|
+
if (!existsSync(dir)) {
|
|
55
|
+
throw new ServerBuildError(
|
|
56
|
+
`no room-server available: no prebuilt binary for ${platformKey} ` +
|
|
57
|
+
`(@siltrun/room-server-${platformKey}) and no source checkout at ${dir}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const binPath = join(dir, "silt-room-server");
|
|
62
|
+
const upToDate =
|
|
63
|
+
existsSync(binPath) && statSync(binPath).mtimeMs >= (await newestGoMtime(dir));
|
|
64
|
+
|
|
65
|
+
if (upToDate) return binPath;
|
|
66
|
+
|
|
67
|
+
log.info("building room-server (go build)…");
|
|
68
|
+
const proc = Bun.spawn(["go", "build", "-o", binPath, "."], {
|
|
69
|
+
cwd: dir,
|
|
70
|
+
stdout: "pipe",
|
|
71
|
+
stderr: "pipe",
|
|
72
|
+
});
|
|
73
|
+
const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
|
|
74
|
+
if (code !== 0) {
|
|
75
|
+
throw new ServerBuildError("go build failed", stderr.trim());
|
|
76
|
+
}
|
|
77
|
+
return binPath;
|
|
78
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Regression test for the `siltrun` shim signal-propagation bug (review finding #5).
|
|
2
|
+
//
|
|
3
|
+
// bin/silt.mjs re-execs the real CLI under Bun and is meant to be transparent: if
|
|
4
|
+
// the child dies from a signal, the shim's own exit should reflect that signal.
|
|
5
|
+
// It re-raised the signal to itself — but its SIGINT/SIGTERM handlers were still
|
|
6
|
+
// installed, so the re-raise hit the JS handler (which only forwards to the
|
|
7
|
+
// already-dead child) and the shim fell through to exit 0, swallowing the signal.
|
|
8
|
+
// The fix removes its own handlers before re-raising so the default disposition
|
|
9
|
+
// terminates it.
|
|
10
|
+
//
|
|
11
|
+
// We drive the REAL shim with a stand-in "bun" that just sleeps (so there's a live
|
|
12
|
+
// child to signal), SIGINT the shim, and assert it exits BY signal / non-zero.
|
|
13
|
+
|
|
14
|
+
import { expect, test } from "bun:test";
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
const SHIM = join(import.meta.dir, "..", "bin", "silt.mjs");
|
|
21
|
+
|
|
22
|
+
test("SIGINT to the shim exits by signal (not swallowed to 0)", async () => {
|
|
23
|
+
const dir = mkdtempSync(join(tmpdir(), "silt-shim-"));
|
|
24
|
+
// A stand-in for the bun binary: ignore the args the shim passes (the cli entry)
|
|
25
|
+
// and just sleep, so the shim has a live child to forward the signal to.
|
|
26
|
+
const fakeBun = join(dir, "fake-bun.sh");
|
|
27
|
+
writeFileSync(fakeBun, "#!/bin/sh\nexec sleep 30\n");
|
|
28
|
+
chmodSync(fakeBun, 0o755);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const shim = spawn(process.execPath, [SHIM], {
|
|
32
|
+
env: { ...process.env, SILT_BUN: fakeBun },
|
|
33
|
+
stdio: "ignore",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const exit = new Promise<{ code: number | null; signal: string | null }>((resolve) => {
|
|
37
|
+
shim.on("exit", (code, signal) => resolve({ code, signal }));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Give the shim time to spawn its child and install its signal handlers.
|
|
41
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
42
|
+
shim.kill("SIGINT");
|
|
43
|
+
|
|
44
|
+
const { code, signal } = await exit;
|
|
45
|
+
// Killed-by-signal → signal="SIGINT", code=null. Some platforms surface it as
|
|
46
|
+
// exit code 130 (128+SIGINT) instead. Either is correct; a swallowed signal
|
|
47
|
+
// (the bug) is code=0, signal=null.
|
|
48
|
+
const diedBySignal = signal === "SIGINT" || code === 130;
|
|
49
|
+
expect(diedBySignal).toBe(true);
|
|
50
|
+
expect(code === 0 && signal === null).toBe(false);
|
|
51
|
+
} finally {
|
|
52
|
+
rmSync(dir, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
}, 15_000);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { parseCertHash } from "./supervisor.ts";
|
|
3
|
+
|
|
4
|
+
describe("parseCertHash", () => {
|
|
5
|
+
test("extracts the hash from the room-server's boot line", () => {
|
|
6
|
+
// exact format from packages/room-server/main.go
|
|
7
|
+
expect(parseCertHash("CERT-SHA256-HEX: a1b2c3d4e5f6")).toBe("a1b2c3d4e5f6");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("lowercases", () => {
|
|
11
|
+
expect(parseCertHash("CERT-SHA256-HEX: ABCDEF01")).toBe("abcdef01");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("tolerates extra whitespace", () => {
|
|
15
|
+
expect(parseCertHash("CERT-SHA256-HEX: deadbeef")).toBe("deadbeef");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("ignores unrelated lines", () => {
|
|
19
|
+
expect(parseCertHash("silt room server listening on 127.0.0.1:4433")).toBeNull();
|
|
20
|
+
expect(parseCertHash("session upgraded (handshake OK)")).toBeNull();
|
|
21
|
+
expect(parseCertHash("")).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Process supervision for the Go room-server child.
|
|
2
|
+
//
|
|
3
|
+
// Responsibilities:
|
|
4
|
+
// - spawn the room-server binary and stream its logs (demoted, prefixed) to our output
|
|
5
|
+
// - scrape the per-boot cert hash it prints ("CERT-SHA256-HEX: <hex>", per main.go)
|
|
6
|
+
// - restart on crash with exponential backoff, clearly logged
|
|
7
|
+
// - support an INTENTIONAL restart (hot-reload) that is not counted as a crash
|
|
8
|
+
// - tear everything down on stop()
|
|
9
|
+
//
|
|
10
|
+
// One request in flight, one child at a time. The cert changes on every boot, so each
|
|
11
|
+
// (re)spawn resolves a fresh cert; callers update room-info from onCert.
|
|
12
|
+
|
|
13
|
+
import { log } from "./log.ts";
|
|
14
|
+
|
|
15
|
+
// The room-server prints this line once at boot (packages/room-server/main.go).
|
|
16
|
+
const CERT_LINE = /CERT-SHA256-HEX:\s*([0-9a-fA-F]{4,})/;
|
|
17
|
+
|
|
18
|
+
/** Pure, testable: extract the cert hash from a log line, or null. */
|
|
19
|
+
export function parseCertHash(line: string): string | null {
|
|
20
|
+
const m = CERT_LINE.exec(line);
|
|
21
|
+
return m ? m[1]!.toLowerCase() : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Deferred<T> {
|
|
25
|
+
promise: Promise<T>;
|
|
26
|
+
resolve: (v: T) => void;
|
|
27
|
+
reject: (e: unknown) => void;
|
|
28
|
+
}
|
|
29
|
+
function deferred<T>(): Deferred<T> {
|
|
30
|
+
let resolve!: (v: T) => void;
|
|
31
|
+
let reject!: (e: unknown) => void;
|
|
32
|
+
const promise = new Promise<T>((res, rej) => {
|
|
33
|
+
resolve = res;
|
|
34
|
+
reject = rej;
|
|
35
|
+
});
|
|
36
|
+
return { promise, resolve, reject };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SupervisorOptions {
|
|
40
|
+
binPath: string;
|
|
41
|
+
args: string[];
|
|
42
|
+
cwd?: string;
|
|
43
|
+
env?: Record<string, string>;
|
|
44
|
+
/** Called every time a fresh cert hash is observed (initial boot + each restart). */
|
|
45
|
+
onCert: (hash: string) => void;
|
|
46
|
+
/** How long to wait for the cert line before declaring a boot failure. */
|
|
47
|
+
certTimeoutMs?: number;
|
|
48
|
+
backoff?: { baseMs?: number; maxMs?: number; stableMs?: number };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class ServerSupervisor {
|
|
52
|
+
private proc: Bun.Subprocess | null = null;
|
|
53
|
+
private stopping = false;
|
|
54
|
+
private restarting = false;
|
|
55
|
+
private crashCount = 0;
|
|
56
|
+
private certPending: Deferred<string> | null = null;
|
|
57
|
+
private recentErr: string[] = [];
|
|
58
|
+
|
|
59
|
+
private readonly baseMs: number;
|
|
60
|
+
private readonly maxMs: number;
|
|
61
|
+
private readonly stableMs: number;
|
|
62
|
+
|
|
63
|
+
constructor(private readonly opts: SupervisorOptions) {
|
|
64
|
+
this.baseMs = opts.backoff?.baseMs ?? 250;
|
|
65
|
+
this.maxMs = opts.backoff?.maxMs ?? 5000;
|
|
66
|
+
this.stableMs = opts.backoff?.stableMs ?? 4000;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Boot the server and resolve once the cert hash appears. Rejects on early exit. */
|
|
70
|
+
async start(): Promise<string> {
|
|
71
|
+
return this.spawnAndAwaitCert();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Intentional restart (e.g. contract reload). Resolves with the new cert hash. */
|
|
75
|
+
async restart(): Promise<string> {
|
|
76
|
+
this.restarting = true;
|
|
77
|
+
await this.killCurrent();
|
|
78
|
+
this.restarting = false;
|
|
79
|
+
return this.spawnAndAwaitCert();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async stop(): Promise<void> {
|
|
83
|
+
this.stopping = true;
|
|
84
|
+
await this.killCurrent();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async killCurrent(): Promise<void> {
|
|
88
|
+
const proc = this.proc;
|
|
89
|
+
if (!proc) return;
|
|
90
|
+
this.proc = null;
|
|
91
|
+
try {
|
|
92
|
+
proc.kill("SIGTERM");
|
|
93
|
+
} catch {
|
|
94
|
+
/* already gone */
|
|
95
|
+
}
|
|
96
|
+
// Give it a beat to exit cleanly; escalate if it lingers.
|
|
97
|
+
const exited = proc.exited;
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
try {
|
|
100
|
+
proc.kill("SIGKILL");
|
|
101
|
+
} catch {
|
|
102
|
+
/* ignore */
|
|
103
|
+
}
|
|
104
|
+
}, 2000);
|
|
105
|
+
await exited;
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private spawnAndAwaitCert(): Promise<string> {
|
|
110
|
+
const pending = deferred<string>();
|
|
111
|
+
this.certPending = pending;
|
|
112
|
+
this.recentErr = [];
|
|
113
|
+
|
|
114
|
+
const startedAt = Date.now();
|
|
115
|
+
const proc = Bun.spawn([this.opts.binPath, ...this.opts.args], {
|
|
116
|
+
cwd: this.opts.cwd,
|
|
117
|
+
env: { ...process.env, ...this.opts.env },
|
|
118
|
+
stdout: "pipe",
|
|
119
|
+
stderr: "pipe",
|
|
120
|
+
});
|
|
121
|
+
this.proc = proc;
|
|
122
|
+
|
|
123
|
+
void this.pumpLines(proc.stdout, (line) => {
|
|
124
|
+
const hash = parseCertHash(line);
|
|
125
|
+
if (hash) {
|
|
126
|
+
this.opts.onCert(hash);
|
|
127
|
+
pending.resolve(hash);
|
|
128
|
+
}
|
|
129
|
+
log.child("server", line);
|
|
130
|
+
});
|
|
131
|
+
void this.pumpLines(proc.stderr, (line) => {
|
|
132
|
+
this.recentErr.push(line);
|
|
133
|
+
if (this.recentErr.length > 20) this.recentErr.shift();
|
|
134
|
+
log.child("server", line);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
void proc.exited.then((code) => this.handleExit(proc, code, startedAt));
|
|
138
|
+
|
|
139
|
+
// Boot-timeout guard so start() never hangs forever on a wedged server.
|
|
140
|
+
const timeout = this.opts.certTimeoutMs ?? 15000;
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
pending.reject(
|
|
143
|
+
new Error(
|
|
144
|
+
`room-server did not print a cert hash within ${timeout}ms` +
|
|
145
|
+
this.errTail(),
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
}, timeout);
|
|
149
|
+
void pending.promise.finally(() => clearTimeout(timer));
|
|
150
|
+
|
|
151
|
+
return pending.promise;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private handleExit(proc: Bun.Subprocess, code: number | null, startedAt: number) {
|
|
155
|
+
if (proc !== this.proc && this.proc !== null) return; // superseded by a newer spawn
|
|
156
|
+
if (this.stopping || this.restarting) return; // expected teardown/restart
|
|
157
|
+
|
|
158
|
+
// Unexpected exit = crash.
|
|
159
|
+
const uptime = Date.now() - startedAt;
|
|
160
|
+
if (uptime > this.stableMs) this.crashCount = 0; // it had been healthy; reset backoff
|
|
161
|
+
this.crashCount += 1;
|
|
162
|
+
|
|
163
|
+
// If start()/restart() is still waiting on the cert, surface the crash to it.
|
|
164
|
+
if (this.certPending) {
|
|
165
|
+
this.certPending.reject(
|
|
166
|
+
new Error(`room-server exited (code ${code}) before booting${this.errTail()}`),
|
|
167
|
+
);
|
|
168
|
+
this.certPending = null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const delay = Math.min(this.maxMs, this.baseMs * 2 ** (this.crashCount - 1));
|
|
172
|
+
log.error(
|
|
173
|
+
`room-server exited (code ${code}); restarting in ${delay}ms (attempt ${this.crashCount})`,
|
|
174
|
+
);
|
|
175
|
+
setTimeout(() => {
|
|
176
|
+
if (this.stopping) return;
|
|
177
|
+
log.warn(`restarting room-server (backoff attempt ${this.crashCount})`);
|
|
178
|
+
// Non-fatal on early exit: a crash-loop keeps retrying rather than killing dev.
|
|
179
|
+
void this.spawnAndAwaitCert().catch((e) => {
|
|
180
|
+
log.error(`respawn failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
181
|
+
});
|
|
182
|
+
}, delay);
|
|
183
|
+
|
|
184
|
+
void code; // referenced above; keep for clarity
|
|
185
|
+
void proc;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private errTail(): string {
|
|
189
|
+
return this.recentErr.length ? `\n last server output:\n ${this.recentErr.join("\n ")}` : "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private async pumpLines(
|
|
193
|
+
stream: ReadableStream<Uint8Array>,
|
|
194
|
+
onLine: (line: string) => void,
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
const reader = stream.getReader();
|
|
197
|
+
const decoder = new TextDecoder();
|
|
198
|
+
let buf = "";
|
|
199
|
+
try {
|
|
200
|
+
for (;;) {
|
|
201
|
+
const { done, value } = await reader.read();
|
|
202
|
+
if (done) break;
|
|
203
|
+
buf += decoder.decode(value, { stream: true });
|
|
204
|
+
let idx: number;
|
|
205
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
206
|
+
const line = buf.slice(0, idx).replace(/\r$/, "");
|
|
207
|
+
if (line.length) onLine(line);
|
|
208
|
+
buf = buf.slice(idx + 1);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (buf.trim().length) onLine(buf.trim());
|
|
212
|
+
} catch {
|
|
213
|
+
/* stream closed on process exit — normal */
|
|
214
|
+
} finally {
|
|
215
|
+
reader.releaseLock();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|