From 11fe5a13f8aee87d0c0e036a9f497407e085fe48 Mon Sep 17 00:00:00 2001 From: Yijie Lu <115929844+1NormalGuy@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:37:47 +0800 Subject: [PATCH] fix(native): retain failed pwntools process and runtime cleanup (#1708) * fix(native): retain failed pwntools process and runtime cleanup Keep failed worker/debugger cleanup owners and their private files until verified process shutdown and successful directory removal. Retry cleanup before another inspection and expose shutdown through the shared services, CLI and MCP server. Serialize acquisition without delaying cancellation of queued callers, and preserve primary diagnostics when CLI cleanup fails. The original decoder reproduction left a live owned Node child with its runtime deleted and no retained cleanup owner; the regression now verifies retention, admission blocking, retries and eventual process/file cleanup. Validation: 10 lifecycle/public CLI/MCP tests, 20 service tests, test discovery 814/814, build:cached and full check including architecture guards. Actual POSIX process/filesystem boundaries used injected Node workers on macOS; real Linux pwntools/GDB provider parsing was not exercised. * test(native): verify retained recorded-crash cleanup lifecycle * fix(native): finalize diagnostic cleanup before publishing results * fix(native): retire decoder stages before reading runtime evidence --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../binaryDiagnostics/BinaryLayoutPort.ts | 5 + .../BinaryLayoutService.test.ts | 18 + .../binaryDiagnostics/BinaryLayoutService.ts | 28 +- .../binaryDiagnostics/RecordedCrashPort.ts | 5 + .../RecordedCrashService.test.ts | 18 + .../binaryDiagnostics/RecordedCrashService.ts | 28 +- src/cli/binaryDiagnosticsCommands.ts | 4 +- src/domain/analysisErrorBase.ts | 8 + src/domain/native/recordedCrash.ts | 11 + src/native/pwndbg/PwndbgCoreContext.ts | 40 +- src/native/pwntools/PwntoolsDecoder.ts | 324 ++++++++++++++-- .../pwntools/PwntoolsRecordedCrashProvider.ts | 54 ++- src/process/OwnedCommand.ts | 8 +- src/server/createServer.ts | 8 + .../pwntoolsLayoutProvider.test.ts | 32 +- .../recordedCrashProvider.test.ts | 156 +++++--- .../mcp/analysisSummaryDetailMcp.test.ts | 3 + tests/boundary/mcp/analysisViewMcp.test.ts | 3 + .../boundary/mcp/binaryDiagnosticsMcp.test.ts | 8 + tests/boundary/mcp/recordedCrashMcp.test.ts | 8 + .../boundary/native/PwndbgCoreContext.test.ts | 3 + tests/fixtures/pwntoolsChangingArtifact.mjs | 40 ++ .../binaryDiagnosticsCliCleanup.test.ts | 111 ++++++ tests/process-global/pwntoolsCleanup.test.ts | 264 +++++++++++++ .../pwntoolsCliFinalization.test.ts | 308 +++++++++++++++ .../pwntoolsStageRetirement.test.ts | 356 ++++++++++++++++++ 26 files changed, 1716 insertions(+), 135 deletions(-) create mode 100644 tests/fixtures/pwntoolsChangingArtifact.mjs create mode 100644 tests/process-global/binaryDiagnosticsCliCleanup.test.ts create mode 100644 tests/process-global/pwntoolsCleanup.test.ts create mode 100644 tests/process-global/pwntoolsCliFinalization.test.ts create mode 100644 tests/process-global/pwntoolsStageRetirement.test.ts diff --git a/src/application/binaryDiagnostics/BinaryLayoutPort.ts b/src/application/binaryDiagnostics/BinaryLayoutPort.ts index a6936a60f..55fe16c8e 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutPort.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutPort.ts @@ -10,6 +10,11 @@ import type { ProviderIdentity } from "../AnalysisProvider.js"; /** Replaceable offline binary-layout provider; owns its parser and process protocol. */ export interface BinaryLayoutPort { readonly identity: ProviderIdentity; + inspectAndClose( + input: InspectBinaryLayoutInput, + options?: ExecutionOptions, + ): Promise>; + close?(): Promise; inspect( input: InspectBinaryLayoutInput, options?: ExecutionOptions, diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts index e65da65f6..76a8d8551 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts @@ -17,6 +17,9 @@ it.each([ "rejects malformed selection before starting the provider: %j", async (input) => { const service = new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => { throw new Error("must not run"); @@ -32,6 +35,9 @@ it.each([ it("records the decoded layout once in its Evidence", async () => { const layout = binaryLayoutFixture(); const service = new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => Promise.resolve(ok(layout)), }); @@ -49,6 +55,9 @@ it("preserves a provider's actionable unsupported reason", async () => { "ELF core belongs to recorded-crash analysis.", ); const service = new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => Promise.resolve(err(failure)), }); @@ -58,6 +67,9 @@ it("preserves a provider's actionable unsupported reason", async () => { it("cancels before invocation and after an acquired observation", async () => { const controller = new AbortController(); const service = new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => { controller.abort(); @@ -77,6 +89,9 @@ it("cancels before invocation and after an acquired observation", async () => { }, }); const untouched = new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => { throw new Error("must not run"); @@ -112,6 +127,9 @@ it.each(["different-path", "invalid-digest", "outside-range"])( permissions: { read: true, write: false, execute: true }, }); const service = new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => Promise.resolve(ok(fixture)), }); diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.ts b/src/application/binaryDiagnostics/BinaryLayoutService.ts index bdb07da56..68859b64a 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.ts @@ -21,10 +21,36 @@ const OPERATION = "inspect_binary_layout"; export class BinaryLayoutService { constructor(readonly provider: BinaryLayoutPort) {} + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + /** Bind complete observations to the caller-selected artifact before creating Evidence. */ async inspect( rawInput: unknown, options?: ExecutionOptions, + ): Promise> { + return this.#inspect(rawInput, options, (input) => + this.provider.inspect(input, options), + ); + } + + /** Complete the owned one-shot lifecycle before validating and publishing Evidence. */ + inspectAndClose( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + return this.#inspect(rawInput, options, (input) => + this.provider.inspectAndClose(input, options), + ); + } + + async #inspect( + rawInput: unknown, + options: ExecutionOptions | undefined, + inspect: ( + input: Parameters[0], + ) => ReturnType, ): Promise> { if (options?.signal?.aborted) return err(new AnalysisCancelledError(OPERATION)); @@ -45,7 +71,7 @@ export class BinaryLayoutService { }, ]), ); - const inspected = await this.provider.inspect(input.data, options); + const inspected = await inspect(input.data); if (!inspected.ok) return inspected; const diagnostics = binaryLayoutSchema.shape.diagnostics.safeParse( inspected.value.diagnostics, diff --git a/src/application/binaryDiagnostics/RecordedCrashPort.ts b/src/application/binaryDiagnostics/RecordedCrashPort.ts index 3f9551c67..aca32a0e4 100644 --- a/src/application/binaryDiagnostics/RecordedCrashPort.ts +++ b/src/application/binaryDiagnostics/RecordedCrashPort.ts @@ -12,6 +12,11 @@ import type { Result } from "../../domain/result.js"; /** Replaceable decoder of supplied recordings; historical PID is never live authority. */ export interface RecordedCrashPort { readonly identity: ProviderIdentity; + inspectAndClose( + input: InspectRecordedCrashInput, + options?: ExecutionOptions, + ): Promise>; + close?(): Promise; inspect( input: InspectRecordedCrashInput, options?: ExecutionOptions, diff --git a/src/application/binaryDiagnostics/RecordedCrashService.test.ts b/src/application/binaryDiagnostics/RecordedCrashService.test.ts index 0ab49ee1b..cdf7cbd8f 100644 --- a/src/application/binaryDiagnostics/RecordedCrashService.test.ts +++ b/src/application/binaryDiagnostics/RecordedCrashService.test.ts @@ -13,6 +13,9 @@ it("labels an envelope containing debugger-derived maps as derived", async () => const fixture = recordedCrashFixture(); fixture.debugger = recordedCrashDebuggerFixture(); const service = new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: RECORDED_CRASH_TEST_PROVIDER, inspect: () => Promise.resolve(ok(fixture)), }); @@ -36,6 +39,9 @@ it.each([ "rejects malformed selection before starting the provider: %j", async (input) => { const service = new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: RECORDED_CRASH_TEST_PROVIDER, inspect: () => { throw new Error("must not run"); @@ -55,6 +61,9 @@ it("preserves a provider's actionable unsupported reason", async () => { "ELF core belongs to recorded-crash analysis.", ); const service = new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: RECORDED_CRASH_TEST_PROVIDER, inspect: () => Promise.resolve(err(failure)), }); @@ -64,6 +73,9 @@ it("preserves a provider's actionable unsupported reason", async () => { it("cancels before invocation and after an acquired observation", async () => { const controller = new AbortController(); const service = new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: RECORDED_CRASH_TEST_PROVIDER, inspect: () => { controller.abort(); @@ -83,6 +95,9 @@ it("cancels before invocation and after an acquired observation", async () => { }, }); const untouched = new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: RECORDED_CRASH_TEST_PROVIDER, inspect: () => { throw new Error("must not run"); @@ -117,6 +132,9 @@ it.each(["different-path", "invalid-digest", "outside-range"])( flags: "0x5", }); const service = new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: RECORDED_CRASH_TEST_PROVIDER, inspect: () => Promise.resolve(ok(fixture)), }); diff --git a/src/application/binaryDiagnostics/RecordedCrashService.ts b/src/application/binaryDiagnostics/RecordedCrashService.ts index 5a4fff645..df137f83e 100644 --- a/src/application/binaryDiagnostics/RecordedCrashService.ts +++ b/src/application/binaryDiagnostics/RecordedCrashService.ts @@ -21,10 +21,36 @@ const OPERATION = "inspect_recorded_crash"; export class RecordedCrashService { constructor(readonly provider: RecordedCrashPort) {} + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + /** Bind complete observations to the caller-selected artifact before creating Evidence. */ async inspect( rawInput: unknown, options?: ExecutionOptions, + ): Promise> { + return this.#inspect(rawInput, options, (input) => + this.provider.inspect(input, options), + ); + } + + /** Complete the owned one-shot lifecycle before validating and publishing Evidence. */ + inspectAndClose( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + return this.#inspect(rawInput, options, (input) => + this.provider.inspectAndClose(input, options), + ); + } + + async #inspect( + rawInput: unknown, + options: ExecutionOptions | undefined, + inspect: ( + input: Parameters[0], + ) => ReturnType, ): Promise> { if (options?.signal?.aborted) return err(new AnalysisCancelledError(OPERATION)); @@ -45,7 +71,7 @@ export class RecordedCrashService { }, ]), ); - const inspected = await this.provider.inspect(input.data, options); + const inspected = await inspect(input.data); if (!inspected.ok) return inspected; const diagnostics = recordedCrashSchema.shape.diagnostics.safeParse( inspected.value.diagnostics, diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 70c119900..bddd16bed 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -53,7 +53,7 @@ export const registerBinaryDiagnosticsCommands = ( run: ({ args }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectBinaryLayout, async () => { - const result = await service.inspect( + const result = await service.inspectAndClose( { path: resolve(args.path) }, { signal }, ); @@ -75,7 +75,7 @@ export const registerBinaryDiagnosticsCommands = ( run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectRecordedCrash, async () => { - const result = await recorded.inspect( + const result = await recorded.inspectAndClose( { path: resolve(args.path), include_debugger_context: options.debuggerContext, diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 389d4870a..ff5562704 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -109,6 +109,11 @@ import type { AdbPackagePullPartialObservation, } from "./adb/adbDeviceAnalysis.js"; import type { NativeUiObservationResult } from "./native/nativeUiObservation.js"; +import type { BinaryLayout } from "./native/binaryLayout.js"; +import type { + RecordedCrash, + RecordedCrashPartialObservation, +} from "./native/recordedCrash.js"; /** Provider-neutral evidence collected before a typed analysis failure. */ export type AnalysisPartialObservation = @@ -123,6 +128,9 @@ export type AnalysisPartialObservation = | WebScriptExportPartialObservation | JavaScriptRecoveryPartialObservation | Evidence + | BinaryLayout + | RecordedCrash + | RecordedCrashPartialObservation | AndroidPartialObservation | FirmwarePartialObservation | NativeUiObservationResult diff --git a/src/domain/native/recordedCrash.ts b/src/domain/native/recordedCrash.ts index 110bd1d91..d4b533838 100644 --- a/src/domain/native/recordedCrash.ts +++ b/src/domain/native/recordedCrash.ts @@ -358,3 +358,14 @@ export type InspectRecordedCrashInput = z.output< typeof inspectRecordedCrashInputSchema >; export type RecordedCrash = z.output; + +/** Validated basic core evidence retained when requested debugger enrichment fails. */ +export type RecordedCrashPartialObservation = Omit< + RecordedCrash, + "debugger" +> & { + readonly debugger: { + readonly status: "unavailable"; + readonly requested: true; + }; +}; diff --git a/src/native/pwndbg/PwndbgCoreContext.ts b/src/native/pwndbg/PwndbgCoreContext.ts index 9c5f339e0..0ef29cd9b 100644 --- a/src/native/pwndbg/PwndbgCoreContext.ts +++ b/src/native/pwndbg/PwndbgCoreContext.ts @@ -17,14 +17,14 @@ import { type RecordedCrash, } from "../../domain/native/recordedCrash.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; -import { runOwnedCommand } from "../../process/OwnedCommand.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { capturedPwntoolsOutput, pwntoolsDecoderFailure, } from "../pwntools/PwntoolsFailures.js"; import { PWNTOOLS_LIMITS } from "../pwntools/PwntoolsRelease.js"; import { readPwntoolsFailureEvidence } from "../pwntools/PwntoolsResourceLimits.js"; -import type { PwntoolsLauncher } from "../pwntools/PwntoolsDecoder.js"; +import type { PwntoolsCommand } from "../pwntools/PwntoolsDecoder.js"; const OPERATION = "inspect_recorded_crash"; const replySchema = z.discriminatedUnion("ok", [ @@ -48,14 +48,14 @@ export const inspectPwndbgCore = async ({ rootPath, diagnostics, options, - launcher, + runCommand, probePath, }: { readonly environment: Readonly; readonly rootPath: string; readonly diagnostics: AnalysisCapturedOutput; readonly options: ExecutionOptions | undefined; - readonly launcher?: PwntoolsLauncher; + readonly runCommand: PwntoolsCommand; readonly probePath?: ( path: string, kind: "file" | "directory", @@ -126,9 +126,23 @@ export const inspectPwndbgCore = async ({ { flag: "wx", mode: 0o600 }, ); let output: AnalysisCapturedOutput | undefined; + const failureContext = { + operation: OPERATION, + providerId: "pwndbg-core", + precedingOutput: diagnostics, + }; + const failure = (cause: unknown) => + pwntoolsDecoderFailure( + cause, + "debugger", + gdb, + environment.REA_PWNTOOLS_PYTHON ?? "", + {}, + failureContext, + ); try { options?.signal?.throwIfAborted(); - const execution = await runOwnedCommand( + const execution = await runCommand( { command: environment.REA_PWNTOOLS_PYTHON ?? "", arguments: [ @@ -155,14 +169,7 @@ export const inspectPwndbgCore = async ({ OMP_NUM_THREADS: "1", }, }, - { - timeoutMs: PWNTOOLS_LIMITS.timeoutMs, - diagnosticBytes: PWNTOOLS_LIMITS.diagnosticBytes, - }, - { - ...(options?.signal === undefined ? {} : { signal: options.signal }), - ...(launcher === undefined ? {} : { launcher }), - }, + failure, ); output = capturedPwntoolsOutput(execution); let reply: z.output; @@ -218,6 +225,7 @@ export const inspectPwndbgCore = async ({ ); return { ...reply.value, diagnostics: output }; } catch (cause: unknown) { + if (cause instanceof AnalysisError && cause.cleanupIncomplete) throw cause; if ( options?.signal?.aborted && (cause === options.signal.reason || @@ -232,11 +240,7 @@ export const inspectPwndbgCore = async ({ gdb, environment.REA_PWNTOOLS_PYTHON ?? "", await readPwntoolsFailureEvidence(cause, stage), - { - operation: OPERATION, - providerId: "pwndbg-core", - precedingOutput: diagnostics, - }, + failureContext, ); } }; diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index 273f7d4b5..3a28edbfa 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -11,9 +11,12 @@ import { fileURLToPath } from "node:url"; import { z } from "zod"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import type { ProviderIdentity } from "../../application/AnalysisProvider.js"; -import type { AnalysisCapturedOutput } from "../../domain/analysisErrorBase.js"; +import type { + AnalysisCapturedOutput, + AnalysisPartialObservation, +} from "../../domain/analysisErrorBase.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; -import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, @@ -23,10 +26,17 @@ import { AnalysisResourceConstraintError, } from "../../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import { err, ok, type Result } from "../../domain/result.js"; import { runOwnedCommand } from "../../process/OwnedCommand.js"; +import type { + ProviderProcessSupervisor, + OwnedProviderProcessSpawnOptions, + ProviderProcessSnapshot, +} from "../../process/ProviderProcess.js"; +import { PendingOperations } from "../../process/PendingOperations.js"; import { pwntoolsResourceLimitsSchema, readPwntoolsFailureEvidence, @@ -44,9 +54,15 @@ const HOST_REQUIREMENT = export type PwntoolsLauncher = NonNullable< Parameters[2] >["launcher"]; -interface DecoderReport { +/** Owner-bound execution; replies and failure files are stable only after this settles without cleanup uncertainty. */ +export type PwntoolsCommand = ( + spawn: OwnedProviderProcessSpawnOptions, + /** Project the original failure without reading files an unresolved worker may change. */ + failure: (cause: unknown) => AnalysisError, +) => Promise; +type DecoderReport = AnalysisPartialObservation & { readonly diagnostics: AnalysisCapturedOutput; -} +}; /** Adapter-selected semantics around a shared owned pwntools process boundary. */ export interface PwntoolsDecoderProfile< Input extends { readonly path: string }, @@ -64,6 +80,7 @@ export interface PwntoolsDecoderProfile< readonly rootPath: string; readonly diagnostics: AnalysisCapturedOutput; readonly options: ExecutionOptions | undefined; + readonly runCommand: PwntoolsCommand; }) => Report | Promise; } @@ -74,6 +91,22 @@ export class PwntoolsDecoder< Report extends DecoderReport, > { readonly identity: ProviderIdentity; + #tail: Promise = Promise.resolve(); + readonly #admissions = new PendingOperations< + symbol, + "admitted" | "cancelled" + >(); + #closing = false; + #closePromise: Promise | undefined; + #pendingCleanup: + | { + root: Pick | undefined; + process: ProviderProcessSupervisor | undefined; + failure: AnalysisError | undefined; + diagnostics: AnalysisCapturedOutput | undefined; + observation: AnalysisPartialObservation | undefined; + } + | undefined; constructor( readonly profile: PwntoolsDecoderProfile, readonly environment: Readonly, @@ -86,10 +119,61 @@ export class PwntoolsDecoder< } /** Snapshot one explicit artifact and return complete static observations without launching the selected object as a host process. */ - async inspect( + inspect( input: Input, options?: ExecutionOptions, ): Promise> { + return this.#enqueue(() => this.#inspect(input, options), options); + } + + /** Finalize an admitted inspection before publishing its outcome; rejected admission acquires nothing and does not close the provider. */ + inspectAndClose( + input: Input, + options?: ExecutionOptions, + ): Promise> { + return this.#enqueue(() => { + this.#closing = true; + return this.#inspect(input, options); + }, options); + } + + #enqueue( + inspect: () => Promise>, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return Promise.resolve( + err(new AnalysisCancelledError(this.profile.operation)), + ); + if (this.#closing) return Promise.resolve(err(this.#closedError())); + const key = Symbol(); + const admission = this.#admissions.wait(key, { + ...(options?.signal === undefined ? {} : { signal: options.signal }), + cancelledValue: () => "cancelled", + }); + const operation = this.#tail.then(() => { + this.#admissions.settle(key, "admitted"); + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(this.profile.operation)); + return this.#closing ? err(this.#closedError()) : inspect(); + }); + this.#tail = operation.then( + () => undefined, + () => undefined, + ); + return admission.then((state) => + state === "cancelled" + ? err(new AnalysisCancelledError(this.profile.operation)) + : operation, + ); + } + + async #inspect( + input: Input, + options?: ExecutionOptions, + ): Promise> { + const pending = await this.#retire(); + if (pending !== undefined) return err(pending); const OPERATION = this.profile.operation; const context = { operation: OPERATION, providerId: this.identity.id }; const replySchema = z.discriminatedUnion("ok", [ @@ -118,6 +202,15 @@ export class PwntoolsDecoder< let phase: "configuration" | "artifact-read" | "decoder" = "configuration"; const executablePath = this.environment.REA_PWNTOOLS_PYTHON ?? ""; let selectedPath = executablePath; + const failure = (cause: unknown) => + pwntoolsDecoderFailure( + cause, + phase, + selectedPath, + executablePath, + {}, + context, + ); try { if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION); if (process.platform !== "linux" || process.arch !== "x64") @@ -154,6 +247,13 @@ export class PwntoolsDecoder< ); phase = "decoder"; root = await this.createRuntime(); + this.#pendingCleanup = { + root, + process: undefined, + failure: undefined, + diagnostics: undefined, + observation: undefined, + }; const requestPath = join(root.path, "request.json"); const snapshotPath = join(root.path, "object.snapshot"); const replyPath = join(root.path, "reply.json"); @@ -167,7 +267,7 @@ export class PwntoolsDecoder< JSON.stringify({ snapshot_path: snapshotPath, reply_path: replyPath }), { flag: "wx", mode: 0o600 }, ); - const execution = await runOwnedCommand( + const execution = await this.#runCommand( { command: this.environment.REA_PWNTOOLS_PYTHON ?? "", arguments: [ @@ -188,14 +288,8 @@ export class PwntoolsDecoder< OMP_NUM_THREADS: "1", }, }, - { - timeoutMs: PWNTOOLS_LIMITS.timeoutMs, - diagnosticBytes: PWNTOOLS_LIMITS.diagnosticBytes, - }, - { - ...(options?.signal === undefined ? {} : { signal: options.signal }), - ...(this.launcher === undefined ? {} : { launcher: this.launcher }), - }, + options, + failure, ); const capturedOutput = capturedPwntoolsOutput(execution); let reply: z.output; @@ -277,9 +371,20 @@ export class PwntoolsDecoder< rootPath: root.path, diagnostics: capturedOutput, options, + runCommand: (spawn, classifyFailure) => + this.#runCommand(spawn, options, classifyFailure), }), ); } catch (cause: unknown) { + // The command owner has exhausted this operation's cleanup attempts. + // Keep its resources and do not consume files an unresolved worker may change. + if (cause instanceof AnalysisError && cause.cleanupIncomplete) { + if (this.#pendingCleanup !== undefined) { + this.#pendingCleanup.failure = cause; + this.#pendingCleanup.observation = cause.partialObservation; + } + return err(cause); + } const evidence = await readPwntoolsFailureEvidence(cause, root?.path); result = err( options?.signal?.aborted && @@ -296,37 +401,26 @@ export class PwntoolsDecoder< ), ); } - if (root !== undefined) { - try { - await root.close(); - } catch (cause: unknown) { - return err( - new ProviderCleanupError( - this.identity.id, - [root.path], - { - reason: cause instanceof Error ? cause.message : String(cause), - previous_error: result.ok - ? null - : projectAnalysisError(result.error), - ...(result.ok - ? { - captured_output: { - stdout: result.value.diagnostics.stdout, - stderr: result.value.diagnostics.stderr, - truncated: result.value.diagnostics.truncated, - }, - } - : {}), - }, - { operation: OPERATION }, - ), - ); - } + if (this.#pendingCleanup !== undefined) { + this.#pendingCleanup.failure = result.ok ? undefined : result.error; + this.#pendingCleanup.observation = result.ok + ? result.value + : result.error.partialObservation; + this.#pendingCleanup.diagnostics = result.ok + ? result.value.diagnostics + : result.error.capturedOutput; } + const cleanup = await this.#completeCleanup(() => this.#retire()); + if (cleanup !== undefined) + return err( + result.ok + ? cleanup + : analysisErrorWithCleanupFailure(result.error, cleanup, OPERATION), + ); return result.ok && options?.signal?.aborted ? err( new AnalysisCancelledError(OPERATION, { + partialObservation: result.value, capturedOutput: { stdout: result.value.diagnostics.stdout, stderr: result.value.diagnostics.stderr, @@ -336,4 +430,152 @@ export class PwntoolsDecoder< ) : result; } + + async #runCommand( + spawn: OwnedProviderProcessSpawnOptions, + options: ExecutionOptions | undefined, + failure: (cause: unknown) => AnalysisError, + ): Promise { + // A runtime is registered before either decoder stage may acquire a process. + const pending = this.#pendingCleanup; + if (pending === undefined) + throw new Error("Decoder command requires an owned runtime."); + let outcome: Result; + try { + outcome = ok( + await runOwnedCommand( + spawn, + { + timeoutMs: PWNTOOLS_LIMITS.timeoutMs, + diagnosticBytes: PWNTOOLS_LIMITS.diagnosticBytes, + }, + { + ...(options?.signal === undefined + ? {} + : { signal: options.signal }), + ...(this.launcher === undefined ? {} : { launcher: this.launcher }), + retainCleanup: (owner) => { + pending.process = owner; + }, + }, + ), + ); + } catch (cause: unknown) { + outcome = err(cause); + } + const cleanup = await this.#completeCleanup(() => this.#retireProcess()); + if (cleanup !== undefined) { + if (outcome.ok) throw cleanup; + // Until retirement succeeds, resource-limit markers remain unverified. + const primary = failure(outcome.error); + throw analysisErrorWithCleanupFailure( + primary, + cleanup, + this.profile.operation, + ); + } + if (!outcome.ok) throw outcome.error; + return outcome.value; + } + + /** Drain the running inspection, reject queued acquisition, and retry retained processes before deleting their files. */ + close(): Promise { + this.#closing = true; + this.#closePromise ??= this.#tail + .then(async () => { + const failure = await this.#retire(); + if (failure !== undefined) throw failure; + }) + .catch((cause: unknown) => { + this.#closePromise = undefined; + throw cause; + }); + return this.#closePromise; + } + + #closedError(): AnalysisCapabilityUnavailableError { + return new AnalysisCapabilityUnavailableError( + this.identity.id, + this.profile.operation, + "Provider is closing; create a new inspection service.", + ); + } + + async #completeCleanup( + retire: () => Promise, + ): Promise { + const failure = await retire(); + return failure !== undefined && this.#closing ? retire() : failure; + } + + async #retireProcess(): Promise { + const pending = this.#pendingCleanup; + if (pending?.process === undefined) return undefined; + const stopped = await pending.process.stop(); + if (stopped.status === "incomplete") + return new ProviderCleanupError( + this.identity.id, + [ + pending.process.launch.ownership?.runId ?? "owned-decoder", + ...(pending.root === undefined ? [] : [pending.root.path]), + ], + { + reason: stopped.reason, + captured_output: { + ...(pending.diagnostics ?? + capturedPwntoolsOutput(pending.process.snapshot())), + }, + previous_error: + pending.failure === undefined + ? null + : projectAnalysisError(pending.failure), + }, + { + operation: this.profile.operation, + ...(pending.observation === undefined + ? {} + : { partialObservation: pending.observation }), + }, + ); + pending.process = undefined; + return undefined; + } + + async #retire(): Promise { + const failure = await this.#retireProcess(); + if (failure !== undefined) return failure; + const pending = this.#pendingCleanup; + if (pending === undefined) return undefined; + if (pending.root !== undefined) { + const root = pending.root; + try { + await root.close(); + pending.root = undefined; + } catch (cause: unknown) { + return new ProviderCleanupError( + this.identity.id, + [root.path], + { + reason: cause instanceof Error ? cause.message : String(cause), + ...(pending.diagnostics === undefined + ? {} + : { captured_output: { ...pending.diagnostics } }), + previous_error: + pending.failure === undefined + ? null + : projectAnalysisError(pending.failure), + }, + { + operation: this.profile.operation, + cause, + ...(pending.observation === undefined + ? {} + : { partialObservation: pending.observation }), + }, + ); + } + } + this.#pendingCleanup = undefined; + return undefined; + } } diff --git a/src/native/pwntools/PwntoolsRecordedCrashProvider.ts b/src/native/pwntools/PwntoolsRecordedCrashProvider.ts index e0d1ea8d2..ffc10d13b 100644 --- a/src/native/pwntools/PwntoolsRecordedCrashProvider.ts +++ b/src/native/pwntools/PwntoolsRecordedCrashProvider.ts @@ -1,4 +1,5 @@ import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { recordedCrashPayloadSchema, recordedCrashSchema, @@ -49,6 +50,7 @@ export class PwntoolsRecordedCrashProvider rootPath, diagnostics, options, + runCommand, }) => { const validate = (debuggerContext: RecordedCrash["debugger"]) => recordedCrashSchema.safeParse({ @@ -114,26 +116,38 @@ export class PwntoolsRecordedCrashProvider ); } if (!input.include_debugger_context) return value; - const context = await inspectPwndbgCore({ - environment, - rootPath, - diagnostics, - options, - ...(launcher === undefined ? {} : { launcher }), - }); - const enriched = validate(context); - if (!enriched.success) - throw new AnalysisOutputError( - "inspect_recorded_crash", - `Recorded debugger context is invalid: ${enriched.error.issues[0]?.message ?? "schema mismatch"}`, - { - capturedOutput: - context.status === "available" - ? recordedCrashStageOutput(diagnostics, context.diagnostics) - : diagnostics, - }, - ); - return enriched.data; + try { + const context = await inspectPwndbgCore({ + environment, + rootPath, + diagnostics, + options, + runCommand, + }); + const enriched = validate(context); + if (!enriched.success) + throw new AnalysisOutputError( + "inspect_recorded_crash", + `Recorded debugger context is invalid: ${enriched.error.issues[0]?.message ?? "schema mismatch"}`, + { + capturedOutput: + context.status === "available" + ? recordedCrashStageOutput( + diagnostics, + context.diagnostics, + ) + : diagnostics, + }, + ); + return enriched.data; + } catch (cause: unknown) { + if (cause instanceof AnalysisError) + cause.retainPartialObservation({ + ...value, + debugger: { status: "unavailable", requested: true }, + }); + throw cause; + } }, }, environment, diff --git a/src/process/OwnedCommand.ts b/src/process/OwnedCommand.ts index a6b500b1b..02fb4defc 100644 --- a/src/process/OwnedCommand.ts +++ b/src/process/OwnedCommand.ts @@ -45,6 +45,8 @@ export const runOwnedCommand = async ( }, options: { readonly signal?: AbortSignal; + /** Synchronously accept unresolved cleanup ownership; execution still returns its original outcome. Must not throw. */ + readonly retainCleanup?: (owner: ProviderProcessSupervisor) => void; readonly launcher?: ( input: OwnedProviderProcessSpawnOptions, ) => Promise; @@ -64,6 +66,7 @@ export const runOwnedCommand = async ( options.launcher ?? spawnOwnedProviderProcess, deadline, callerSignal, + options.retainCleanup, ); } finally { deadline.dispose(); @@ -82,6 +85,7 @@ const collectOwnedCommand = async ( ) => Promise, deadline: ProviderStartupDeadline, callerSignal?: AbortSignal, + retainCleanup?: (owner: ProviderProcessSupervisor) => void, ): Promise => { const signal = deadline.signal; let launched: SpawnedOwnedProviderProcess; @@ -190,7 +194,9 @@ const collectOwnedCommand = async ( } const stopped = await supervisor.stop(); const snapshot = supervisor.snapshot(); - if (stopped.status === "incomplete") + if (stopped.status === "incomplete" && retainCleanup !== undefined) + retainCleanup(supervisor); + else if (stopped.status === "incomplete") throw new OwnedCommandFailure( failure?.reason ?? "process", failure?.message ?? "Command cleanup failed.", diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 3ff41d13c..844cc5d72 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -243,6 +243,10 @@ export const createServer = ( const delivery = selectToolResultDelivery(environment, options.delivery); const evmInterface = options.evmInterface ?? evmInterfaceModule.create(environment); + const binaryLayout = + options.binaryLayout ?? createBinaryLayoutService(environment); + const recordedCrash = + options.recordedCrash ?? createRecordedCrashService(environment); const webSourceLocation = options.webSourceLocation ?? createWebSourceLocationService(environment); const webModuleTrace = @@ -258,6 +262,8 @@ export const createServer = ( environment, delivery, evmInterface, + binaryLayout, + recordedCrash, webSourceLocation, webModuleTrace, javascriptRecovery, @@ -296,6 +302,8 @@ export const createServer = ( adbDevice.close(), flutter.close(), evmInterface.close(), + binaryLayout.close(), + recordedCrash.close(), webSourceLocation.close(), webModuleTrace.close(), javascriptRecovery.close?.() ?? Promise.resolve(), diff --git a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts index 71b7383ca..a16496b56 100644 --- a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts +++ b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts @@ -11,6 +11,7 @@ import { PWNTOOLS_PROVIDER_IDENTITY } from "../../../src/native/pwntools/Pwntool import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js"; import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; import { binaryLayoutFixture } from "../../fixtures/binaryDiagnostics/layout.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; @@ -94,6 +95,7 @@ it.runIf(!unsupportedHost)( async () => { const { path } = await fixture(); let ownedPath = ""; + let cleanupAllowed = false; const provider = new PwntoolsLayoutProvider( { REA_PWNTOOLS_PYTHON: process.execPath }, async (spawn) => { @@ -106,22 +108,37 @@ it.runIf(!unsupportedHost)( return { ...launched, cleanup: async () => { - await launched.cleanup?.(); + if (cleanupAllowed) + return cleanupOwnedProcessGroup(launched.ownership); throw new Error("injected post-cleanup reporting failure"); }, }; }, ); + onTestFinished(async () => { + cleanupAllowed = true; + await provider.close(); + }); const result = await provider.inspect({ path }); if (result.ok) throw new Error("Expected compounded lifecycle failure"); expect(result.error).toMatchObject({ cleanupIncomplete: true, diagnostics: { - reason: "injected post-cleanup reporting failure", - previous_error: { failure_kind: "output-limit" }, - captured_output: { truncated: true, stderr: "" }, + primary_error: { + code: "unreadable_output", + details: { captured_output: { truncated: true, stderr: "" } }, + }, + cleanup_error: { + code: "cleanup_incomplete", + details: { + diagnostics: { reason: "injected post-cleanup reporting failure" }, + }, + }, }, }); + await expect(access(ownedPath)).resolves.toBeUndefined(); + cleanupAllowed = true; + await provider.close(); await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" }); }, ); @@ -418,8 +435,11 @@ it.runIf(!unsupportedHost)( cleanupIncomplete: true, cleanupResources: [root], diagnostics: { - reason: "root cleanup failed", - previous_error: { category: "invalid_input" }, + primary_error: { category: "invalid_input" }, + cleanup_error: { + code: "cleanup_incomplete", + details: { diagnostics: { reason: "root cleanup failed" } }, + }, }, }, }); diff --git a/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts b/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts index e10297ad3..de422334b 100644 --- a/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts +++ b/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts @@ -1,3 +1,4 @@ +import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; import type { RecordedCrash } from "../../../src/domain/native/recordedCrash.js"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -9,7 +10,10 @@ import { } from "../../support/workspace/workspaceFixture.js"; import { PwntoolsRecordedCrashProvider } from "../../../src/native/pwntools/PwntoolsRecordedCrashProvider.js"; import { PWNTOOLS_PROVIDER_IDENTITY } from "../../../src/native/pwntools/PwntoolsRelease.js"; -import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { + spawnOwnedProviderProcess, + type SpawnedOwnedProviderProcess, +} from "../../../src/process/ProviderProcess.js"; import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js"; import { @@ -27,6 +31,19 @@ const requestSchema = z.object({ }); const host = process.platform === "linux" && process.arch === "x64"; +const withCleanupGate = ( + launched: SpawnedOwnedProviderProcess, + cleanupAllowed: () => boolean, +): SpawnedOwnedProviderProcess => ({ + ...launched, + cleanup: async () => { + const result = await cleanupOwnedProcessGroup(launched.ownership); + if (!cleanupAllowed()) + throw new Error("source-owned post-cleanup reporting failure"); + return result; + }, +}); + it .runIf(host) .each([ @@ -132,6 +149,7 @@ it "invalid-context", "cancelled", "cleanup-failure", + "decoder-cleanup-failure", ] as const)( "owns optional context from the same snapshot: %s", async (scenario) => { @@ -139,6 +157,8 @@ it const controller = new AbortController(); let owned = ""; let snapshotPath = ""; + let cleanupAllowed = false; + let launches = 0; const venv = join(root, "venv"); const plugin = join(root, "gdbinit.py"); await mkdir(venv); @@ -155,6 +175,7 @@ it }), }, async (spawn) => { + launches++; const request = requestSchema.parse( JSON.parse(await readFile(spawn.arguments.at(-1) ?? "", "utf8")), ); @@ -177,11 +198,14 @@ it value: raw, }), ); - return spawnOwnedProviderProcess({ + const launched = await spawnOwnedProviderProcess({ ...spawn, command: process.execPath, arguments: ["-e", "process.stdout.write('basic stage diagnostic')"], }); + return scenario === "decoder-cleanup-failure" + ? withCleanupGate(launched, () => cleanupAllowed) + : launched; } expect(request.snapshot_path).toBe(snapshotPath); const context = recordedCrashDebuggerFixture(); @@ -207,17 +231,15 @@ it await waitForProviderProcessReady(launched.process); setImmediate(() => controller.abort()); } - if (scenario === "cleanup-failure") - return { - ...launched, - cleanup: async () => { - await cleanupOwnedProcessGroup(launched.ownership); - throw new Error("source-owned post-cleanup reporting failure"); - }, - }; - return launched; + return scenario === "cleanup-failure" + ? withCleanupGate(launched, () => cleanupAllowed) + : launched; }, ); + onTestFinished(async () => { + cleanupAllowed = true; + await provider.close(); + }); const result = await provider.inspect( { path, @@ -234,46 +256,90 @@ it }); } else { if (result.ok) throw new Error("Expected optional debugger failure"); - const projected = projectAnalysisError(result.error); - if (scenario === "unconfigured") - expect(projected).toMatchObject({ - details: { captured_output: { stdout: "basic stage diagnostic" } }, - }); - else if ( - scenario === "invalid-context" || - scenario === "cancelled" || - scenario === "cleanup-failure" - ) { - const serialized = JSON.stringify(projected); - expect(serialized).toContain("basic stage diagnostic"); - expect(serialized).toContain( - scenario === "cancelled" ? "ready" : "debugger stage diagnostic", - ); - expect(result.error._tag).toBe( - scenario === "invalid-context" - ? "AnalysisOutputError" - : scenario === "cancelled" - ? "AnalysisCancelledError" - : "ProviderAdapterError", - ); - if (scenario === "cleanup-failure") - expect(result.error).toBeInstanceOf(ProviderCleanupError); - } else - expect(projected).toMatchObject({ - category: "execution_failure", - details: { - diagnostics: { - preceding_output: { stdout: "basic stage diagnostic" }, - stderr: "debugger failed", - }, - }, - }); + expectOptionalContextFailure(scenario, result.error); } expect(await readFile(path)).toEqual(bytes); + if ( + scenario === "cleanup-failure" || + scenario === "decoder-cleanup-failure" + ) { + await expect(access(owned)).resolves.toBeUndefined(); + expect(await readFile(snapshotPath)).toEqual(bytes); + const refused = await provider.inspect({ + path, + include_debugger_context: true, + }); + if (refused.ok) + throw new Error("Unreleased ownership must block a launch"); + expect(refused.error.cleanupResources).toContain(owned); + expect(launches).toBe(scenario === "decoder-cleanup-failure" ? 1 : 2); + await expect(provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + await expect(access(owned)).resolves.toBeUndefined(); + cleanupAllowed = true; + await provider.close(); + } await expect(access(owned)).rejects.toMatchObject({ code: "ENOENT" }); }, ); +const expectOptionalContextFailure = ( + scenario: string, + error: AnalysisError, +) => { + const projected = projectAnalysisError(error); + if (scenario === "unconfigured") + expect(projected).toMatchObject({ + details: { captured_output: { stdout: "basic stage diagnostic" } }, + }); + else if (scenario === "decoder-cleanup-failure") + expect(projected).toMatchObject({ + code: "cleanup_incomplete", + details: { + diagnostics: { captured_output: { stdout: "basic stage diagnostic" } }, + }, + }); + else if ( + scenario === "invalid-context" || + scenario === "cancelled" || + scenario === "cleanup-failure" + ) { + const serialized = JSON.stringify(projected); + expect(serialized).toContain("basic stage diagnostic"); + expect(serialized).toContain( + scenario === "cancelled" ? "ready" : "debugger stage diagnostic", + ); + expect(error._tag).toBe( + scenario === "invalid-context" + ? "AnalysisOutputError" + : scenario === "cancelled" + ? "AnalysisCancelledError" + : "ProviderAdapterError", + ); + if (scenario === "cleanup-failure" || scenario === "invalid-context") { + if (scenario === "cleanup-failure") + expect(error).toBeInstanceOf(ProviderCleanupError); + expect(projected).toMatchObject({ + details: { + partial_observation: { + debugger: { status: "unavailable", requested: true }, + }, + }, + }); + } + } else + expect(projected).toMatchObject({ + category: "execution_failure", + details: { + diagnostics: { + preceding_output: { stdout: "basic stage diagnostic" }, + stderr: "debugger failed", + }, + }, + }); +}; + const fixture = async (includeSignal = false, opaqueOwner = false) => { const workspace = await createTestWorkspace("rea-recorded-core-seam-"); onTestFinished(() => removeTestWorkspace(workspace.root)); diff --git a/tests/boundary/mcp/analysisSummaryDetailMcp.test.ts b/tests/boundary/mcp/analysisSummaryDetailMcp.test.ts index 548619ec4..925740b94 100644 --- a/tests/boundary/mcp/analysisSummaryDetailMcp.test.ts +++ b/tests/boundary/mcp/analysisSummaryDetailMcp.test.ts @@ -137,6 +137,9 @@ it("returns the summary of retained binary layout Evidence", async () => { const client = await connect( true, new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => Promise.resolve(ok(layout)), }), diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index 62ec7d20b..f23295829 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -179,6 +179,9 @@ it("discovers an oversized native dossier by exact metadata and reads its retain it("advertises exact schemas and projects one layout section from retained Evidence", async () => { const layout = analysisViewLayoutFixture(); const service = new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => Promise.resolve(ok(layout)), }); diff --git a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts index d825d96de..bea7c89dd 100644 --- a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts +++ b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts @@ -14,9 +14,16 @@ import { it("advertises exact valid schemas and records inline layout without a binary target", async () => { const value = binaryLayoutFixture(); + let providerClosed = false; const service = new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => Promise.resolve(ok(value)), + close: async () => { + providerClosed = true; + }, }); const session = createTestBinarySession(() => { throw new Error("deep provider must not start"); @@ -29,6 +36,7 @@ it("advertises exact valid schemas and records inline layout without a binary ta onTestFinished(async () => { await client.close(); await server.close(); + expect(providerClosed).toBe(true); await session.close(); }); const [clientTransport, serverTransport] = diff --git a/tests/boundary/mcp/recordedCrashMcp.test.ts b/tests/boundary/mcp/recordedCrashMcp.test.ts index 3b809a296..227ca5bd1 100644 --- a/tests/boundary/mcp/recordedCrashMcp.test.ts +++ b/tests/boundary/mcp/recordedCrashMcp.test.ts @@ -14,9 +14,16 @@ import { it("advertises exact valid schemas and records inline recorded-core evidence without a binary target", async () => { const value = recordedCrashFixture(); + let providerClosed = false; const service = new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: RECORDED_CRASH_TEST_PROVIDER, inspect: () => Promise.resolve(ok(value)), + close: async () => { + providerClosed = true; + }, }); const session = createTestBinarySession(() => { throw new Error("deep provider must not start"); @@ -29,6 +36,7 @@ it("advertises exact valid schemas and records inline recorded-core evidence wit onTestFinished(async () => { await client.close(); await server.close(); + expect(providerClosed).toBe(true); await session.close(); }); const [clientTransport, serverTransport] = diff --git a/tests/boundary/native/PwndbgCoreContext.test.ts b/tests/boundary/native/PwndbgCoreContext.test.ts index 4eb042b6b..e8f007a9b 100644 --- a/tests/boundary/native/PwndbgCoreContext.test.ts +++ b/tests/boundary/native/PwndbgCoreContext.test.ts @@ -43,6 +43,9 @@ it.each(["pre-aborted", "aborted-during-probe"] as const)( diagnostics, options: { signal: controller.signal }, probePath, + runCommand: () => { + throw new Error("No process should exist before cancellation"); + }, }), ).rejects.toBeInstanceOf(AnalysisCancelledError); expect(probes).toBe(scenario === "pre-aborted" ? 0 : 1); diff --git a/tests/fixtures/pwntoolsChangingArtifact.mjs b/tests/fixtures/pwntoolsChangingArtifact.mjs new file mode 100644 index 000000000..9dbc55fe6 --- /dev/null +++ b/tests/fixtures/pwntoolsChangingArtifact.mjs @@ -0,0 +1,40 @@ +import { spawn } from "node:child_process"; +import { existsSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const [mode, path, initial, final, trigger, ready, done, exitCode] = + process.argv.slice(2); + +if (mode === "leader") { + writeFileSync(path, initial); + spawn( + process.execPath, + [ + fileURLToPath(import.meta.url), + "descendant", + path, + initial, + final, + trigger, + ready, + done, + ], + { stdio: "ignore" }, + ); + const timer = setInterval(() => { + if (!existsSync(ready)) return; + clearInterval(timer); + process.stdout.write("stage diagnostic", () => + process.exit(Number(exitCode)), + ); + }, 5); +} else { + writeFileSync(ready, String(process.pid)); + const timer = setInterval(() => { + if (!existsSync(trigger)) return; + writeFileSync(path, final); + writeFileSync(done, "changed"); + clearInterval(timer); + }, 5); + setInterval(() => undefined, 1000); +} diff --git a/tests/process-global/binaryDiagnosticsCliCleanup.test.ts b/tests/process-global/binaryDiagnosticsCliCleanup.test.ts new file mode 100644 index 000000000..d23ef2a49 --- /dev/null +++ b/tests/process-global/binaryDiagnosticsCliCleanup.test.ts @@ -0,0 +1,111 @@ +import { afterEach, expect, it, vi } from "vitest"; + +import { createCli } from "../../src/cli.js"; +import { CLI_COMMANDS } from "../../src/cliCommandNames.js"; +import * as composition from "../../src/composition/binaryDiagnostics.js"; +import { BinaryLayoutService } from "../../src/application/binaryDiagnostics/BinaryLayoutService.js"; +import { RecordedCrashService } from "../../src/application/binaryDiagnostics/RecordedCrashService.js"; +import { AnalysisOutputError } from "../../src/domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js"; +import { err } from "../../src/domain/result.js"; +import { analysisErrorWithCleanupFailure } from "../../src/domain/analysisErrorCleanup.js"; + +const exitCode = process.exitCode; +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = exitCode; +}); + +it.each(["layout", "recorded"] as const)( + "projects the final owned outcome through the public CLI: %s", + async (scenario) => { + const primary = new AnalysisOutputError( + "inspect_binary_layout", + "fixture decoder output overflow", + { + capturedOutput: { + stdout: "partial original output", + stderr: "warning", + truncated: true, + }, + }, + ); + const cleanup = new ProviderCleanupError( + "fixture-decoder", + ["owned-process", "/retained/runtime"], + { reason: "fixture process inspection still denied" }, + ); + const inspectAndClose = vi.fn(async () => + err( + analysisErrorWithCleanupFailure( + primary, + cleanup, + "inspect_binary_layout", + ), + ), + ); + const identity = { + id: "fixture-decoder", + name: "Fixture decoder", + version: "1", + }; + if (scenario === "layout") + vi.spyOn(composition, "createBinaryLayoutService").mockReturnValue( + new BinaryLayoutService({ + identity, + inspect: () => { + throw new Error("CLI must request a terminal inspection"); + }, + inspectAndClose, + }), + ); + else + vi.spyOn(composition, "createRecordedCrashService").mockReturnValue( + new RecordedCrashService({ + identity, + inspect: () => { + throw new Error("CLI must request a terminal inspection"); + }, + inspectAndClose, + }), + ); + + const cli = createCli({}); + let stdout = ""; + await cli.serve( + [ + scenario === "layout" + ? CLI_COMMANDS.inspectBinaryLayout + : CLI_COMMANDS.inspectRecordedCrash, + "/selected/fixture.elf", + "--json", + ], + { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: () => undefined, + }, + ); + const output: unknown = JSON.parse(stdout); + expect(output).toMatchObject({ + code: "cleanup_incomplete", + details: { + cleanup: "incomplete", + resources: ["owned-process", "/retained/runtime"], + captured_output: { + stdout: "partial original output", + stderr: "warning", + truncated: true, + }, + diagnostics: { + primary_error: { code: "unreadable_output" }, + cleanup_error: { code: "cleanup_incomplete" }, + }, + }, + }); + expect(inspectAndClose).toHaveBeenCalledOnce(); + expect(process.exitCode).toBe(1); + }, +); diff --git a/tests/process-global/pwntoolsCleanup.test.ts b/tests/process-global/pwntoolsCleanup.test.ts new file mode 100644 index 000000000..97e35ae66 --- /dev/null +++ b/tests/process-global/pwntoolsCleanup.test.ts @@ -0,0 +1,264 @@ +import { access, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + afterEach, + beforeAll, + beforeEach, + expect, + it, + onTestFinished, +} from "vitest"; + +import { PwntoolsLayoutProvider } from "../../src/native/pwntools/PwntoolsLayoutProvider.js"; +import { PrivateRuntimeRoot } from "../../src/process/PrivateRuntimeRoot.js"; +import { spawnOwnedProviderProcess } from "../../src/process/ProviderProcess.js"; +import { cleanupOwnedProcessGroup } from "../../src/process/ProcessOwnership.js"; +import { prepareProcessOwnershipInspection } from "../../src/process/ProcessOwnershipObservation.js"; +import { stopProviderProcessFixture } from "../fixtures/providerProcess.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../support/workspace/workspaceFixture.js"; + +const platform = Object.getOwnPropertyDescriptor(process, "platform"); +const architecture = Object.getOwnPropertyDescriptor(process, "arch"); +const supportedHost = process.platform !== "win32"; + +beforeAll(() => + supportedHost ? prepareProcessOwnershipInspection() : undefined, +); + +// Only the decoder's availability gate is simulated. The module-owned process +// inspector was created and prepared for the real host before these overrides; +// every fixture launch, output stream, cleanup signal and filesystem is real. +beforeEach(() => { + Object.defineProperty(process, "platform", { value: "linux" }); + Object.defineProperty(process, "arch", { value: "x64" }); +}); + +afterEach(() => { + if (platform !== undefined) + Object.defineProperty(process, "platform", platform); + if (architecture !== undefined) + Object.defineProperty(process, "arch", architecture); +}); + +const fixture = async () => { + const workspace = await createTestWorkspace("rea-pwntools-cleanup-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = join(workspace.root, "selected.elf"); + await writeFile(path, "source-owned-seam-bytes"); + return { workspace, path }; +}; + +it.runIf(supportedHost)( + "retains live worker files, refuses another launch, and retries cleanup on close", + async () => { + const { workspace, path } = await fixture(); + let ownedPath = ""; + let pid: number | undefined; + let launches = 0; + let cleanupAllowed = false; + let releaseFixture: (() => Promise) | undefined; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (input) => { + launches++; + ownedPath = input.cwd ?? ""; + const launched = await spawnOwnedProviderProcess({ + ...input, + arguments: [ + "-e", + 'process.stdout.write("x".repeat(1048577)); setInterval(() => undefined, 1000)', + ], + }); + pid = launched.process.pid; + releaseFixture = () => stopProviderProcessFixture(launched.process); + return { + ...launched, + cleanup: () => + cleanupAllowed + ? cleanupOwnedProcessGroup(launched.ownership) + : Promise.resolve({ + cleaned: false as const, + reason: "fixture process inspection denied", + }), + }; + }, + () => + PrivateRuntimeRoot.create({ parent: workspace.root, prefix: "owned-" }), + ); + onTestFinished(async () => { + cleanupAllowed = true; + await provider.close(); + await releaseFixture?.(); + }); + + const result = await provider.inspect({ path }); + if (result.ok) + throw new Error("Expected output overflow and failed cleanup"); + expect(result.error).toMatchObject({ + cleanupIncomplete: true, + diagnostics: { + primary_error: { + code: "unreadable_output", + details: { captured_output: { truncated: true } }, + }, + cleanup_error: { code: "cleanup_incomplete" }, + }, + }); + if (pid === undefined) + throw new Error("The fixture did not acquire a process"); + const workerPid = pid; + expect(() => process.kill(workerPid, 0)).not.toThrow(); + expect(await readFile(join(ownedPath, "object.snapshot"), "utf8")).toBe( + "source-owned-seam-bytes", + ); + const refused = await provider.inspect({ path }); + if (refused.ok) + throw new Error("Unreleased ownership must block another launch"); + expect(refused.error.cleanupResources).toContain(ownedPath); + expect(launches).toBe(1); + await expect(provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + await expect(access(ownedPath)).resolves.toBeUndefined(); + + cleanupAllowed = true; + await provider.close(); + expect(() => process.kill(workerPid, 0)).toThrow(); + await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(supportedHost)( + "retains a failed root removal and retries it before acquiring another decoder", + async () => { + const { workspace, path } = await fixture(); + let releases = 0; + let launches = 0; + let removalAllowed = false; + const root = await PrivateRuntimeRoot.create({ + parent: workspace.root, + prefix: "owned-", + }); + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (input) => { + launches++; + return spawnOwnedProviderProcess({ + ...input, + arguments: [ + "-e", + 'process.stdout.write("retained diagnostic"); process.exitCode = 2', + ], + }); + }, + async () => ({ + path: root.path, + close: async () => { + releases++; + if (!removalAllowed) throw new Error("fixture root removal denied"); + await root.close(); + }, + }), + ); + onTestFinished(async () => { + removalAllowed = true; + await provider.close(); + await root.close(); + }); + const result = await provider.inspect({ path }); + if (result.ok) + throw new Error("Expected command and root-removal failures"); + expect(result.error).toMatchObject({ + cleanupIncomplete: true, + diagnostics: { + primary_error: { + code: "execution_failure", + details: { diagnostics: { stdout: "retained diagnostic" } }, + }, + cleanup_error: { code: "cleanup_incomplete" }, + }, + }); + const refused = await provider.inspect({ path }); + if (refused.ok) + throw new Error("Retained root cleanup must precede a new launch"); + expect(refused.error.cleanupResources).toEqual([root.path]); + expect(launches).toBe(1); + expect(releases).toBe(2); + removalAllowed = true; + await provider.close(); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(supportedHost)( + "cancels queued inspections promptly and drains the running inspection when closing", + async () => { + const { workspace, path } = await fixture(); + let release: (() => void) | undefined; + let entered: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + entered = resolve; + }); + let launches = 0; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (input) => { + launches++; + entered?.(); + await gate; + return spawnOwnedProviderProcess({ + ...input, + arguments: ["-e", "process.exit(2)"], + }); + }, + () => + PrivateRuntimeRoot.create({ parent: workspace.root, prefix: "owned-" }), + ); + onTestFinished(async () => { + release?.(); + await provider.close(); + }); + const first = provider.inspect({ path }); + await started; + const controller = new AbortController(); + const cancelled = provider.inspect({ path }, { signal: controller.signal }); + controller.abort(); + await expect(cancelled).resolves.toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + const queued = provider.inspect({ path }); + const closing = provider.close(); + const late = provider.inspect({ path }); + release?.(); + const [firstResult, queuedResult, lateResult] = await Promise.all([ + first, + queued, + late, + ]); + if (firstResult.ok) throw new Error("The fixture exits with failure"); + expect(firstResult.error).not.toMatchObject({ cleanupIncomplete: true }); + expect(queuedResult).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + cleanupIncomplete: false, + }, + }); + expect(lateResult).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + cleanupIncomplete: false, + }, + }); + await closing; + expect(launches).toBe(1); + }, +); diff --git a/tests/process-global/pwntoolsCliFinalization.test.ts b/tests/process-global/pwntoolsCliFinalization.test.ts new file mode 100644 index 000000000..4ba9f2c12 --- /dev/null +++ b/tests/process-global/pwntoolsCliFinalization.test.ts @@ -0,0 +1,308 @@ +import { z } from "zod"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + afterEach, + beforeAll, + beforeEach, + expect, + it, + onTestFinished, + vi, +} from "vitest"; + +import { BinaryLayoutService } from "../../src/application/binaryDiagnostics/BinaryLayoutService.js"; +import { createCli } from "../../src/cli.js"; +import * as composition from "../../src/composition/binaryDiagnostics.js"; +import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; +import { PwntoolsLayoutProvider } from "../../src/native/pwntools/PwntoolsLayoutProvider.js"; +import type { PwntoolsLauncher } from "../../src/native/pwntools/PwntoolsDecoder.js"; +import { PWNTOOLS_PROVIDER_IDENTITY } from "../../src/native/pwntools/PwntoolsRelease.js"; +import { PrivateRuntimeRoot } from "../../src/process/PrivateRuntimeRoot.js"; +import { cleanupOwnedProcessGroup } from "../../src/process/ProcessOwnership.js"; +import { prepareProcessOwnershipInspection } from "../../src/process/ProcessOwnershipObservation.js"; +import { spawnOwnedProviderProcess } from "../../src/process/ProviderProcess.js"; +import { binaryLayoutFixture } from "../fixtures/binaryDiagnostics/layout.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../support/workspace/workspaceFixture.js"; + +const supportedHost = process.platform !== "win32"; +const platform = Object.getOwnPropertyDescriptor(process, "platform"); +const architecture = Object.getOwnPropertyDescriptor(process, "arch"); +const exitCode = process.exitCode; + +beforeAll(() => + supportedHost ? prepareProcessOwnershipInspection() : undefined, +); +beforeEach(() => { + // Prepare the real host inspector first; only decoder availability is simulated. + Object.defineProperty(process, "platform", { value: "linux" }); + Object.defineProperty(process, "arch", { value: "x64" }); +}); +afterEach(() => { + if (platform !== undefined) + Object.defineProperty(process, "platform", platform); + if (architecture !== undefined) + Object.defineProperty(process, "arch", architecture); + process.exitCode = exitCode; + vi.restoreAllMocks(); +}); + +const fixture = async (outcome: "decoded" | "failed" = "decoded") => { + const workspace = await createTestWorkspace("rea-pwntools-finalization-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = workspace.path("selected.elf"); + await writeFile(path, Buffer.alloc(128)); + const root = await PrivateRuntimeRoot.create({ + parent: workspace.root, + prefix: "owned-", + }); + const { + artifact: _artifact, + diagnostics: _diagnostics, + ...payload + } = binaryLayoutFixture(path); + const launch: PwntoolsLauncher = async (input) => { + const request = z + .object({ reply_path: z.string() }) + .parse( + JSON.parse(await readFile(join(root.path, "request.json"), "utf8")), + ); + return spawnOwnedProviderProcess({ + ...input, + arguments: [ + "-e", + outcome === "decoded" + ? 'require("node:fs").writeFileSync(process.argv[1], process.argv[2]); process.stdout.write("decoder output")' + : 'process.stdout.write("decoder output"); process.exitCode = 2', + request.reply_path, + JSON.stringify({ + ok: true, + profile: PWNTOOLS_PROVIDER_IDENTITY.version, + value: payload, + }), + ], + }); + }; + return { path, root, launch }; +}; + +const runCli = async ( + provider: PwntoolsLayoutProvider, + path: string, +): Promise => { + vi.spyOn(composition, "createBinaryLayoutService").mockReturnValue( + new BinaryLayoutService(provider), + ); + let stdout = ""; + await createCli({}).serve(["inspect-binary-layout", path, "--json"], { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: () => undefined, + }); + return JSON.parse(stdout); +}; + +it.runIf(supportedHost).each(["decoded", "failed"] as const)( + "returns the original %s outcome after a successful runtime-removal retry through the CLI", + async (outcome) => { + const { path, root, launch } = await fixture(outcome); + let removals = 0; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + launch, + async () => ({ + path: root.path, + close: async () => { + if (++removals === 1) throw new Error("transient removal failure"); + await root.close(); + }, + }), + ); + onTestFinished(() => provider.close()); + const output = await runCli(provider, path); + if (outcome === "decoded") { + expect(output).toMatchObject({ + normalized_result: { + artifact: { path }, + diagnostics: { stdout: "decoder output", truncated: false }, + }, + }); + expect(process.exitCode ?? 0).toBe(0); + } else { + expect(output).toMatchObject({ + code: "execution_failure", + details: { + diagnostics: { + exit_code: 2, + captured_output: { stdout: "decoder output" }, + }, + }, + }); + expect(output).not.toHaveProperty("details.cleanup"); + expect(process.exitCode).toBe(1); + } + expect(removals).toBe(2); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(supportedHost)( + "recovers a completed worker reply after terminal process cleanup succeeds", + async () => { + const { path, root, launch } = await fixture(); + let stops = 0; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (input) => { + const launched = await launch(input); + return { + ...launched, + cleanup: () => + ++stops < 3 + ? Promise.resolve({ + cleaned: false as const, + reason: "transient process inspection failure", + }) + : cleanupOwnedProcessGroup(launched.ownership), + }; + }, + async () => root, + ); + onTestFinished(() => provider.close()); + expect(await runCli(provider, path)).toMatchObject({ + normalized_result: { artifact: { path } }, + }); + expect(process.exitCode ?? 0).toBe(0); + expect(stops).toBe(3); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(supportedHost)( + "reports only the runtime still owned after process retirement", + async () => { + const { path, root, launch } = await fixture("failed"); + let stops = 0; + let removalAllowed = false; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (input) => { + const launched = await launch(input); + return { + ...launched, + cleanup: () => + ++stops === 1 + ? Promise.resolve({ + cleaned: false as const, + reason: "transient process inspection failure", + }) + : cleanupOwnedProcessGroup(launched.ownership), + }; + }, + async () => ({ + path: root.path, + close: async () => { + if (!removalAllowed) throw new Error("runtime removal denied"); + await root.close(); + }, + }), + ); + onTestFinished(async () => { + removalAllowed = true; + await provider.close(); + }); + expect(await runCli(provider, path)).toMatchObject({ + code: "cleanup_incomplete", + details: { + resources: [root.path], + diagnostics: { + primary_error: { code: "execution_failure" }, + cleanup_error: { + code: "cleanup_incomplete", + details: { resources: [root.path] }, + }, + }, + }, + }); + expect(stops).toBe(2); + await expect(access(root.path)).resolves.toBeUndefined(); + }, +); + +it.runIf(supportedHost)( + "preserves the validated report when runtime removal remains denied", + async () => { + const { path, root, launch } = await fixture(); + let removalAllowed = false; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + launch, + async () => ({ + path: root.path, + close: async () => { + if (!removalAllowed) throw new Error("runtime removal denied"); + await root.close(); + }, + }), + ); + onTestFinished(async () => { + removalAllowed = true; + await provider.close(); + }); + expect(await runCli(provider, path)).toMatchObject({ + code: "cleanup_incomplete", + details: { + resources: [root.path], + partial_observation: { + artifact: { path }, + diagnostics: { stdout: "decoder output", truncated: false }, + sections: binaryLayoutFixture(path).sections, + }, + }, + }); + expect(process.exitCode).toBe(1); + await expect(access(root.path)).resolves.toBeUndefined(); + }, +); + +it.runIf(supportedHost)( + "preserves cancellation during successful final cleanup", + async () => { + const { path, root, launch } = await fixture(); + const controller = new AbortController(); + let removals = 0; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + launch, + async () => ({ + path: root.path, + close: async () => { + if (++removals === 1) { + controller.abort(); + throw new Error("transient removal failure"); + } + await root.close(); + }, + }), + ); + onTestFinished(() => provider.close()); + const result = await provider.inspectAndClose( + { path }, + { signal: controller.signal }, + ); + if (result.ok) + throw new Error("Cancelled inspection must not publish success"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + details: { + captured_output: { stdout: "decoder output", truncated: false }, + }, + }); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); diff --git a/tests/process-global/pwntoolsStageRetirement.test.ts b/tests/process-global/pwntoolsStageRetirement.test.ts new file mode 100644 index 000000000..bcdaf858f --- /dev/null +++ b/tests/process-global/pwntoolsStageRetirement.test.ts @@ -0,0 +1,356 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + afterEach, + beforeAll, + beforeEach, + expect, + it, + onTestFinished, + vi, +} from "vitest"; +import { z } from "zod"; +import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; +import { PwntoolsRecordedCrashProvider } from "../../src/native/pwntools/PwntoolsRecordedCrashProvider.js"; +import type { PwntoolsLauncher } from "../../src/native/pwntools/PwntoolsDecoder.js"; +import { + PWNTOOLS_MEMORY_FAILURE_EXIT, + PWNTOOLS_PROVIDER_IDENTITY, +} from "../../src/native/pwntools/PwntoolsRelease.js"; +import { cleanupOwnedProcessGroup } from "../../src/process/ProcessOwnership.js"; +import { prepareProcessOwnershipInspection } from "../../src/process/ProcessOwnershipObservation.js"; +import { spawnOwnedProviderProcess } from "../../src/process/ProviderProcess.js"; +import { PrivateRuntimeRoot } from "../../src/process/PrivateRuntimeRoot.js"; +import * as artifacts from "../../src/artifacts/readStableArtifact.js"; +import { + recordedCrashFixture, + recordedCrashFixtureBytes, + recordedCrashDebuggerFixture, +} from "../fixtures/binaryDiagnostics/recordedCrash.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../support/workspace/workspaceFixture.js"; + +const supportedHost = process.platform !== "win32"; +const platform = Object.getOwnPropertyDescriptor(process, "platform"); +const architecture = Object.getOwnPropertyDescriptor(process, "arch"); +const worker = fileURLToPath( + new URL("../fixtures/pwntoolsChangingArtifact.mjs", import.meta.url), +); +beforeAll(() => + supportedHost ? prepareProcessOwnershipInspection() : undefined, +); +beforeEach(() => { + // Use the actual host inspector; simulate only the decoder availability gate. + Object.defineProperty(process, "platform", { value: "linux" }); + Object.defineProperty(process, "arch", { value: "x64" }); +}); +afterEach(() => { + if (platform !== undefined) + Object.defineProperty(process, "platform", platform); + if (architecture !== undefined) + Object.defineProperty(process, "arch", architecture); + vi.restoreAllMocks(); +}); + +const fixture = async () => { + const workspace = await createTestWorkspace("rea-stage-retirement-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = workspace.path("selected.core"); + const value = recordedCrashFixture(path); + await writeFile(path, recordedCrashFixtureBytes(value)); + const root = await PrivateRuntimeRoot.create({ + parent: workspace.root, + prefix: "owned-", + }); + const venv = workspace.path("venv"); + const plugin = workspace.path("gdbinit.py"); + await mkdir(venv); + await writeFile(plugin, ""); + const environment = { + REA_PWNTOOLS_PYTHON: process.execPath, + REA_PWNDBG_GDB: process.execPath, + REA_PWNDBG_GDBINIT: plugin, + REA_PWNDBG_VENV_PATH: venv, + }; + const { + artifact: _artifact, + diagnostics: _diagnostics, + debugger: _debugger, + decoder_diagnostics: _decoderDiagnostics, + ...payload + } = value; + const reply = JSON.stringify({ + ok: true, + profile: PWNTOOLS_PROVIDER_IDENTITY.version, + value: payload, + }); + const basic: PwntoolsLauncher = async (spawn) => { + await writeFile(join(root.path, "reply.json"), reply); + return spawnOwnedProviderProcess({ + ...spawn, + arguments: ["-e", "process.stdout.write('basic diagnostic')"], + }); + }; + return { path, root, environment, basic }; +}; + +const changingWorker = async ( + spawn: Parameters>[0], + path: string, + contents: { initial: string; final: string; exitCode: number }, + cleanupAllowed: () => boolean = () => true, +) => { + const trigger = join(spawn.cwd ?? "", "change.trigger"); + const ready = join(spawn.cwd ?? "", "descendant.ready"); + const done = join(spawn.cwd ?? "", "change.done"); + const launched = await spawnOwnedProviderProcess({ + ...spawn, + arguments: [ + worker, + "leader", + path, + contents.initial, + contents.final, + trigger, + ready, + done, + String(contents.exitCode), + ], + }); + let stops = 0; + return { + ...launched, + cleanup: async () => { + if (++stops === 1 || !cleanupAllowed()) + return { + cleaned: false as const, + reason: "fixture inspection unavailable", + }; + const descendant = Number(await readFile(ready, "utf8")); + expect(() => process.kill(descendant, 0)).not.toThrow(); + await writeFile(trigger, "change now"); + await expect + .poll(() => + access(done).then( + () => true, + () => false, + ), + ) + .toBe(true); + return cleanupOwnedProcessGroup(launched.ownership); + }, + }; +}; + +it.runIf(supportedHost).each(["decoder", "debugger"] as const)( + "classifies %s failure markers only after surviving descendants stop", + async (stage) => { + const { path, root, environment, basic } = await fixture(); + let launches = 0; + const provider = new PwntoolsRecordedCrashProvider( + environment, + async (spawn) => { + if (stage === "debugger" && ++launches === 1) return basic(spawn); + const marker = join(spawn.cwd ?? "", "resource.failure"); + await writeFile( + join(spawn.cwd ?? "", "limits.json"), + JSON.stringify(recordedCrashDebuggerFixture().reported_limits), + ); + return changingWorker(spawn, marker, { + initial: "M", + final: "!", + exitCode: PWNTOOLS_MEMORY_FAILURE_EXIT, + }); + }, + async () => root, + ); + onTestFinished(() => provider.close()); + const result = await provider.inspectAndClose({ + path, + include_debugger_context: stage === "debugger", + }); + if (result.ok) throw new Error("Expected the original command failure"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "execution_failure", + details: { + diagnostics: { + phase: stage, + resource_failure_marker: { resource: null }, + exit_code: PWNTOOLS_MEMORY_FAILURE_EXIT, + captured_output: { stdout: "stage diagnostic" }, + }, + }, + }); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(supportedHost)( + "publishes debugger replies only after surviving descendants stop", + async () => { + const { path, root, environment, basic } = await fixture(); + let launches = 0; + const context = recordedCrashDebuggerFixture(); + const finalContext = { ...context, pwndbg_version: "after retirement" }; + const provider = new PwntoolsRecordedCrashProvider( + environment, + async (spawn) => { + if (++launches === 1) return basic(spawn); + return changingWorker(spawn, join(spawn.cwd ?? "", "reply.json"), { + initial: JSON.stringify({ ok: true, value: context }), + final: JSON.stringify({ ok: true, value: finalContext }), + exitCode: 0, + }); + }, + async () => root, + ); + onTestFinished(() => provider.close()); + const result = await provider.inspectAndClose({ + path, + include_debugger_context: true, + }); + if (!result.ok) throw result.error; + expect(result.value.debugger).toMatchObject({ + status: "available", + pwndbg_version: "after retirement", + }); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(supportedHost).each([ + { stage: "decoder", outcome: "success" }, + { stage: "decoder", outcome: "failure" }, + { stage: "debugger", outcome: "success" }, + { stage: "debugger", outcome: "failure" }, +] as const)( + "does not read unresolved $stage files after command $outcome", + async ({ stage, outcome }) => { + const { path, root, environment, basic } = await fixture(); + let launches = 0; + let cleanupAllowed = false; + const read = vi.spyOn(artifacts, "readStableArtifact"); + const provider = new PwntoolsRecordedCrashProvider( + environment, + async (spawn) => { + if (stage === "debugger" && ++launches === 1) return basic(spawn); + return changingWorker( + spawn, + join(spawn.cwd ?? "", "resource.failure"), + { + initial: "M", + final: "!", + exitCode: outcome === "success" ? 0 : PWNTOOLS_MEMORY_FAILURE_EXIT, + }, + () => cleanupAllowed, + ); + }, + async () => root, + ); + onTestFinished(async () => { + cleanupAllowed = true; + await provider.close(); + }); + const result = await provider.inspectAndClose({ + path, + include_debugger_context: true, + }); + if (result.ok) throw new Error("Expected unresolved process ownership"); + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("cleanup_incomplete"); + if (stage === "debugger") + expect(projected).toMatchObject({ + details: { + partial_observation: { + artifact: { path }, + debugger: { status: "unavailable", requested: true }, + }, + }, + }); + if (outcome === "failure") + expect(projected).toMatchObject({ + details: { + diagnostics: { + primary_error: { code: "execution_failure" }, + }, + }, + }); + const stagePath = + stage === "debugger" ? join(root.path, "debugger") : root.path; + const paths = read.mock.calls.map(([selected]) => selected); + for (const file of ["reply.json", "resource.failure", "limits.json"]) + expect(paths).not.toContain(join(stagePath, file)); + const descendant = Number( + await readFile(join(stagePath, "descendant.ready"), "utf8"), + ); + expect(() => process.kill(descendant, 0)).not.toThrow(); + await expect(access(root.path)).resolves.toBeUndefined(); + }, +); + +it.runIf(supportedHost)( + "retains a live debugger after output overflow with basic evidence and retryable cleanup", + async () => { + const { path, root, environment, basic } = await fixture(); + let launches = 0; + let pid: number | undefined; + let cleanupAllowed = false; + const provider = new PwntoolsRecordedCrashProvider( + environment, + async (spawn) => { + if (++launches === 1) return basic(spawn); + const launched = await spawnOwnedProviderProcess({ + ...spawn, + arguments: [ + "-e", + 'process.stdout.write("x".repeat(1048577)); setInterval(() => undefined, 1000)', + ], + }); + pid = launched.process.pid; + return { + ...launched, + cleanup: () => + cleanupAllowed + ? cleanupOwnedProcessGroup(launched.ownership) + : Promise.resolve({ + cleaned: false as const, + reason: "fixture inspection unavailable", + }), + }; + }, + async () => root, + ); + onTestFinished(async () => { + cleanupAllowed = true; + await provider.close(); + }); + const result = await provider.inspectAndClose({ + path, + include_debugger_context: true, + }); + if (result.ok) throw new Error("Expected retained output-limit failure"); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "cleanup_incomplete", + details: { + captured_output: { truncated: true }, + diagnostics: { primary_error: { code: "unreadable_output" } }, + partial_observation: { + artifact: { path }, + diagnostics: { stdout: "basic diagnostic" }, + debugger: { status: "unavailable", requested: true }, + }, + }, + }); + if (pid === undefined) throw new Error("Expected a live owned debugger"); + const debuggerPid = pid; + expect(() => process.kill(debuggerPid, 0)).not.toThrow(); + cleanupAllowed = true; + await provider.close(); + expect(() => process.kill(debuggerPid, 0)).toThrow(); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + }, +);