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" }); + }, +);