diff --git a/README.md b/README.md index 13e66f7..4bc8f5c 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,28 @@ bili --no-auto-update # disable self-update for this run Flags override env vars and the config file. `bili --help` lists them all. +### Per-session proxy (`bili daemon`) + +Agent-side plugins can start a dedicated, isolated proxy per session instead of +sharing one long-lived instance: + +```bash +bili daemon --parent-pid +# stdout: {"origin":"http://127.0.0.1:","port":,"pid":,"logPath":"…"} +# exit 0 on success; non-zero + stderr explanation on failure +``` + +- The port is allocated dynamically (ephemeral bind-0 probe). An explicit + `--port` / `ACP_PORT` is preferred first, with automatic fallback on conflict. +- A fresh instance is **always** started — it never attaches to an existing + proxy, so sessions stay isolated from each other. +- With `--parent-pid` (or an inherited `BILI_PARENT_PID`), the proxy stops + itself when that process exits — the same mechanism `bili ` uses. + Without it, a warning is printed and the proxy keeps running until killed. +- Concurrency-safe: daemons handshakes through a per-session result file, so + parallel daemons never clobber each other. The global instance file is still + written (last-writer-wins) for MCP-shell/install discovery. + ### Remote agents (`--host`) By default the proxy binds `127.0.0.1` and only accepts loopback diff --git a/src/cli.ts b/src/cli.ts index 193d4b8..c65449d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -27,7 +27,7 @@ import { checkForUpdate, startAutoUpdate } from "./update.js"; import { resolveProxy } from "./upstream-proxy.js"; import { runMcpStdio } from "./mcp.js"; import { PLUGIN_AGENTS, isPluginAgent, pluginInstall, pluginRemove, pluginStatusAll, type PluginAgent } from "./plugin-install.js"; -import { runLaunch, runTestPi, isLaunchClient, type ClientName } from "./launcher.js"; +import { runDaemon, runLaunch, runTestPi, isLaunchClient, type ClientName } from "./launcher.js"; import { exportSession } from "./export.js"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -70,8 +70,11 @@ Usage: bili test pi non-polluting pi smoke test through the proxy bili export [session] [--full] list sessions / export one as a Markdown handoff (--full includes original messages; --output FILE) - bili update check for & install a newer version now - bili plugin install install the thin plugin into a host (pi/omp/ + bili update check for & install a newer version now + bili daemon [--parent-pid N] start a per-session proxy on a dynamic port + (always a fresh instance; prints one JSON line + {origin,port,pid,logPath} to stdout, exit 0) + bili plugin install install the thin plugin into a host (pi/omp/ claude/codex/opencode; original backed up once) bili plugin remove remove it again bili plugin list show install status for every host @@ -109,9 +112,11 @@ Options (override config file / env): --mitm-domain extra MITM domain (repeatable; launcher only) --config path to config JSON (default: XDG location) --debug verbose logging - --passthrough forward without compression - --no-passthrough force compression on (overrides config) - --no-auto-update disable background self-update this run + --passthrough forward without compression + --no-passthrough force compression on (overrides config) + --no-auto-update disable background self-update this run + --parent-pid host pid to watch for auto-stop (daemon only; + also honored from BILI_PARENT_PID env) Config: ${defaultConfigFile()} Set port/host/debug/providers/compress/autoUpdate there. See README §Configuration. @@ -121,7 +126,7 @@ Docs: https://github.com/ranxianglei/billion-context `; type Parsed = { - command: "start" | "update" | "help" | "version" | "launch" | "test" | "export" | "plugin-register" | "mcp" | "plugin"; + command: "start" | "update" | "help" | "version" | "launch" | "test" | "export" | "plugin-register" | "mcp" | "plugin" | "daemon"; client?: ClientName; clientArgs: string[]; mitmDomains: string[]; @@ -130,6 +135,7 @@ type Parsed = { exportOutput?: string; exportFull?: boolean; registerConversationId?: string; + parentPid?: number; pluginAction?: "install" | "remove" | "list"; pluginAgent?: PluginAgent; }; @@ -143,6 +149,7 @@ export function parseArgs(argv: string[]): Parsed { const mitmDomains: string[] = []; let exportSelector: string | undefined; let registerConversationId: string | undefined; + let parentPid: number | undefined; let exportOutput: string | undefined; let exportFull = false; let pluginAction: Parsed["pluginAction"]; @@ -189,6 +196,15 @@ export function parseArgs(argv: string[]): Parsed { mitmDomains.push(val); break; } + case "--parent-pid": { + const val = argv[++i]; + if (val === undefined || !/^\d+$/.test(val) || Number(val) <= 0) { + console.error(`bili: ${a} requires a positive integer pid`); + process.exit(2); + } + parentPid = Number(val); + break; + } case "--full": exportFull = true; break; @@ -248,6 +264,8 @@ export function parseArgs(argv: string[]): Parsed { command = command === "help" || command === "version" ? command : "start"; } else if (cmd === "update") { command = "update"; + } else if (cmd === "daemon") { + command = "daemon"; } else if (cmd === "export") { command = "export"; exportSelector = positional[1]; @@ -292,11 +310,11 @@ export function parseArgs(argv: string[]): Parsed { } } - return { command, client, clientArgs, mitmDomains, overrides, exportSelector, exportOutput, exportFull, registerConversationId, pluginAction, pluginAgent }; + return { command, client, clientArgs, mitmDomains, overrides, exportSelector, exportOutput, exportFull, registerConversationId, parentPid, pluginAction, pluginAgent }; } export async function main(): Promise { - const { command, client, clientArgs, mitmDomains, overrides, exportSelector, exportOutput, exportFull, registerConversationId, pluginAction, pluginAgent } = parseArgs(process.argv.slice(2)); + const { command, client, clientArgs, mitmDomains, overrides, exportSelector, exportOutput, exportFull, registerConversationId, parentPid, pluginAction, pluginAgent } = parseArgs(process.argv.slice(2)); if (command === "help") { process.stdout.write(HELP); return; @@ -411,6 +429,16 @@ export async function main(): Promise { await runLaunch({ client: client!, clientArgs, mitmDomains, overrides }); return; } + if (command === "daemon") { + // Merge overrides into env BEFORE spawning: the proxy child inherits + // this process's env, and the generic merge below runs only on the + // server path (which this branch returns ahead of). + for (const [k, v] of Object.entries(overrides)) { + if (v !== undefined) process.env[k] = v; + } + await runDaemon({ overrides, mitmDomains, parentPid }); + return; + } for (const [k, v] of Object.entries(overrides)) { if (v !== undefined) process.env[k] = v; diff --git a/src/launcher.ts b/src/launcher.ts index 73d0f90..00ab096 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -117,6 +117,19 @@ export interface LaunchOptions { * BILI_LAUNCHER_MODEL_WINDOWS so the nudge denominator matches the * client's real window instead of the built-in table guess. */ modelWindows?: Record; + /** Skip the attach-to-existing step: always spawn a fresh, owned + * instance (per-session isolation for `bili daemon`, #518). */ + fresh?: boolean; + /** Per-session handshake file: passed to the child as BILI_RESULT_FILE; + * the child writes its bind record there after a successful listen and + * this caller polls ONLY that file (concurrent daemons must not clobber + * each other's handshake through the single global instance file). */ + resultFile?: string; + /** Host process to watch via BILI_PARENT_PID (#414 parent-gone reaping). + * undefined → this process (launcher semantics); null → no watcher env + * at all (daemon without --parent-pid; defaulting to the daemon's own + * pid would suicide the proxy within 2s). */ + parentPid?: number | null; } export interface ProxyHandle { @@ -1409,6 +1422,24 @@ async function probeExistingInstance( return inst; } +/** Bind-0 probe: ask the kernel for an ephemeral port on `host`. The port + * may be stolen before the proxy child binds it — the child's EADDRINUSE + * retry (#407) plus the launch-token handshake reporting the REAL origin + * make the race harmless. */ +export function allocateDynamicPort(host = LAUNCHER_DEFAULT_HOST): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.once("error", reject); + srv.listen(0, host, () => { + const addr = srv.address(); + srv.close(() => { + if (addr && typeof addr === "object") resolve(addr.port); + else reject(new Error("could not allocate a free port")); + }); + }); + }); +} + export function findFreePort(preferred: number, host = LAUNCHER_DEFAULT_HOST): Promise { const tryBind = (port: number): Promise => new Promise((resolve) => { @@ -1417,20 +1448,7 @@ export function findFreePort(preferred: number, host = LAUNCHER_DEFAULT_HOST): P srv.once("listening", () => srv.close(() => resolve(true))); srv.listen(port, host); }); - return tryBind(preferred).then((free) => { - if (free) return preferred; - return new Promise((resolve, reject) => { - const srv = net.createServer(); - srv.once("error", reject); - srv.listen(0, host, () => { - const addr = srv.address(); - srv.close(() => { - if (addr && typeof addr === "object") resolve(addr.port); - else reject(new Error("could not allocate a free port")); - }); - }); - }); - }); + return tryBind(preferred).then((free) => (free ? Promise.resolve(preferred) : allocateDynamicPort(host))); } export function pickEphemeralPort(host = LAUNCHER_DEFAULT_HOST): Promise { @@ -1484,11 +1502,14 @@ export async function ensureProxyRunning( // #394/#417: a healthy proxy with a compatible config is SHARED, not // doubled — two concurrent launches of the same client would otherwise - // spawn two writers over one sessions dir. - const existing = await probeExistingInstance(readInstance, fetchHealthInfo); - if (existing && instanceCompatible(existing, opts)) { - console.error(`bili: attaching to running proxy at ${existing.origin} (pid ${existing.pid})`); - return { origin: existing.origin, port: existing.port, attached: true }; + // spawn two writers over one sessions dir. fresh (#518 daemon) skips this: + // per-session instances must never attach to someone else's proxy. + if (!opts.fresh) { + const existing = await probeExistingInstance(readInstance, fetchHealthInfo); + if (existing && instanceCompatible(existing, opts)) { + console.error(`bili: attaching to running proxy at ${existing.origin} (pid ${existing.pid})`); + return { origin: existing.origin, port: existing.port, attached: true }; + } } // #407: no probe-release-rebind. The child binds the preferred port @@ -1515,7 +1536,8 @@ export async function ensureProxyRunning( env: { ...stripInheritedProxy(process.env), BILI_LAUNCH_TOKEN: launchToken, - BILI_PARENT_PID: String(process.pid), + ...(opts.parentPid === null ? {} : { BILI_PARENT_PID: String(opts.parentPid ?? process.pid) }), + ...(opts.resultFile ? { BILI_RESULT_FILE: opts.resultFile } : {}), ...(opts.mitmDomains && opts.mitmDomains.length ? { BILI_MITM_DOMAINS: opts.mitmDomains.join(",") } : {}), @@ -1545,11 +1567,17 @@ export async function ensureProxyRunning( }; }); + // With a resultFile the child reports into OUR file (BILI_RESULT_FILE), + // so concurrent daemons never clobber each other's handshake through the + // single global instance file (#518). + const readHandshake = (): ReturnType => + opts.resultFile ? readProxyInstanceFile(opts.resultFile) : readInstance(); + const deadline = now() + SPAWN_WAIT_MS; while (now() < deadline) { if (childExit) break; await sleepImpl(HEALTH_POLL_INTERVAL_MS); - const inst = readInstance(); + const inst = readHandshake(); if (isProxyInstanceFile(inst) && inst.launchToken === launchToken) { if (await probeHealth(inst.origin, fetchImpl)) { return { origin: inst.origin, port: inst.port, child, logPath }; @@ -1597,6 +1625,89 @@ export function stopProxy(handle: ProxyHandle): void { } catch {} } +export interface DaemonParams { + overrides: Record; + mitmDomains?: string[]; + /** Host agent's pid for BILI_PARENT_PID reaping (#414). Omitted → the + * child gets NO watcher env (never defaults to this process: the daemon + * exits right away and would suicide the proxy within 2s). */ + parentPid?: number; +} + +export interface DaemonResult { + origin: string; + port: number; + pid: number; + logPath?: string; +} + +export async function spawnDaemonProxy( + opts: { + host: string; + port?: number; + passthrough: boolean; + debug: boolean; + mitmDomains?: string[]; + parentPid?: number | null; + resultFile?: string; + }, + deps: LauncherDeps = {}, +): Promise { + const port = opts.port ?? (await allocateDynamicPort(opts.host)); + const handle = await ensureProxyRunning( + { + host: opts.host, + port, + passthrough: opts.passthrough, + debug: opts.debug, + mitmDomains: opts.mitmDomains, + fresh: true, + resultFile: opts.resultFile, + parentPid: opts.parentPid, + }, + deps, + ); + if (!handle.child || handle.child.pid === undefined) { + throw new Error("bili daemon: proxy handle has no child pid"); + } + return { origin: handle.origin, port: handle.port, pid: handle.child.pid, logPath: handle.logPath }; +} + +export async function runDaemon(params: DaemonParams, deps: LauncherDeps = {}): Promise { + const host = params.overrides.ACP_HOST?.trim() || LAUNCHER_DEFAULT_HOST; + const rawPort = params.overrides.ACP_PORT ?? process.env.ACP_PORT; + const port = rawPort && rawPort.trim() ? parsePort(rawPort) : undefined; + const passthrough = params.overrides.ACP_PASSTHROUGH === "1"; + const debug = params.overrides.ACP_DEBUG === "1"; + + const envParent = parseInt(process.env.BILI_PARENT_PID ?? "", 10); + const hostPid = params.parentPid ?? (!Number.isNaN(envParent) && envParent > 0 ? envParent : undefined); + if (hostPid === undefined) { + console.error("bili daemon: no --parent-pid given — the proxy keeps running after this command exits (no auto-stop)"); + } + + const resultFile = path.join(os.tmpdir(), `bili-daemon-${randomUUID()}.json`); + try { + const res = await spawnDaemonProxy( + { host, port, passthrough, debug, mitmDomains: params.mitmDomains, parentPid: hostPid ?? null, resultFile }, + deps, + ); + try { + fs.unlinkSync(resultFile); + } catch {} + // Single-line JSON on stdout is a machine contract (#518) — keep stdout + // free of anything else. exitCode (not process.exit) so the pipe flushes. + process.stdout.write(`${JSON.stringify(res)}\n`); + process.exitCode = 0; + } catch (err) { + try { + fs.unlinkSync(resultFile); + } catch {} + console.error(`bili daemon: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } +} + export function runClient( cmd: string, args: string[], diff --git a/src/server.ts b/src/server.ts index c594eef..457b24d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -54,7 +54,7 @@ import { reapOrphanBlocks } from "./orphan-gc.js"; import { getStore } from "./persist.js"; import { log as loggerLog, configureLogger, getLogPath, closeLogger } from "./logger.js"; import { configFile, defaultLogFile, stateDir } from "./paths.js"; -import { atomicWriteInstanceFile, clearProxyInstanceFile, isPidAlive, registerInstanceAndWarn, unregisterInstance } from "./instance.js"; +import { atomicWriteInstanceFile, clearProxyInstanceFile, isPidAlive, isProxyInstanceFile, readProxyInstanceFile, registerInstanceAndWarn, unregisterInstance } from "./instance.js"; import { compressLoopResponsesJson } from "./compress-loop-responses.js"; import { runCompressLoop, pickAdapter } from "./loop/index.js"; import { containsToolCallXmlFragment } from "./loop/tag-echo-filter.js"; @@ -408,6 +408,7 @@ export async function startServer(opts: ProxyOptions): Promise { // EADDRINUSE instead of dying, reporting the real origin via the instance // file (launchToken match). Manual `bili start` keeps fail-fast semantics. const launchToken = process.env.BILI_LAUNCH_TOKEN?.trim(); + const resultFile = process.env.BILI_RESULT_FILE?.trim() || undefined; const MAX_LISTEN_ATTEMPTS = 17; let listenAttempts = 0; let lastTriedPort = opts.port; @@ -424,21 +425,25 @@ export async function startServer(opts: ProxyOptions): Promise { // so the file always holds a valid URL. const originHost = opts.host === "0.0.0.0" || opts.host === "::" || opts.host === "localhost" ? "127.0.0.1" : opts.host.includes(":") && !opts.host.startsWith("[") ? `[${opts.host}]` : opts.host; const origin = `http://${originHost}:${actualPort}`; + const instanceInfo = { + origin, + instanceId, + pid: process.pid, + startedAt: instanceStartedAt, + host: opts.host, + port: actualPort, + passthrough: opts.passthrough, + mitmDomains: opts.mitm.enabled ? opts.mitm.domains : [], + modelWindows: { ...LAUNCHER_MODEL_WINDOWS }, + launchToken: launchToken || undefined, + }; try { fs.mkdirSync(stateDir(), { recursive: true }); hydratePrefixAffinity(); - atomicWriteInstanceFile({ - origin, - instanceId, - pid: process.pid, - startedAt: instanceStartedAt, - host: opts.host, - port: actualPort, - passthrough: opts.passthrough, - mitmDomains: opts.mitm.enabled ? opts.mitm.domains : [], - modelWindows: { ...LAUNCHER_MODEL_WINDOWS }, - launchToken: launchToken || undefined, - }); + atomicWriteInstanceFile(instanceInfo); + // Per-session handshake file (#518): daemon parents poll ONLY their + // own file, so concurrent daemons never clobber each other's token. + if (resultFile) atomicWriteInstanceFile(instanceInfo, resultFile); } catch { // best-effort discovery hint for host-spawned MCP shells } @@ -534,6 +539,15 @@ export async function startServer(opts: ProxyOptions): Promise { const finishShutdown = (): void => { flushPrefixAffinity(); clearProxyInstanceFile(instanceId); + // Per-session handshake file (#518): parents normally delete it right + // after the handshake; this covers the parent-died-early case. Guarded + // like clearProxyInstanceFile — never unlink a record we don't own. + if (resultFile) { + try { + const rec = readProxyInstanceFile(resultFile); + if (isProxyInstanceFile(rec) && rec.instanceId === instanceId) fs.unlinkSync(resultFile); + } catch {} + } unregisterInstance(instanceId); closeLogger(); process.exit(0); diff --git a/tests/cli-parseargs.test.ts b/tests/cli-parseargs.test.ts index 9f67735..772e907 100644 --- a/tests/cli-parseargs.test.ts +++ b/tests/cli-parseargs.test.ts @@ -33,3 +33,32 @@ test("parseArgs: -F composes with other bili flags before the client (#346)", () assert.equal(r.overrides.BILI_UPSTREAM_PROXY, "http://127.0.0.1:7897"); assert.deepEqual(r.clientArgs, []); }); + +test("parseArgs: daemon command with --parent-pid (#518)", () => { + const r = parseArgs(["daemon", "--parent-pid", "42"]); + assert.equal(r.command, "daemon"); + assert.equal(r.parentPid, 42); +}); + +test("parseArgs: bare daemon command has no parent pid (#518)", () => { + const r = parseArgs(["daemon"]); + assert.equal(r.command, "daemon"); + assert.equal(r.parentPid, undefined); +}); + +test("parseArgs: --parent-pid rejects non-numeric, zero, and missing values (#518)", () => { + const prevExit = process.exit; + let exited: number | undefined; + process.exit = ((code?: number) => { + exited = code; + throw new Error(`process.exit(${code})`); + }) as typeof process.exit; + try { + for (const bad of [["abc"], ["0"], ["-5"], []]) { + assert.throws(() => parseArgs(["daemon", "--parent-pid", ...bad])); + assert.equal(exited, 2); + } + } finally { + process.exit = prevExit; + } +}); diff --git a/tests/daemon.test.ts b/tests/daemon.test.ts new file mode 100644 index 0000000..20795b3 --- /dev/null +++ b/tests/daemon.test.ts @@ -0,0 +1,302 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import type { ProxyInstanceFile } from "../src/instance.ts"; +import { + allocateDynamicPort, + ensureProxyRunning, + runDaemon, + spawnDaemonProxy, + type LauncherDeps, + type SpawnChild, + type SpawnFn, +} from "../src/launcher.ts"; + +function rec(over: Partial = {}): ProxyInstanceFile { + return { + origin: "http://127.0.0.1:8787", + instanceId: "inst-1", + pid: process.pid, + startedAt: Date.now(), + host: "127.0.0.1", + port: 8787, + passthrough: false, + mitmDomains: [], + modelWindows: {}, + ...over, + }; +} + +function fakeChild(pid = 4242): SpawnChild { + return { pid, unref() {}, kill() {} }; +} + +const BASE_OPTS = { host: "127.0.0.1", port: 9911, passthrough: false, debug: false }; + +interface Sink { + env?: NodeJS.ProcessEnv; + args?: readonly string[]; +} + +// Fake child that performs the launch-token handshake against the per-session +// result file exactly like a real proxy does (env BILI_RESULT_FILE, record +// carrying the token and the REAL bound port). +function handshakeSpawn(sink: Sink): SpawnFn { + return (_cmd, args, options) => { + sink.env = options.env; + sink.args = args; + const target = options.env?.BILI_RESULT_FILE; + const port = Number(args[args.indexOf("--port") + 1]); + setImmediate(() => { + if (!target) return; + fs.writeFileSync(target, JSON.stringify(rec({ origin: `http://127.0.0.1:${port}`, port, launchToken: options.env?.BILI_LAUNCH_TOKEN ?? "" }))); + }); + return fakeChild(); + }; +} + +function handshakeDeps(sink: Sink, over: Partial = {}): LauncherDeps { + return { + fetchImpl: async (url) => ({ ok: url.includes("/__bili/health") && !url.startsWith("http://127.0.0.1:8787") }), + fetchHealthInfo: async () => ({ ok: true }), + // Live, compatible GLOBAL instance: if the fresh path ever polled this + // instead of the result file, the token would mismatch and the wait + // would time out instead of completing. + readInstanceFile: () => rec(), + spawnImpl: handshakeSpawn(sink), + ...over, + }; +} + +function tmpResultFile(): string { + return path.join(os.tmpdir(), `bili-daemon-test-${process.pid}-${Math.random().toString(36).slice(2)}.json`); +} + +function daemonTmpFiles(): string[] { + return fs.readdirSync(os.tmpdir()).filter((f) => f.startsWith("bili-daemon-")); +} + +interface EnvSave { + ACP_PORT?: string; + ACP_HOST?: string; + BILI_PARENT_PID?: string; +} + +function saveDaemonEnv(): EnvSave { + return { ACP_PORT: process.env.ACP_PORT, ACP_HOST: process.env.ACP_HOST, BILI_PARENT_PID: process.env.BILI_PARENT_PID }; +} + +function clearDaemonEnv(): void { + delete process.env.ACP_PORT; + delete process.env.ACP_HOST; + delete process.env.BILI_PARENT_PID; +} + +function restoreDaemonEnv(saved: EnvSave): void { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } +} + +// Tee, not blind patch: node:test emits its TAP protocol through +// process.stdout.write, so captured writes must still reach the original. +function captureStdout(): { stop: () => void; text: () => string } { + let out = ""; + const orig = process.stdout.write; + const tee = ((...args: Parameters) => { + const chunk = args[0]; + // Record STRING chunks only: the runner's own IPC travels here as + // binary frames and must stay out of the capture. + if (typeof chunk === "string") out += chunk; + return Reflect.apply(orig, process.stdout, args); + }) as typeof process.stdout.write; + process.stdout.write = tee; + return { + stop() { + process.stdout.write = orig; + }, + text: () => out, + }; +} + +test("ensureProxyRunning: fresh skips attach even when a compatible healthy instance exists", async () => { + const sink: Sink = {}; + const deps = handshakeDeps(sink); + + const attached = await ensureProxyRunning({ ...BASE_OPTS }, deps); + assert.equal(attached.attached, true); + assert.equal(attached.origin, "http://127.0.0.1:8787"); + assert.equal(sink.args, undefined); + + const resultFile = tmpResultFile(); + try { + const fresh = await ensureProxyRunning({ ...BASE_OPTS, fresh: true, resultFile }, deps); + assert.equal(fresh.attached, undefined); + assert.equal(fresh.origin, `http://127.0.0.1:${BASE_OPTS.port}`); + assert.equal(fresh.child?.pid, 4242); + assert.deepEqual(sink.args?.slice(1, 6), ["start", "--host", "127.0.0.1", "--port", "9911"]); + assert.equal(sink.env?.BILI_RESULT_FILE, resultFile); + assert.ok(typeof sink.env?.BILI_LAUNCH_TOKEN === "string" && sink.env.BILI_LAUNCH_TOKEN.length > 0); + assert.equal(sink.env?.BILI_PARENT_PID, String(process.pid)); + } finally { + fs.unlinkSync(resultFile); + } +}); + +test("ensureProxyRunning: parentPid 31337 is forwarded, null omits the watcher env", async () => { + const sinkA: Sink = {}; + const resultA = tmpResultFile(); + try { + await ensureProxyRunning({ ...BASE_OPTS, fresh: true, resultFile: resultA, parentPid: 31337 }, handshakeDeps(sinkA)); + assert.equal(sinkA.env?.BILI_PARENT_PID, "31337"); + } finally { + fs.unlinkSync(resultA); + } + + const sinkB: Sink = {}; + const resultB = tmpResultFile(); + try { + await ensureProxyRunning({ ...BASE_OPTS, fresh: true, resultFile: resultB, parentPid: null }, handshakeDeps(sinkB)); + assert.equal(sinkB.env?.BILI_PARENT_PID, undefined); + } finally { + fs.unlinkSync(resultB); + } +}); + +test("ensureProxyRunning: fresh + resultFile times out when the child never hands back", async () => { + const sink: Sink = {}; + let t = 0; + const deps = handshakeDeps(sink, { + spawnImpl: (() => fakeChild()) as SpawnFn, + fetchImpl: async () => ({ ok: false }), + now: () => t, + sleep: async () => { + t += 20001; + }, + }); + await assert.rejects( + ensureProxyRunning({ ...BASE_OPTS, fresh: true, resultFile: tmpResultFile() }, deps), + /did not become healthy within 20000ms/, + ); +}); + +test("allocateDynamicPort returns a currently-bindable ephemeral port", async () => { + const p = await allocateDynamicPort("127.0.0.1"); + assert.ok(p > 1024 && p <= 65535, `unexpected port ${p}`); + await new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.once("error", reject); + srv.listen(p, "127.0.0.1", () => srv.close(() => resolve())); + }); +}); + +test("spawnDaemonProxy allocates a dynamic port and reports the child pid", async () => { + const sink: Sink = {}; + const resultFile = tmpResultFile(); + try { + const res = await spawnDaemonProxy({ host: "127.0.0.1", passthrough: false, debug: false, resultFile }, handshakeDeps(sink)); + assert.equal(res.pid, 4242); + assert.ok(res.port > 1024 && res.port <= 65535, `unexpected port ${res.port}`); + assert.equal(res.origin, `http://127.0.0.1:${res.port}`); + assert.ok(res.logPath?.endsWith(".log")); + assert.equal(sink.env?.BILI_RESULT_FILE, resultFile); + } finally { + fs.unlinkSync(resultFile); + } +}); + +test("runDaemon prints single-line JSON, exits 0, cleans up its result file", async () => { + const saved = saveDaemonEnv(); + clearDaemonEnv(); + const prevExitCode = process.exitCode; + const cap = captureStdout(); + const countBefore = daemonTmpFiles().length; + const sink: Sink = {}; + try { + await runDaemon({ overrides: {}, parentPid: 777 }, handshakeDeps(sink)); + assert.equal(process.exitCode, 0); + const lines = cap.text().split("\n").filter((l) => l.startsWith("{")); + assert.equal(lines.length, 1, `expected one JSON line, got: ${cap.text()}`); + const parsed = JSON.parse(lines[0]) as { origin: string; port: number; pid: number; logPath?: string }; + assert.equal(parsed.pid, 4242); + assert.ok(parsed.port > 0); + assert.equal(parsed.origin, `http://127.0.0.1:${parsed.port}`); + assert.ok(parsed.logPath?.endsWith(".log")); + assert.equal(sink.env?.BILI_PARENT_PID, "777"); + assert.equal(daemonTmpFiles().length, countBefore, "result file must be unlinked after success"); + } finally { + cap.stop(); + process.exitCode = prevExitCode; + restoreDaemonEnv(saved); + } +}); + +test("runDaemon exits 1 with stderr diagnostics and no stdout on failure", async () => { + const saved = saveDaemonEnv(); + clearDaemonEnv(); + const prevExitCode = process.exitCode; + const cap = captureStdout(); + const prevErr = console.error; + let errOut = ""; + console.error = ((...args: unknown[]) => { + errOut += args.map(String).join(" ") + "\n"; + }) as typeof console.error; + const countBefore = daemonTmpFiles().length; + let t = 0; + const deps: LauncherDeps = { + fetchImpl: async () => ({ ok: false }), + spawnImpl: (() => fakeChild()) as SpawnFn, + now: () => t, + sleep: async () => { + t += 20001; + }, + }; + try { + await runDaemon({ overrides: {}, parentPid: 777 }, deps); + assert.equal(process.exitCode, 1); + assert.ok(!cap.text().split("\n").some((l) => l.startsWith("{")), `no JSON result line on failure: ${cap.text()}`); + assert.match(errOut, /bili daemon:/); + assert.match(errOut, /did not become healthy/); + assert.equal(daemonTmpFiles().length, countBefore, "result file must be unlinked after failure"); + } finally { + cap.stop(); + console.error = prevErr; + process.exitCode = prevExitCode; + restoreDaemonEnv(saved); + } +}); + +test("runDaemon honors inherited BILI_PARENT_PID and warns when no host pid exists", async () => { + const saved = saveDaemonEnv(); + clearDaemonEnv(); + process.env.BILI_PARENT_PID = "555"; + const prevExitCode = process.exitCode; + const cap = captureStdout(); + const prevErr = console.error; + let errOut = ""; + console.error = ((...args: unknown[]) => { + errOut += args.map(String).join(" ") + "\n"; + }) as typeof console.error; + try { + const sinkA: Sink = {}; + await runDaemon({ overrides: {} }, handshakeDeps(sinkA)); + assert.equal(sinkA.env?.BILI_PARENT_PID, "555"); + assert.equal(errOut, ""); + + delete process.env.BILI_PARENT_PID; + const sinkB: Sink = {}; + await runDaemon({ overrides: {} }, handshakeDeps(sinkB)); + assert.equal(sinkB.env?.BILI_PARENT_PID, undefined); + assert.match(errOut, /no --parent-pid given/); + assert.equal(process.exitCode, 0); + } finally { + cap.stop(); + console.error = prevErr; + process.exitCode = prevExitCode; + restoreDaemonEnv(saved); + } +}); diff --git a/tests/e2e-daemon.test.ts b/tests/e2e-daemon.test.ts new file mode 100644 index 0000000..bc68a56 --- /dev/null +++ b/tests/e2e-daemon.test.ts @@ -0,0 +1,109 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// #518 integration: drive the REAL `bili daemon` subcommand through a real +// process tree (daemon -> detached proxy), verify the single-line JSON +// contract, then verify #414 parent-gone reaping kills the proxy. +// +// The proxy grandchild is spawned as bare `node