diff --git a/.github/workflows/real-go-binary.yml b/.github/workflows/real-go-binary.yml index 8ffe3fe58d0..cf2a704332c 100644 --- a/.github/workflows/real-go-binary.yml +++ b/.github/workflows/real-go-binary.yml @@ -15,6 +15,8 @@ on: - "src/server/registerGoTools.ts" - "scripts/verify-go-binary.mjs" - "scripts/lib/mcp-verifier-results.mjs" + - "scripts/lib/verifier-cleanup.mjs" + - "scripts/lib/verifier-stdio-transport.mjs" permissions: contents: read diff --git a/scripts/lib/verifier-stdio-transport.mjs b/scripts/lib/verifier-stdio-transport.mjs new file mode 100644 index 00000000000..9574eb3bf16 --- /dev/null +++ b/scripts/lib/verifier-stdio-transport.mjs @@ -0,0 +1,79 @@ +import { ChildProcess } from "node:child_process"; + +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +import { ProviderProcessSupervisor } from "../../dist/process/ProviderProcess.js"; +import { closeVerifierResources } from "./verifier-cleanup.mjs"; + +const noChildHandle = () => + new Error( + "The MCP SDK stdio transport exposes no child handle, so its completion cannot be proven", + ); + +/** + * SDK stdio transport that can prove its child finished. + * + * The SDK's `close()` can return right after sending its final SIGKILL, before + * the child is reaped, and its `pid` is cleared by then. `close()` itself keeps + * that contract and must not reject: the SDK calls it without awaiting after a + * failed handshake. Callers that release resources the child can use await + * `closeAndVerifyChild()` instead. + */ +export class VerifierStdioClientTransport extends StdioClientTransport { + #supervisor; + #handleMissing = false; + #outputClosed = false; + + async start() { + const started = super.start(); + // The SDK assigns its child synchronously and exposes only that PID. + const child = Reflect.get(this, "_process"); + // A failed spawn rejects here, having acquired nothing to prove finished. + await started; + if (!(child instanceof ChildProcess)) { + this.#handleMissing = true; + throw noChildHandle(); + } + // An unread stderr would hold the child's pipe open past its exit. + this.stderr?.resume(); + child.once("close", () => { + this.#outputClosed = true; + }); + this.#supervisor = new ProviderProcessSupervisor( + { process: child, ownsProcessLifetime: true }, + { + captureStdout: false, + maxDiagnosticBytes: { stdout: 0, stderr: 0 }, + }, + ); + } + + /** + * Close through the SDK, then stop any surviving child and prove it finished. + * A rejection keeps the child owned; calling this again retries from its state. + */ + closeAndVerifyChild() { + return closeVerifierResources([ + () => this.close(), + () => this.#verifyChildFinished(), + ]); + } + + async #verifyChildFinished() { + if (this.#handleMissing) throw noChildHandle(); + const supervisor = this.#supervisor; + if (supervisor === undefined) return; + const stopped = await supervisor.stop(); + const child = `MCP stdio child ${String(supervisor.launch.process.pid)}`; + if (stopped.status === "incomplete") + throw new Error( + `${child} completion could not be proven: ${stopped.reason}`, + ); + // Exit proves only this process. Its pipes stay open while anything that + // inherited them runs, including a reparented process no lineage walk finds. + if (!this.#outputClosed) + throw new Error( + `${child} exited, but its stdio pipes are still open, so a process that inherited them may still be running`, + ); + } +} diff --git a/scripts/verify-go-binary.mjs b/scripts/verify-go-binary.mjs index 799b21cecf7..2bcb538428f 100644 --- a/scripts/verify-go-binary.mjs +++ b/scripts/verify-go-binary.mjs @@ -8,12 +8,36 @@ import { isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; -import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { parseEvidence } from "../dist/domain/evidence.js"; import { goBinarySchema } from "../dist/domain/go/goBinary.js"; import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js"; import { requireMcpOutput } from "./lib/mcp-verifier-results.mjs"; -import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { + closeVerifierResources, + withVerifierCleanup, +} from "./lib/verifier-cleanup.mjs"; +import { + assertVerifierCleanupComplete, + createVerifierRun, + completeVerifierRun, +} from "./lib/verifier-run.mjs"; +import { VerifierStdioClientTransport } from "./lib/verifier-stdio-transport.mjs"; + +/** The retained owner remains the exact authority for retrying this release. */ +class GoVerifierCleanupFailure extends AggregateError { + _tag = "GoVerifierCleanupFailure"; + reason = "cleanup_incomplete"; + + constructor(failures, cleanupOwner, retainedRoot) { + super( + failures, + `Real Go metadata verification failed; retained fixture root ${retainedRoot} because its cleanup is incomplete.`, + ); + this.name = "GoVerifierCleanupFailure"; + this.cleanupOwner = cleanupOwner; + this.retainedRoot = retainedRoot; + } +} const execute = promisify(execFile); const compiler = process.env.REA_VERIFY_GO_COMMAND ?? "go"; @@ -50,13 +74,8 @@ const goEnvironment = { // Go's own verifier seam isolates telemetry; no global configuration is changed. TEST_TELEMETRY_DIR: join(root.path, "telemetry"), }; -const client = new Client({ name: "go-binary-verifier", version: "1" }); -const transport = new StdioClientTransport({ - command: process.execPath, - args: [entrypoint, "mcp"], - env: environment, - stderr: "pipe", -}); +let client; +let transport; const targets = [ ["linux", "amd64", "elf", 64, "little"], ["linux", "386", "elf", 32, "little"], @@ -70,8 +89,40 @@ const targets = [ ]; const verified = []; const omittedCoverage = []; +let verifier; +let rootRemoved = false; +// Each owner a verification acquired must finish before its root can go. Any +// failure leaves the root and owners in place, and closing again retries them. +const fixture = { + async close() { + if (rootRemoved) return; + await closeVerifierResources([ + () => client?.close(), + () => transport?.closeAndVerifyChild(), + ]); + verifier = await completeVerifierRun(run); + // On POSIX an unavailable lineage is an unknown, so it keeps the root. Windows + // has no lineage facility here, and requiring one would retain every root, so + // the report states the weaker proof there instead of implying more. + if (process.platform === "win32") + omittedCoverage.push( + "Verifier process lineage is unavailable on Windows; the fixture root's release relies on the MCP child's completion alone, and other descendants were not observed.", + ); + else assertVerifierCleanupComplete(verifier.process_lineage); + await root.close(); + await assert.rejects(lstat(root.path), { code: "ENOENT" }); + rootRemoved = true; + }, +}; const failures = []; -try { +await withVerifierCleanup(async () => { + client = new Client({ name: "go-binary-verifier", version: "1" }); + transport = new VerifierStdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: environment, + stderr: "pipe", + }); await prepareFixture(); try { compilerVersion = ( @@ -194,29 +245,11 @@ try { ); await verify(path, targets[0], oracle, expected); } -} catch (cause) { - failures.push(cause); -} finally { - for (const close of [ - () => client.close(), - () => transport.close(), - () => root.close(), - ]) { - try { - await close(); - } catch (cause) { - failures.push(cause); - } - } -} -const verifier = await completeVerifierRun(run); -try { - await assert.rejects(lstat(root.path), { code: "ENOENT" }); - if (verifier.process_lineage.status === "verified") - assert.deepEqual(verifier.process_lineage.descendants, []); -} catch (cause) { - failures.push(cause); -} +}, [() => fixture.close()]).catch((cause) => { + failures.push(...flattenFailures(cause)); +}); +// A release that stopped early never observed lineage; report it regardless. +verifier ??= await completeVerifierRun(run); if (failures.length > 0) { console.error( JSON.stringify( @@ -225,16 +258,19 @@ if (failures.length > 0) { compiler: compilerVersion, verified, omitted_coverage: omittedCoverage, + retained_root: rootRemoved ? null : root.path, verifier, }, null, 2, ), ); - throw new AggregateError( - failures, - "Real Go metadata verification failed; cleanup failures are retained.", - ); + if (rootRemoved) + throw new AggregateError( + failures, + "Real Go metadata verification failed; cleanup failures are retained.", + ); + throw new GoVerifierCleanupFailure(failures, fixture, root.path); } console.log( JSON.stringify( @@ -254,6 +290,12 @@ console.log( ), ); +function flattenFailures(cause) { + return cause instanceof AggregateError + ? cause.errors.flatMap(flattenFailures) + : [cause]; +} + async function go(args, overrides = {}) { return execute(compiler, args, { cwd: sourceRoot, diff --git a/tests/boundary/process/goVerifierCleanup.test.ts b/tests/boundary/process/goVerifierCleanup.test.ts new file mode 100644 index 00000000000..e8b36f13502 --- /dev/null +++ b/tests/boundary/process/goVerifierCleanup.test.ts @@ -0,0 +1,280 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + processTest, + type TestProcesses, +} from "../../support/process/processFixture.js"; + +const harness = fileURLToPath( + new URL("../../fixtures/goVerifierCleanupHarness.mjs", import.meta.url), +); + +const outcomeSchema = z.object({ + failure: z.object({ + name: z.string().optional(), + message: z.string().optional(), + retained_root: z.string().nullable(), + messages: z.array(z.string()), + }), + report: z.object({ + status: z.string().optional(), + retained_root: z.string().nullable(), + descendants: z.number().nullable(), + lineage_status: z.string().optional(), + lineage_reason: z.string().nullable(), + omitted_coverage: z.array(z.string()), + }), + retained_at_failure: z.array(z.string()), + retained_after_retry: z.array(z.string()).optional(), + root_removed_after_retry: z.boolean().optional(), + events: z.array(z.string()), +}); + +/** Run the actual verifier with a fake compiler and one authored fault. */ +const runScenario = async (processes: TestProcesses, scenario: string) => { + const root = await createTestTempDirectory("rea-go-verifier-cleanup-"); + const temporary = join(root, "temporary"); + await mkdir(temporary); + const result = await processes.run(process.execPath, [harness, scenario], { + env: { + ...process.env, + TMPDIR: temporary, + TMP: temporary, + TEMP: temporary, + }, + timeoutMs: 40_000, + }); + expect(result.exitCode, result.stderr).toBe(0); + return outcomeSchema.parse(JSON.parse(result.stdout)); +}; + +const PRIMARY = /ENOENT.*linux-amd64/u; +const posixTest = processTest.skipIf(process.platform === "win32"); + +posixTest( + "releases a real MCP server before its root and keeps the verification failure", + async ({ processes }) => { + const outcome = await runScenario(processes, "real-server"); + expect(outcome.failure.messages).toEqual([expect.stringMatching(PRIMARY)]); + expect(outcome.report).toMatchObject({ + status: "failed", + retained_root: null, + descendants: 0, + }); + expect(outcome.retained_at_failure).toEqual([]); + expect(outcome.events.indexOf("child-close")).toBeGreaterThanOrEqual(0); + expect(outcome.events.indexOf("root-close")).toBeGreaterThan( + outcome.events.indexOf("child-close"), + ); + }, +); + +posixTest.for([ + ["client-constructor", /^authored client constructor failure$/u], + ["transport-constructor", /^authored transport constructor failure$/u], + ["missing-compiler", /requires Go on PATH/u], + ["spawn-failure", /absent-node ENOENT/u], +] as const)( + "removes the root and keeps the %s failure before any child exists", + async ([scenario, message], { processes }) => { + const outcome = await runScenario(processes, scenario); + expect(outcome.failure.messages).toEqual([expect.stringMatching(message)]); + expect(outcome.report.retained_root).toBeNull(); + expect(outcome.retained_at_failure).toEqual([]); + // A spawn that failed still reports close; no process ever existed. + expect(outcome.events).not.toContain("child-exit"); + expect(outcome.events.at(-1)).toBe("root-close"); + }, +); + +posixTest( + "retains the root when the SDK no longer exposes its child", + async ({ processes }) => { + const outcome = await runScenario(processes, "unobservable-child"); + expect(outcome.failure.messages).toEqual([ + expect.stringMatching(/child handle/u), + expect.stringMatching(/child handle/u), + ]); + expect(outcome.retained_at_failure).toHaveLength(1); + expect(outcome.events).toEqual(["retry-refused"]); + }, +); + +posixTest.for([ + ["refused-handshake", /authored handshake refusal/u], + // Unread output would keep the child's pipe open past its exit. + ["noisy-stderr", PRIMARY], +] as const)( + "finishes a %s child before removing the root", + async ([scenario, message], { processes }) => { + const outcome = await runScenario(processes, scenario); + expect(outcome.failure.messages).toEqual([expect.stringMatching(message)]); + expect(outcome.retained_at_failure).toEqual([]); + expect(outcome.events.indexOf("root-close")).toBeGreaterThan( + outcome.events.indexOf("child-close"), + ); + }, +); + +posixTest.for([ + ["client-close", ["authored client close failure"]], + ["transport-close", ["authored transport close failure"]], + [ + "both-closes", + ["authored client close failure", "authored transport close failure"], + ], + ["root-close", ["authored root close failure"]], +] as const)( + "retains and reports the root after a %s failure, then retries", + async ([scenario, cleanupMessages], { processes }) => { + const outcome = await runScenario(processes, scenario); + // The primary failure stays first, and every cleanup failure follows it. + expect(outcome.failure.messages).toEqual([ + expect.stringMatching(PRIMARY), + ...cleanupMessages, + ]); + expect(outcome.retained_at_failure).toHaveLength(1); + const retained = outcome.retained_at_failure[0]!; + expect(outcome.failure.retained_root).toBe(retained); + expect(outcome.report.retained_root).toBe(retained); + expect(outcome.failure.message).toContain(retained); + // An owner that refused to close can still own a running child; the transport + // stops that child itself, so the proof holds whichever close failed. + const failedAttempt = outcome.events.indexOf("child-close"); + expect(failedAttempt).toBeGreaterThanOrEqual(0); + expect(outcome.events.indexOf("root-close")).toBeGreaterThan(failedAttempt); + expect(outcome.root_removed_after_retry).toBe(true); + expect(outcome.retained_after_retry).toEqual([]); + }, +); + +posixTest( + "waits for a child that exits only after the final kill", + async ({ processes }) => { + const outcome = await runScenario(processes, "late-exit"); + expect(outcome.failure.messages).toEqual([expect.stringMatching(PRIMARY)]); + expect(outcome.retained_at_failure).toEqual([]); + expect(outcome.events).toEqual([ + "final-kill", + "child-exit", + "child-close", + "root-close", + ]); + }, +); + +posixTest( + "retains the root while the final kill has not ended the child", + async ({ processes }) => { + const outcome = await runScenario(processes, "undelivered-kill"); + expect(outcome.failure.messages).toEqual([ + expect.stringMatching(PRIMARY), + expect.stringMatching(/completion.*did not exit after SIGKILL/u), + ]); + expect(outcome.retained_at_failure).toHaveLength(1); + expect(outcome.report.retained_root).toBe(outcome.retained_at_failure[0]); + // The running child is still visible to the report's lineage observation. + expect(outcome.report.descendants).toBe(1); + const refused = outcome.events.indexOf("retry-refused"); + expect(refused).toBeGreaterThan(0); + expect(outcome.events.slice(0, refused)).not.toContain("root-close"); + expect(outcome.events.slice(refused)).toEqual([ + "retry-refused", + "child-exit", + "child-close", + "root-close", + ]); + expect(outcome.root_removed_after_retry).toBe(true); + }, +); + +posixTest( + "retains the root while a surviving process holds the child's pipes", + async ({ processes }) => { + const outcome = await runScenario(processes, "pipe-holder"); + expect(outcome.failure.messages).toEqual([ + expect.stringMatching(PRIMARY), + expect.stringMatching(/pipes/u), + ]); + expect(outcome.retained_at_failure).toHaveLength(1); + // Reparented on exit, so only the open pipes reveal it. + expect(outcome.report.descendants).toBe(0); + const refused = outcome.events.indexOf("retry-refused"); + expect(outcome.events.slice(0, refused)).toEqual(["child-exit"]); + expect(outcome.events.slice(refused)).toEqual([ + "retry-refused", + "child-close", + "root-close", + ]); + expect(outcome.root_removed_after_retry).toBe(true); + }, +); + +posixTest( + "retains the root while another owned descendant is live", + async ({ processes }) => { + const outcome = await runScenario(processes, "live-descendant"); + expect(outcome.failure.messages).toEqual([ + expect.stringMatching(PRIMARY), + "Owned descendants survived verifier cleanup", + ]); + expect(outcome.report).toMatchObject({ + lineage_status: "verified", + descendants: 1, + }); + expect(outcome.retained_at_failure).toHaveLength(1); + expect(outcome.root_removed_after_retry).toBe(true); + }, +); + +posixTest.for([ + ["failed-snapshot", /lineage could not be inspected/u], + ["unreadable-environment", /descendant \d+ environment was unreadable/u], + ["foreign-descendant", /descendant \d+ run token did not match/u], +] as const)( + "retains the root while lineage is unknown after a %s, then retries", + async ([scenario, reason], { processes }) => { + const outcome = await runScenario(processes, scenario); + expect(outcome.failure.messages).toEqual([ + expect.stringMatching(PRIMARY), + expect.stringMatching(reason), + ]); + expect(outcome.report.lineage_status).toBe("unavailable"); + expect(outcome.report.lineage_reason).toMatch(reason); + expect(outcome.retained_at_failure).toHaveLength(1); + expect(outcome.report.retained_root).toBe(outcome.retained_at_failure[0]); + const released = outcome.events.indexOf("root-close"); + expect(released).toBeGreaterThan(outcome.events.indexOf("child-close")); + // The child's proof alone did not release it: only the retry reached the root. + expect(outcome.events.filter((kind) => kind === "root-close")).toHaveLength( + 1, + ); + expect(outcome.root_removed_after_retry).toBe(true); + expect(outcome.report.omitted_coverage).toEqual([]); + }, +); + +posixTest( + "releases the root where lineage has no facility, and says what that leaves unproven", + async ({ processes }) => { + const outcome = await runScenario(processes, "windows-lineage"); + expect(outcome.failure.messages).toEqual([expect.stringMatching(PRIMARY)]); + expect(outcome.report).toMatchObject({ + retained_root: null, + lineage_status: "unavailable", + lineage_reason: expect.stringMatching(/Job Object/u), + omitted_coverage: [ + expect.stringMatching(/MCP child's completion alone/u), + ], + }); + expect(outcome.retained_at_failure).toEqual([]); + expect(outcome.events.indexOf("root-close")).toBeGreaterThan( + outcome.events.indexOf("child-close"), + ); + }, +); diff --git a/tests/fixtures/goVerifierCleanupHarness.mjs b/tests/fixtures/goVerifierCleanupHarness.mjs new file mode 100644 index 00000000000..dc40c417629 --- /dev/null +++ b/tests/fixtures/goVerifierCleanupHarness.mjs @@ -0,0 +1,424 @@ +// Runs the actual Go verifier in this process with a fake compiler and one +// authored ownership fault, then reports what it released and in which order. +import assert from "node:assert/strict"; +import { ChildProcess, spawn } from "node:child_process"; +import { once } from "node:events"; +import { + chmodSync, + existsSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { registerHooks } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +import { PrivateRuntimeRoot } from "../../dist/process/PrivateRuntimeRoot.js"; + +const scenario = process.argv[2]; +assert.equal(typeof scenario, "string"); +// The launching test selects this directory, and the verifier allocates there. +assert.notEqual(process.env.TMPDIR, undefined); +const temporary = tmpdir(); + +const verifier = new URL("../../scripts/verify-go-binary.mjs", import.meta.url); +const events = []; +const record = (kind) => events.push(kind); + +// The launching test's fixture stops only this process, so anything a scenario +// starts is stopped on every exit from here, including a failed assertion. +const exitCleanups = []; +process.once("exit", () => { + for (const cleanup of exitCleanups) { + try { + cleanup(); + } catch { + // It already ended; the rest still need stopping. + } + } +}); +process.once("SIGTERM", () => process.exit(1)); + +const compiler = join(temporary, "fake-go"); +writeFileSync( + compiler, + `#!/bin/sh +case "$1" in + version) echo "go version go0.0.0-fake" ;; + env) printf 'linux\\namd64\\n' ;; +esac +`, +); +chmodSync(compiler, 0o755); +process.env.REA_VERIFY_GO_COMMAND = compiler; + +const moduleUrl = (source) => + `data:text/javascript,${encodeURIComponent(source)}`; +const replaceSdkModule = (specifier, source, onlyFrom) => + registerHooks({ + resolve(requested, context, next) { + if ( + requested === specifier && + (onlyFrom === undefined || context.parentURL === onlyFrom) + ) + return { shortCircuit: true, url: moduleUrl(source) }; + return next(requested, context); + }, + }); +const useDisposableChild = (mode) => { + const child = new URL("./goVerifierMcpChild.mjs", import.meta.url); + process.env.NODE_OPTIONS = `--import=${child.href}`; + process.env.GO_VERIFIER_CHILD_MODE = mode; +}; +const isMcpChild = (child) => child.spawnargs.includes("mcp"); + +const emit = ChildProcess.prototype.emit; +ChildProcess.prototype.emit = function (event, ...arguments_) { + if (isMcpChild(this)) { + if (event === "exit" || event === "close") record(`child-${event}`); + // Signal directly: a scenario may have replaced ChildProcess#kill. + if (event === "spawn") + exitCleanups.push(() => { + if (this.exitCode === null && this.signalCode === null) + process.kill(this.pid, "SIGKILL"); + }); + } + return emit.call(this, event, ...arguments_); +}; +const closeRoot = PrivateRuntimeRoot.prototype.close; +PrivateRuntimeRoot.prototype.close = function () { + record("root-close"); + return closeRoot.call(this); +}; +const closeClient = Client.prototype.close; +const closeTransport = StdioClientTransport.prototype.close; +// Each owner refuses once and leaves its child alone, as a failed close can. +const refuseClientCloseOnce = () => { + Client.prototype.close = function () { + Client.prototype.close = closeClient; + return Promise.reject(new Error("authored client close failure")); + }; +}; +const refuseTransportCloseOnce = () => { + StdioClientTransport.prototype.close = function () { + StdioClientTransport.prototype.close = closeTransport; + return Promise.reject(new Error("authored transport close failure")); + }; +}; + +// Fail one read in the real lineage observer, which reads /proc on Linux and runs +// ps elsewhere, so it reports its own unavailable reason on either platform. +const failOneLineageRead = (matchesPsArguments, matchesPath) => { + let failed = false; + const failNow = (matches) => { + if (failed || !matches) return false; + failed = true; + return true; + }; + const refuse = () => + Promise.reject(new Error("authored lineage read failure")); + globalThis.goVerifierLineageReads = { + spawn: (command, arguments_, options) => + failNow(command === "ps" && matchesPsArguments(arguments_)) + ? spawn("/bin/sh", ["-c", "exit 1"], options) + : spawn(command, arguments_, options), + readdir: (path, options) => + failNow(matchesPath(path)) ? refuse() : readdir(path, options), + readFile: (path, options) => + failNow(matchesPath(path)) ? refuse() : readFile(path, options), + }; + const lineage = new URL( + "../../scripts/lib/verifier-run.mjs", + import.meta.url, + ); + const reads = "globalThis.goVerifierLineageReads"; + registerHooks({ + resolve(requested, context, next) { + if ( + context.parentURL === lineage.href && + requested === "node:child_process" + ) + return { + shortCircuit: true, + url: moduleUrl( + `export const spawn = (...a) => ${reads}.spawn(...a);`, + ), + }; + if ( + context.parentURL === lineage.href && + requested === "node:fs/promises" + ) + return { + shortCircuit: true, + url: moduleUrl( + `export const readdir = (...a) => ${reads}.readdir(...a); + export const readFile = (...a) => ${reads}.readFile(...a);`, + ), + }; + return next(requested, context); + }, + }); +}; +// Start a run-owned process as the release begins, so lineage has one to observe. +const startDescendantDuringRelease = (environment = {}) => { + useDisposableChild("graceful"); + let descendant; + Client.prototype.close = async function () { + Client.prototype.close = closeClient; + descendant = spawn( + process.execPath, + ["--eval", "console.log('ready'); setTimeout(() => {}, 30000)"], + { + env: { ...process.env, NODE_OPTIONS: "", ...environment }, + stdio: ["ignore", "pipe", "ignore"], + }, + ); + exitCleanups.push(() => descendant.kill("SIGKILL")); + // Its run token must be readable before lineage observes it. + await once(descendant.stdout, "data"); + return closeClient.call(this); + }; + beforeRetry = async () => { + const exited = once(descendant, "exit"); + descendant.kill("SIGKILL"); + await exited; + }; +}; + +const failureMessages = (cause) => + cause instanceof AggregateError + ? cause.errors.flatMap(failureMessages) + : [cause?.message]; +const expectRefusal = async (owner, pattern) => { + const refusal = await owner.close().then( + () => assert.fail("The incomplete release must be refused"), + (cause) => cause, + ); + assert.match(failureMessages(refusal).join("\n"), pattern); + record("retry-refused"); +}; +let beforeRetry = async () => {}; + +const scenarios = { + "real-server": () => {}, + "client-constructor": () => { + replaceSdkModule( + "@modelcontextprotocol/client", + `export class Client { + constructor() { throw new Error("authored client constructor failure"); } + }`, + verifier.href, + ); + }, + "transport-constructor": () => { + replaceSdkModule( + "@modelcontextprotocol/client/stdio", + `export class StdioClientTransport { + constructor() { throw new Error("authored transport constructor failure"); } + }`, + ); + }, + "missing-compiler": () => { + process.env.REA_VERIFY_GO_COMMAND = join(temporary, "absent-go"); + }, + "spawn-failure": () => { + process.execPath = join(temporary, "absent-node"); + }, + "unobservable-child": () => { + beforeRetry = async (owner) => { + await expectRefusal(owner, /child handle/u); + process.exit(writeOutcome()); + }; + replaceSdkModule( + "@modelcontextprotocol/client/stdio", + `export class StdioClientTransport { + async start() {} + async close() {} + async send() {} + get stderr() { return null; } + }`, + ); + }, + "refused-handshake": () => useDisposableChild("refuses-handshake"), + "noisy-stderr": () => useDisposableChild("noisy-stderr"), + "client-close": () => { + useDisposableChild("graceful"); + refuseClientCloseOnce(); + }, + "transport-close": () => { + useDisposableChild("graceful"); + // A client that closed cleanly, then the transport's own close refusal. + Client.prototype.close = function () { + Client.prototype.close = closeClient; + return Promise.resolve(); + }; + refuseTransportCloseOnce(); + }, + "both-closes": () => { + useDisposableChild("graceful"); + refuseClientCloseOnce(); + refuseTransportCloseOnce(); + }, + "root-close": () => { + useDisposableChild("graceful"); + PrivateRuntimeRoot.prototype.close = function () { + record("root-close"); + PrivateRuntimeRoot.prototype.close = closeRoot; + return Promise.reject(new Error("authored root close failure")); + }; + }, + "late-exit": () => { + useDisposableChild("ignores-termination"); + // The SDK returns from close() as soon as it has sent this signal. Hold the + // child past that return, longer than the verifier's next steps can take. + const kill = ChildProcess.prototype.kill; + ChildProcess.prototype.kill = function (signal) { + if (!isMcpChild(this) || signal !== "SIGKILL") + return kill.call(this, signal); + if (!events.includes("final-kill")) record("final-kill"); + setTimeout(() => kill.call(this, signal), 400); + return true; + }; + }, + "undelivered-kill": () => { + useDisposableChild("ignores-termination"); + const kill = ChildProcess.prototype.kill; + ChildProcess.prototype.kill = function (signal) { + if (isMcpChild(this)) { + record(`denied-${String(signal)}`); + return false; + } + return kill.call(this, signal); + }; + beforeRetry = async (owner) => { + await expectRefusal(owner, /completion/u); + ChildProcess.prototype.kill = kill; + }; + }, + "pipe-holder": () => { + const holder = join(temporary, "pipe-holder.pid"); + const stopHolder = () => { + // The child wrote this file; removing it keeps the signal to one delivery. + if (!existsSync(holder)) return; + const pid = Number(readFileSync(holder, "utf8")); + rmSync(holder); + process.kill(pid, "SIGKILL"); + }; + exitCleanups.push(stopHolder); + useDisposableChild("leaves-pipe-holder"); + beforeRetry = async (owner) => { + await expectRefusal(owner, /pipes/u); + stopHolder(); + while (!events.includes("child-close")) await delay(10); + }; + }, + "live-descendant": () => startDescendantDuringRelease(), + "foreign-descendant": () => + startDescendantDuringRelease({ REA_PROCESS_RUN_ID: "foreign-run" }), + "unreadable-environment": () => { + startDescendantDuringRelease(); + failOneLineageRead( + (arguments_) => arguments_[0] === "eww", + (path) => String(path).endsWith("/environ"), + ); + }, + "failed-snapshot": () => { + useDisposableChild("graceful"); + failOneLineageRead( + (arguments_) => arguments_[0] === "-axo", + (path) => path === "/proc", + ); + }, + "windows-lineage": async () => { + useDisposableChild("graceful"); + // Reach the real Windows lineage branch and this verifier's decision on it. Every + // step around that window stays POSIX, so this does not exercise native Windows. + const setPlatform = (value) => + Object.defineProperty(process, "platform", { value }); + const posix = process.platform; + const { VerifierStdioClientTransport } = + await import("../../scripts/lib/verifier-stdio-transport.mjs"); + const closeChild = + VerifierStdioClientTransport.prototype.closeAndVerifyChild; + VerifierStdioClientTransport.prototype.closeAndVerifyChild = + async function () { + await closeChild.call(this); + setPlatform("win32"); + }; + PrivateRuntimeRoot.prototype.close = function () { + setPlatform(posix); + record("root-close"); + return closeRoot.call(this); + }; + }, +}; +const setUpScenario = new Map(Object.entries(scenarios)).get(scenario); +assert.ok(setUpScenario, `Unknown scenario: ${scenario}`); +await setUpScenario(); + +const consoleError = console.error; +let report; +console.error = (text, ...rest) => { + // Only the verifier's own failure report is a JSON document. + if (typeof text === "string" && text.startsWith("{")) + report = JSON.parse(text); + else consoleError(text, ...rest); +}; +const rootEntries = () => + readdirSync(temporary).filter((name) => name.startsWith("rea-go-verifier-")); + +let failure; +try { + await import(verifier.href); +} catch (cause) { + failure = cause; +} +console.error = consoleError; +const retainedAtFailure = rootEntries(); +const outcome = { + failure: { + name: failure?.name, + message: failure?.message, + retained_root: failure?.retainedRoot ?? null, + messages: + failure instanceof AggregateError + ? failure.errors.map(({ message }) => message) + : [failure?.message], + }, + report: { + status: report?.status, + retained_root: report?.retained_root ?? null, + descendants: report?.verifier.process_lineage.descendants?.length ?? null, + lineage_status: report?.verifier.process_lineage.status, + lineage_reason: report?.verifier.process_lineage.reason ?? null, + omitted_coverage: report?.omitted_coverage ?? [], + }, + retained_at_failure: retainedAtFailure.map((name) => join(temporary, name)), +}; + +function writeOutcome() { + outcome.events = events; + console.log(JSON.stringify(outcome)); + return 0; +} + +const owner = failure?.cleanupOwner; +if (owner !== undefined) { + await beforeRetry(owner); + await owner.close(); + // A completed release stays complete. + await owner.close(); + outcome.retained_after_retry = rootEntries(); + outcome.root_removed_after_retry = + retainedAtFailure.length === 1 && + !existsSync(join(temporary, retainedAtFailure[0])); +} +// Keep a failed scenario from waiting on the child it reports. +process.exit(writeOutcome()); diff --git a/tests/fixtures/goVerifierMcpChild.mjs b/tests/fixtures/goVerifierMcpChild.mjs new file mode 100644 index 00000000000..8e6c366ed8a --- /dev/null +++ b/tests/fixtures/goVerifierMcpChild.mjs @@ -0,0 +1,71 @@ +// Loaded with --import in place of `rea mcp`, so the verifier's real SDK client +// and transport launch and frame a disposable child whose exit timing is authored. +import { spawn } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const mode = process.env.GO_VERIFIER_CHILD_MODE; +const lifetime = setTimeout(() => process.exit(1), 30_000); + +if (mode === "ignores-termination") process.on("SIGTERM", () => {}); +// Write more than the transport's stderr buffers hold before anything reads it. +if (mode === "noisy-stderr") process.stderr.write("x".repeat(1024 * 1024)); + +const reply = (message) => { + if (message.method !== "initialize") return; + process.stdout.write( + `${JSON.stringify( + mode === "refuses-handshake" + ? { + jsonrpc: "2.0", + id: message.id, + error: { code: -32603, message: "authored handshake refusal" }, + } + : { + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: message.params.protocolVersion, + capabilities: {}, + serverInfo: { name: "go-verifier-child", version: "1" }, + }, + }, + )}\n`, + ); +}; + +let buffered = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffered += chunk; + for (;;) { + const end = buffered.indexOf("\n"); + if (end === -1) return; + reply(JSON.parse(buffered.slice(0, end))); + buffered = buffered.slice(end + 1); + } +}); +process.stdin.on("end", () => { + if (mode === "ignores-termination") return; + if (mode === "leaves-pipe-holder") { + // Exit first, leaving a reparented process that the lineage walk cannot see. + const { NODE_OPTIONS: _options, ...environment } = process.env; + const holder = spawn( + process.execPath, + ["--eval", "setTimeout(() => {}, 30000)"], + { + detached: true, + env: environment, + stdio: ["ignore", "inherit", "inherit"], + }, + ); + writeFileSync(join(tmpdir(), "pipe-holder.pid"), String(holder.pid)); + holder.unref(); + } + clearTimeout(lifetime); + process.exit(0); +}); + +// Own the process in place of the entrypoint this module was loaded before. +await new Promise(() => {});