fix(windows): harden installation and parent cancellation

This commit is contained in:
Zhang GH
2026-09-08 19:21:54 +08:00
parent e8137b42cc
commit caea98cd52
7 changed files with 534 additions and 35 deletions
+50 -10
View File
@@ -5,7 +5,7 @@
* map the CLI's JSON error envelope onto a thrown `BskError`.
*/
import { type ChildProcess, spawn } from "node:child_process";
import { type ChildProcess, type SpawnOptionsWithoutStdio, spawn } from "node:child_process";
/** Shape of the JSON error envelope `bsk --json` prints on failure. */
export interface BskErrorBody {
@@ -53,7 +53,11 @@ export interface BskRunOptions {
}
/** Minimal spawn signature so tests can substitute a fake child process. */
export type SpawnImpl = (command: string, args: string[]) => ChildProcess;
export type SpawnImpl = (
command: string,
args: string[],
options?: SpawnOptionsWithoutStdio,
) => ChildProcess;
export interface BskRunner {
/** Run `bsk <args...> --json` and collect its output. */
@@ -64,29 +68,65 @@ export interface BskRunner {
killFor(tag: string): number;
}
// Business RPCs translate SIGINT into the daemon's cancel(rpc_id) protocol.
// Give that bounded reconciliation path time to settle before the hard kill.
// Business RPCs translate Ctrl-C / opt-in stdin EOF into cancel(rpc_id).
// Allow reconciliation before hard-killing an old or unresponsive CLI.
const KILL_GRACE_MS = 3000;
// Windows IPC may spend 5s connecting, then 2s cancelling and 2s settling.
const WINDOWS_KILL_GRACE_MS = 10_000;
const SESSION_BUSY_RETRY_DELAY_MS = 100;
export function createBskRunner(bskPath: string, spawnImpl: SpawnImpl = spawn): BskRunner {
const live = new Map<ChildProcess, string | undefined>();
const windows = process.platform === "win32";
const cancelling = new Set<ChildProcess>();
function killChild(child: ChildProcess): void {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGINT");
const force = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}, KILL_GRACE_MS);
if (child.exitCode !== null || child.signalCode !== null || cancelling.has(child)) return;
cancelling.add(child);
// Node kills Windows children outright for SIGINT. EOF asks the CLI to
// send its existing cancel RPC and wait for browser reconciliation.
if (windows && child.stdin) child.stdin.end();
else child.kill("SIGINT");
const force = setTimeout(
() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
},
windows ? WINDOWS_KILL_GRACE_MS : KILL_GRACE_MS,
);
force.unref();
child.once("close", () => {
clearTimeout(force);
cancelling.delete(child);
});
}
return {
run(args, options = {}) {
if (options.signal?.aborted) {
return Promise.resolve({
code: null,
stdout: "",
stderr: "",
timedOut: false,
aborted: true,
});
}
return new Promise<BskRunResult>((resolve, reject) => {
let child: ChildProcess;
try {
child = spawnImpl(bskPath, [...args, "--json"]);
child = spawnImpl(
bskPath,
[...args, "--json"],
windows
? {
windowsHide: true,
env: { ...process.env, BSK_CANCEL_ON_STDIN_CLOSE: "1" },
}
: undefined,
);
// A child exiting while cancellation closes stdin may report EPIPE.
// Its close/error event remains the authority for the run result.
child.stdin?.on("error", () => {});
} catch (error) {
reject(error);
return;
@@ -1,6 +1,7 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { describe, expect, it } from "vitest";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
type BskRunResult,
createBskRunner,
@@ -92,6 +93,75 @@ describe("createBskRunner", () => {
});
});
describe("Windows parent cancellation", () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")!;
beforeEach(() => Object.defineProperty(process, "platform", { value: "win32" }));
afterEach(() => {
Object.defineProperty(process, "platform", originalPlatform);
vi.useRealTimers();
});
it("opts in to stdin cancellation and waits for CLI reconciliation", async () => {
const child = Object.assign(new FakeChild(), { stdin: new PassThrough() });
const spawn = vi.fn(() => child as unknown as ChildProcess);
const runner = createBskRunner("bsk", spawn);
const abort = new AbortController();
const result = runner.run(["snapshot"], { signal: abort.signal });
expect(spawn.mock.calls[0]).toEqual([
"bsk",
["snapshot", "--json"],
{
windowsHide: true,
env: { ...process.env, BSK_CANCEL_ON_STDIN_CLOSE: "1" },
},
]);
abort.abort();
expect(child.stdin.writableEnded).toBe(true);
expect(child.killedWith).toEqual([]);
// Closing an already-exited child's pipe must not crash the host.
child.stdin.emit("error", Object.assign(new Error("closed"), { code: "EPIPE" }));
child.finish(2, '{"code":"cancelled"}');
expect(await result).toMatchObject({ code: 2, aborted: true });
});
it("bounds cancellation of an old or unresponsive CLI", async () => {
vi.useFakeTimers();
const child = Object.assign(new FakeChild(), { stdin: new PassThrough() });
const runner = createBskRunner("bsk", fakeSpawn([child]));
const result = runner.run(["snapshot"], { timeoutMs: 5 });
await vi.advanceTimersByTimeAsync(5);
expect(child.stdin.writableEnded).toBe(true);
expect(child.killedWith).toEqual([]);
await vi.advanceTimersByTimeAsync(10_000);
expect(child.killedWith).toEqual(["SIGKILL"]);
expect(await result).toMatchObject({ timedOut: true });
});
it("cancels only the requested tag and clears the fallback after exit", async () => {
vi.useFakeTimers();
const a = Object.assign(new FakeChild(), { stdin: new PassThrough() });
const b = Object.assign(new FakeChild(), { stdin: new PassThrough() });
const runner = createBskRunner("bsk", fakeSpawn([a, b]));
const first = runner.run(["snapshot"], { tag: "a" });
const second = runner.run(["snapshot"], { tag: "b" });
expect(runner.killFor("a")).toBe(1);
expect(a.stdin.writableEnded).toBe(true);
expect(b.stdin.writableEnded).toBe(false);
a.finish(2);
b.finish(0);
await Promise.all([first, second]);
expect(vi.getTimerCount()).toBe(0);
});
it("does not start work for an already-aborted call", async () => {
const spawn = vi.fn();
const runner = createBskRunner("bsk", spawn);
const signal = AbortSignal.abort();
expect(await runner.run(["snapshot"], { signal })).toMatchObject({ aborted: true });
expect(spawn).not.toHaveBeenCalled();
});
});
describe("parseBskJson", () => {
const base: BskRunResult = { code: 0, stdout: "", stderr: "", timedOut: false, aborted: false };