Refs #4522, #4526, #4555. Retain producer-owned compiler output and cleanup markers through xcrun resolution and native failure projection. Keep physical preparation failures primary and preserve separate command deadline classification for interrupted preparation. Omitted output facts stay unknown; client close does not resolve foreign helper cleanup. Include CLI failure output in native regression reports. Validation: 87 focused tests passed. Broader 140-file run passed 1483 tests, skipped 109, and exposed three pre-existing deep-provider selection failures tracked in #4555. Static checks passed. Compiler faults use controlled Node fixtures; native Swift compiler faults and Windows execution were not verified. Other command consumers still need preparation-fact propagation.
236 lines
7.6 KiB
TypeScript
236 lines
7.6 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import { EventEmitter } from "node:events";
|
|
import { PassThrough } from "node:stream";
|
|
import { afterEach, expect, it, vi } from "vitest";
|
|
import {
|
|
XcrunCommandRunner,
|
|
type NativeCommandLauncher,
|
|
} from "../../../../src/native/CommandRunner.js";
|
|
import { ok } from "../../../../src/domain/result.js";
|
|
import { DarwinProcessOwnershipInspectionError } from "../../../../src/process/DarwinProcessRunTokenReader.js";
|
|
|
|
afterEach(() => vi.unstubAllEnvs());
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"retains an xcrun resolver's failed owner and retries it before resolving again",
|
|
async () => {
|
|
let launches = 0;
|
|
const launcher: NativeCommandLauncher = async (options) => {
|
|
launches += 1;
|
|
const child = Object.assign(new EventEmitter(), {
|
|
stdout: new PassThrough(),
|
|
stderr: new PassThrough(),
|
|
exitCode: null as number | null,
|
|
signalCode: null as NodeJS.Signals | null,
|
|
kill: () => true,
|
|
});
|
|
setImmediate(() => {
|
|
child.stdout.end("partial xcrun output");
|
|
child.stderr.end("resolver failed");
|
|
child.exitCode = 1;
|
|
child.emit("exit", 1, null);
|
|
setImmediate(() => child.emit("close", 1, null));
|
|
});
|
|
return {
|
|
process: child,
|
|
ownership: {
|
|
runId: options.runId,
|
|
leaderPid: 43_000 + launches,
|
|
processGroupId: 43_000 + launches,
|
|
expectedParentPid: process.pid,
|
|
},
|
|
cleanup: async () => ({
|
|
cleaned: false,
|
|
reason: "resolver owner remains unverified",
|
|
}),
|
|
};
|
|
};
|
|
const runner = new XcrunCommandRunner({}, undefined, launcher);
|
|
|
|
const resolution = await runner.run("file", [], {});
|
|
|
|
expect(resolution.ok).toBe(false);
|
|
if (resolution.ok) return;
|
|
expect(resolution.error.cleanupOwner).toBeDefined();
|
|
expect(resolution.error.capture).toMatchObject({
|
|
stdout: "partial xcrun output",
|
|
stderr: "resolver failed",
|
|
});
|
|
expect(launches).toBe(1);
|
|
|
|
const retry = await runner.run("file", [], {});
|
|
expect(retry.ok).toBe(false);
|
|
if (!retry.ok) expect(retry.error.cleanupFailures).toHaveLength(1);
|
|
expect(launches).toBe(1);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"executes native commands with the selected environment snapshot instead of ambient values",
|
|
async () => {
|
|
vi.stubEnv("REA_NATIVE_SELECTED", "ambient");
|
|
vi.stubEnv("REA_NATIVE_AMBIENT", "ambient-only");
|
|
const environment = {
|
|
REA_NATIVE_SELECTED: "selected",
|
|
PATH: "/selected-path",
|
|
};
|
|
const runner = new XcrunCommandRunner(environment, async () =>
|
|
ok({ path: process.execPath, sha256: "a".repeat(64) }),
|
|
);
|
|
environment.REA_NATIVE_SELECTED = "mutated-after-construction";
|
|
const captured = await runner.run(
|
|
"file",
|
|
[
|
|
"-e",
|
|
"process.stdout.write(JSON.stringify({selected:process.env.REA_NATIVE_SELECTED,ambient:process.env.REA_NATIVE_AMBIENT??null,path:process.env.PATH,locale:process.env.LC_ALL}))",
|
|
],
|
|
{},
|
|
);
|
|
if (!captured.ok) throw captured.error;
|
|
expect(JSON.parse(captured.value.stdout)).toEqual({
|
|
selected: "selected",
|
|
ambient: null,
|
|
path: "/selected-path",
|
|
locale: "C",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"retains and retries every failed native command cleanup owner before launching another command",
|
|
async () => {
|
|
const cleanupCalls = new Map<string, number>();
|
|
let launches = 0;
|
|
const launcher: NativeCommandLauncher = async (options) => {
|
|
const name = options.arguments[0] ?? "unnamed";
|
|
launches += 1;
|
|
const child = Object.assign(new EventEmitter(), {
|
|
stdout: new PassThrough(),
|
|
stderr: new PassThrough(),
|
|
exitCode: null as number | null,
|
|
signalCode: null as NodeJS.Signals | null,
|
|
kill: () => true,
|
|
});
|
|
setImmediate(() => {
|
|
child.stdout.end(`partial-${name}`);
|
|
child.stderr.end(`diagnostic-${name}`);
|
|
child.exitCode = 0;
|
|
child.emit("exit", 0, null);
|
|
setImmediate(() => child.emit("close", 0, null));
|
|
});
|
|
return {
|
|
process: child,
|
|
ownership: {
|
|
runId: options.runId,
|
|
leaderPid: 42_000 + launches,
|
|
processGroupId: 42_000 + launches,
|
|
expectedParentPid: process.pid,
|
|
},
|
|
cleanup: async () => {
|
|
const calls = (cleanupCalls.get(name) ?? 0) + 1;
|
|
cleanupCalls.set(name, calls);
|
|
return calls < 3
|
|
? {
|
|
cleaned: false,
|
|
reason: `${name} cleanup attempt ${String(calls)}`,
|
|
}
|
|
: { cleaned: true, signaled: false };
|
|
},
|
|
};
|
|
};
|
|
const runner = new XcrunCommandRunner(
|
|
{},
|
|
async () => ok({ path: process.execPath, sha256: "a".repeat(64) }),
|
|
launcher,
|
|
);
|
|
|
|
const [first, second] = await Promise.all([
|
|
runner.run("file", ["first"], {}),
|
|
runner.run("file", ["second"], {}),
|
|
]);
|
|
expect(first.ok).toBe(false);
|
|
expect(second.ok).toBe(false);
|
|
if (first.ok || second.ok) return;
|
|
expect(first.error.capture).toMatchObject({
|
|
stdout: "partial-first",
|
|
stderr: "diagnostic-first",
|
|
});
|
|
expect(first.error.cleanupOwner).toBeDefined();
|
|
expect(second.error.cleanupOwner).toBeDefined();
|
|
expect(cleanupCalls).toEqual(
|
|
new Map([
|
|
["first", 1],
|
|
["second", 1],
|
|
]),
|
|
);
|
|
|
|
const blocked = await runner.run("file", ["must-not-launch"], {});
|
|
expect(blocked.ok).toBe(false);
|
|
if (blocked.ok) return;
|
|
expect(blocked.error.cleanupFailures).toHaveLength(2);
|
|
expect(blocked.error.cleanupResources).toEqual(
|
|
expect.arrayContaining([
|
|
first.error.cleanupOwner?.launch.ownership?.runId,
|
|
second.error.cleanupOwner?.launch.ownership?.runId,
|
|
]),
|
|
);
|
|
expect(launches).toBe(2);
|
|
|
|
const cleared = await runner.retryCleanup?.();
|
|
expect(cleared).toEqual([]);
|
|
expect(cleanupCalls).toEqual(
|
|
new Map([
|
|
["first", 3],
|
|
["second", 3],
|
|
]),
|
|
);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"does not accept signal termination as an allowed nonzero exit",
|
|
async () => {
|
|
const sha256 = createHash("sha256")
|
|
.update(await readFile(process.execPath))
|
|
.digest("hex");
|
|
const runner = new XcrunCommandRunner({}, async () =>
|
|
ok({ path: process.execPath, sha256 }),
|
|
);
|
|
const exited = await runner.run("file", ["-e", "process.exit(1)"], {
|
|
acceptNonZero: true,
|
|
});
|
|
expect(exited.ok).toBe(true);
|
|
if (exited.ok) expect(exited.value.exitCode).toBe(1);
|
|
const killed = await runner.run(
|
|
"file",
|
|
["-e", "process.kill(process.pid, 'SIGTERM')"],
|
|
{ acceptNonZero: true },
|
|
);
|
|
expect(killed.ok).toBe(false);
|
|
if (!killed.ok) expect(killed.error.reason).toBe("nonzero-exit");
|
|
},
|
|
);
|
|
|
|
it("keeps the ownership helper prerequisite when xcrun cannot be supervised", async () => {
|
|
const inspection = new DarwinProcessOwnershipInspectionError(
|
|
"macOS process ownership inspection requires the Apple Swift compiler via xcrun",
|
|
);
|
|
const runner = new XcrunCommandRunner({}, undefined, () =>
|
|
Promise.reject(inspection),
|
|
);
|
|
|
|
const result = await runner.run("file", [], {});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.error.preparationFailure).toBe(inspection);
|
|
expect(result.error).toMatchObject({
|
|
tool: "file",
|
|
reason: "io",
|
|
preparationFailure: inspection,
|
|
detail: inspection.message,
|
|
cause: { tool: "xcrun", detail: inspection.message, cause: inspection },
|
|
});
|
|
});
|