
























11// Crabbox Wrapper tests cover crabbox wrapper script behavior.
2-import { spawnSync } from "node:child_process";
2+import { spawn, spawnSync } from "node:child_process";
33import {
44chmodSync,
5+existsSync,
56mkdirSync,
67mkdtempSync,
8+readFileSync,
79readdirSync,
810rmSync,
911statSync,
1012writeFileSync,
1113} from "node:fs";
1214import { tmpdir } from "node:os";
1315import path from "node:path";
16+import { setTimeout as delay } from "node:timers/promises";
1417import { afterAll, beforeAll, describe, expect, it } from "vitest";
15181619const tempDirs: string[] = [];
@@ -44,6 +47,12 @@ function writeFakeCrabbox(binDir: string, helpText: string): string {
4447const helperPath = path.join(binDir, "fake-crabbox-json.cjs");
45484649if (process.platform !== "win32") {
50+const signalIgnoringDescendantScript = [
51+"process.on('SIGHUP', () => {});",
52+"process.on('SIGINT', () => {});",
53+"process.on('SIGTERM', () => {});",
54+"setInterval(() => {}, 1000);",
55+].join("");
4756const script = [
4857"#!/bin/sh",
4958'if [ "$1" = "--version" ]; then',
@@ -116,6 +125,12 @@ function writeFakeCrabbox(binDir: string, helpText: string): string {
116125" fi",
117126' cd "$deleted_cwd" || exit 1',
118127"fi",
128+'if [ -n "${OPENCLAW_FAKE_CRABBOX_DESCENDANT_PID_PATH:-}" ]; then',
129+` ${shellSingleQuote(process.execPath)} --input-type=module --eval ${shellSingleQuote(signalIgnoringDescendantScript)} &`,
130+' printf "%s" "$!" > "$OPENCLAW_FAKE_CRABBOX_DESCENDANT_PID_PATH"',
131+' trap "exit 0" INT TERM HUP',
132+" while :; do sleep 1; done",
133+"fi",
119134'printf "%s\\0" "__OPENCLAW_FAKE_CRABBOX_V1__"',
120135'printf "%s\\0" "$PWD"',
121136'printf "%s\\0" "$#"',
@@ -280,47 +295,57 @@ function shellArgListCondition(args: string[]): string {
280295return checks.join(" && ");
281296}
282297283-function runWrapper(
284-helpText: string,
285-args: string[],
286-options: {
287-configJson?: Record<string, unknown>;
288-configStatus?: number;
289-env?: Record<string, string>;
290-extraPathEntries?: string[];
291-gitResponses?: Record<string, { status?: number; stdout?: string; stderr?: string }>;
292-input?: string;
293-} = {},
294-) {
295-const binDir = makeFakeCrabbox(helpText);
296-const gitResponses = { ...defaultGitResponses, ...options.gitResponses };
297-const gitBinDir = makeFakeGit(gitResponses);
298+function runWrapper(helpText: string, args: string[], options: WrapperOptions = {}) {
298299return spawnSync(process.execPath, ["scripts/crabbox-wrapper.mjs", ...args], {
299300cwd: repoRoot,
300301encoding: "utf8",
301302input: options.input,
302-env: {
303- ...process.env,
304-PATH: [...(options.extraPathEntries ?? []), binDir, gitBinDir, process.env.PATH ?? ""]
305-.filter(Boolean)
306-.join(path.delimiter),
307-CRABBOX_PROVIDER: "",
308-OPENCLAW_CRABBOX_ALLOW_DIRECT_AWS: "",
309-OPENCLAW_CRABBOX_SYNC_MIN_FREE_BYTES: "0",
310-OPENCLAW_CRABBOX_WRAPPER_IGNORE_REPO_BINARY: "1",
311- ...(options.configJson
312- ? { OPENCLAW_FAKE_CRABBOX_CONFIG_JSON: JSON.stringify(options.configJson) }
313- : {}),
314- ...(options.configStatus
315- ? { OPENCLAW_FAKE_CRABBOX_CONFIG_STATUS: String(options.configStatus) }
316- : {}),
317- ...options.env,
318-OPENCLAW_FAKE_GIT_RESPONSES: JSON.stringify(gitResponses),
319-},
303+env: wrapperEnv(helpText, options),
320304timeout: 10_000,
321305});
322306}
323307308+type WrapperOptions = {
309+configJson?: Record<string, unknown>;
310+configStatus?: number;
311+env?: Record<string, string>;
312+extraPathEntries?: string[];
313+gitResponses?: Record<string, { status?: number; stdout?: string; stderr?: string }>;
314+input?: string;
315+};
316+317+function spawnWrapper(helpText: string, args: string[], options: WrapperOptions = {}) {
318+return spawn(process.execPath, ["scripts/crabbox-wrapper.mjs", ...args], {
319+cwd: repoRoot,
320+env: wrapperEnv(helpText, options),
321+stdio: ["ignore", "pipe", "pipe"],
322+});
323+}
324+325+function wrapperEnv(helpText: string, options: WrapperOptions): NodeJS.ProcessEnv {
326+const binDir = makeFakeCrabbox(helpText);
327+const gitResponses = { ...defaultGitResponses, ...options.gitResponses };
328+const gitBinDir = makeFakeGit(gitResponses);
329+return {
330+ ...process.env,
331+PATH: [...(options.extraPathEntries ?? []), binDir, gitBinDir, process.env.PATH ?? ""]
332+.filter(Boolean)
333+.join(path.delimiter),
334+CRABBOX_PROVIDER: "",
335+OPENCLAW_CRABBOX_ALLOW_DIRECT_AWS: "",
336+OPENCLAW_CRABBOX_SYNC_MIN_FREE_BYTES: "0",
337+OPENCLAW_CRABBOX_WRAPPER_IGNORE_REPO_BINARY: "1",
338+ ...(options.configJson
339+ ? { OPENCLAW_FAKE_CRABBOX_CONFIG_JSON: JSON.stringify(options.configJson) }
340+ : {}),
341+ ...(options.configStatus
342+ ? { OPENCLAW_FAKE_CRABBOX_CONFIG_STATUS: String(options.configStatus) }
343+ : {}),
344+ ...options.env,
345+OPENCLAW_FAKE_GIT_RESPONSES: JSON.stringify(gitResponses),
346+};
347+}
348+324349function parseFakeCrabboxOutput(result: ReturnType<typeof runWrapper>): {
325350args: string[];
326351cwd: string;
@@ -355,6 +380,76 @@ function normalizeShellLineEndings(value: string): string {
355380return value.replace(/\r\n/g, "\n");
356381}
357382383+async function waitForCondition(predicate: () => boolean, timeoutMs = 8_000): Promise<void> {
384+const started = Date.now();
385+while (Date.now() - started < timeoutMs) {
386+if (predicate()) {
387+return;
388+}
389+await delay(50);
390+}
391+throw new Error("timed out waiting for condition");
392+}
393+394+async function waitForProcessExit(
395+child: ReturnType<typeof spawnWrapper>,
396+timeoutMs = 12_000,
397+): Promise<{ status: number | null; signal: NodeJS.Signals | null }> {
398+return await Promise.race([
399+new Promise<{ status: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
400+child.once("error", reject);
401+child.once("exit", (status, signal) => resolve({ status, signal }));
402+}),
403+delay(timeoutMs).then(() => {
404+throw new Error("timed out waiting for wrapper process exit");
405+}),
406+]);
407+}
408+409+function isProcessAlive(pid: number): boolean {
410+try {
411+process.kill(pid, 0);
412+return true;
413+} catch {
414+return false;
415+}
416+}
417+418+async function runSignalCleanupProof(sendSignals: (pid: number) => Promise<void>): Promise<void> {
419+const root = mkdtempSync(path.join(tmpdir(), "openclaw-crabbox-descendant-"));
420+tempDirs.push(root);
421+const descendantPidPath = path.join(root, "descendant.pid");
422+let descendantPid = 0;
423+const runner = spawnWrapper(
424+"provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n",
425+["run", "--provider", "aws", "--", "echo ok"],
426+{
427+env: {
428+OPENCLAW_FAKE_CRABBOX_DESCENDANT_PID_PATH: descendantPidPath,
429+},
430+},
431+);
432+433+try {
434+await waitForCondition(() => existsSync(descendantPidPath));
435+descendantPid = Number.parseInt(readFileSync(descendantPidPath, "utf8"), 10);
436+expect(Number.isInteger(descendantPid)).toBe(true);
437+expect(isProcessAlive(descendantPid)).toBe(true);
438+439+const runnerExit = waitForProcessExit(runner);
440+await sendSignals(runner.pid!);
441+await expect(runnerExit).resolves.toEqual({ status: 143, signal: null });
442+await waitForCondition(() => !isProcessAlive(descendantPid));
443+} finally {
444+if (runner.pid && isProcessAlive(runner.pid)) {
445+runner.kill("SIGKILL");
446+}
447+if (descendantPid && isProcessAlive(descendantPid)) {
448+process.kill(descendantPid, "SIGKILL");
449+}
450+}
451+}
452+358453function testCrabboxConfigDir(home: string): string {
359454if (process.platform === "darwin") {
360455return path.join(home, "Library", "Application Support", "crabbox");
@@ -3041,6 +3136,26 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
30413136}
30423137});
304331383139+(process.platform === "win32" ? it.skip : it)(
3140+"terminates Crabbox descendants before parent signal exit",
3141+async () => {
3142+await runSignalCleanupProof(async (runnerPid) => {
3143+process.kill(runnerPid, "SIGTERM");
3144+});
3145+},
3146+);
3147+3148+(process.platform === "win32" ? it.skip : it)(
3149+"keeps cleanup active after repeated parent signals",
3150+async () => {
3151+await runSignalCleanupProof(async (runnerPid) => {
3152+process.kill(runnerPid, "SIGTERM");
3153+await delay(50);
3154+process.kill(runnerPid, "SIGTERM");
3155+});
3156+},
3157+);
3158+30443159(process.platform === "win32" ? it.skip : it)(
30453160"terminates when sparse-sync temporary full checkouts disappear while Crabbox is running",
30463161() => {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。