diff --git a/src/application/frida/FridaInstrumentationService.test.ts b/src/application/frida/FridaInstrumentationService.test.ts index 6f28a834a0..e3447f759a 100644 --- a/src/application/frida/FridaInstrumentationService.test.ts +++ b/src/application/frida/FridaInstrumentationService.test.ts @@ -12,6 +12,7 @@ import { AnalysisCapabilityUnavailableError, AnalysisInputError, } from "../../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import type { Evidence } from "../../domain/evidence.js"; import { ok } from "../../domain/result.js"; @@ -455,10 +456,16 @@ it.each([false, true])( return cleanupFails ? { ok: false, - error: new AnalysisCapabilityUnavailableError( + error: new ProviderAdapterError( "frida", "close_frida_session", - "session cleanup failed", + { + userMessage: "session cleanup failed", + cleanup: { + reason: "session cleanup failed", + resources: [createSession().sessionId], + }, + }, ), } : { ok: true, value: { targetResumeObserved: false } }; @@ -485,10 +492,14 @@ it.each([false, true])( if (result.ok) return; expect(result.error).not.toBeInstanceOf(AnalysisCancelledError); if (cleanupFails) { - expect(result.error).toBeInstanceOf(AnalysisCapabilityUnavailableError); - expect(result.error.cause).toBe(primary); - expect(result.error.diagnostics).toEqual({ - native_status: "resume_rejected", + expect(result.error).toBeInstanceOf(ProviderAdapterError); + expect(result.error.cleanupPrimary).toBe(primary); + expect(result.error.cause).toBeInstanceOf(AggregateError); + expect((result.error.cause as AggregateError).errors[0]).toBe(primary); + expect(result.error.diagnostics).toMatchObject({ + primary_error: { + details: { diagnostics: { native_status: "resume_rejected" } }, + }, }); expect(result.error.cleanup).toMatchObject({ reason: expect.stringContaining("session cleanup failed"), @@ -906,39 +917,56 @@ it("records target selection in Evidence without remote authentication values", expect(parameters).not.toContain(certificate); }); -it("reports completed cleanup when closing the parent session releases a failed script", async () => { - const service = new FridaInstrumentationService( - createProvider({ - async loadScript() { - return { - ok: false, - error: new AnalysisCapabilityUnavailableError( - "frida", - "load_frida_script", - "script load failed", - { - cleanup: { - reason: "script unload failed", - resources: ["script-1"], - }, - }, - ), - }; +it.each([false, true])( + "tracks the failed script owner after parent close (released=%s)", + async (released) => { + const scriptOwner = { cleanupResources: ["script-1"] }; + const failure = new AnalysisCapabilityUnavailableError( + "frida", + "load_frida_script", + "script load failed", + { + cleanup: { + reason: "script unload failed", + resources: ["script-1"], + }, }, - }), - ); + ); + failure.bindCleanupResourceOwner(scriptOwner); + const service = new FridaInstrumentationService( + createProvider({ + async loadScript() { + return { ok: false, error: failure }; + }, + async closeSession() { + // The parent provider owns the script and confirms its release. + if (released) { + scriptOwner.cleanupResources = []; + failure.resolveCleanup(); + } + return { ok: true, value: { targetResumeObserved: false } }; + }, + }), + ); - const result = await service.instrument({ - mode: "spawn", - deviceId: "local", - program: "/tmp/fixture", - source: { sourceKind: "inline", source: "send('ready');" }, - durationMs: 1, - }); + const result = await service.instrument({ + mode: "spawn", + deviceId: "local", + program: "/tmp/fixture", + source: { sourceKind: "inline", source: "send('ready');" }, + durationMs: 1, + }); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error.cleanupIncomplete).toBe(false); -}); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe(failure); + expect(result.error.cleanupIncomplete).toBe(!released); + expect(result.error.cleanupResources).toEqual( + released ? [] : ["script-1"], + ); + } + }, +); it("cleans up a session and returns typed cancellation after a pending start", async () => { let finishStart: (() => void) | undefined; @@ -1080,10 +1108,16 @@ it.each([ return cleanupFails ? { ok: false, - error: new AnalysisCapabilityUnavailableError( + error: new ProviderAdapterError( "frida", "close_frida_session", - "session cleanup failed", + { + userMessage: "session cleanup failed", + cleanup: { + reason: "session cleanup failed", + resources: [createSession().sessionId], + }, + }, ), } : { ok: true, value: { targetResumeObserved: false } }; @@ -1106,10 +1140,14 @@ it.each([ expect(result.error).not.toBeInstanceOf(AnalysisCancelledError); expect(controller.signal.reason).toBe(abortReason); if (cleanupFails) { - expect(result.error).toBeInstanceOf(AnalysisCapabilityUnavailableError); - expect(result.error.cause).toBe(primary); - expect(result.error.diagnostics).toEqual({ - native_status: "compile_rejected", + expect(result.error).toBeInstanceOf(ProviderAdapterError); + expect(result.error.cleanupPrimary).toBe(primary); + expect(result.error.cause).toBeInstanceOf(AggregateError); + expect((result.error.cause as AggregateError).errors[0]).toBe(primary); + expect(result.error.diagnostics).toMatchObject({ + primary_error: { + details: { diagnostics: { native_status: "compile_rejected" } }, + }, }); expect(result.error.cleanup).toMatchObject({ reason: expect.stringContaining("session cleanup failed"), diff --git a/src/doctorRuntime.test.ts b/src/doctorRuntime.test.ts index 6b3851b46c..a3d9a377d3 100644 --- a/src/doctorRuntime.test.ts +++ b/src/doctorRuntime.test.ts @@ -279,10 +279,9 @@ test.each([true, false])( if (cancel) { controller.abort(reason); const result = await pending; - expect(selected.mock.calls[0]?.[0]).toHaveProperty( - "signal", - controller.signal, - ); + const selectedSignal = selected.mock.calls[0]?.[0].signal; + expect(selectedSignal?.aborted).toBe(true); + expect(selectedSignal?.reason).toBe(reason); expect(inspected).not.toHaveBeenCalled(); expect(result.error).toBeInstanceOf(AnalysisCancelledError); expect((result.error as Error).cause).toBe(reason); diff --git a/src/ida/IdaContainment.test.ts b/src/ida/IdaContainment.test.ts index f81d7b9cdf..53b0887cd9 100644 --- a/src/ida/IdaContainment.test.ts +++ b/src/ida/IdaContainment.test.ts @@ -213,6 +213,7 @@ describe("headless IDA containing procedure", () => { procedure: { address: "0x1000", name: "main", + symbol: null, classification: null, body: { available: false, diff --git a/src/ida/IdaMcpConnection.test.ts b/src/ida/IdaMcpConnection.test.ts index 46ea349fb0..3bd60cf59a 100644 --- a/src/ida/IdaMcpConnection.test.ts +++ b/src/ida/IdaMcpConnection.test.ts @@ -322,12 +322,13 @@ it("bounds a stalled health poll by the remaining readiness deadline", async () expect(pendingCalls).toBe(2); expect(failure).toBeInstanceOf(AnalysisTimeoutError); expect(failure).toMatchObject({ - operation: "server_health: headless worker still busy in config_json_get", + operation: "server_health", timeoutMs, userMessage: expect.stringContaining("800 ms"), remediationAction: expect.stringContaining("timeoutMs"), cause: busy, diagnostics: { + busy_tool: "config_json_get", health_request_operation: "server_health", health_request_timeout_ms: 250, }, diff --git a/src/process/ProcessOwnership.part3.test.ts b/src/process/ProcessOwnership.part3.test.ts index 6707deb12c..1ab9f3908b 100644 --- a/src/process/ProcessOwnership.part3.test.ts +++ b/src/process/ProcessOwnership.part3.test.ts @@ -248,7 +248,7 @@ describe("identityless lineage refresh follows tree membership", () => { expect(environment).toHaveBeenCalledWith(102); }); - it("rejects a cached foreign row after an ancestor joins the launcher", async () => { + it("keeps a tokenless cached row unverified after its ancestor joins the launcher", async () => { let outsiderReads = 0; const environment = vi.fn((pid: number) => { if (pid === 101 || pid === 103) @@ -283,7 +283,8 @@ describe("identityless lineage refresh follows tree membership", () => { }), ).resolves.toMatchObject({ status: "unavailable", - reason: "process lineage contains an unowned or PID-reused process", + reason: + "REA_PROCESS_RUN_ID was absent from the readable environment for PID 102; process lineage ownership remains unverified", }); expect(outsiderReads).toBe(2); expect(environment.mock.calls.filter(([pid]) => pid === 50)).toHaveLength( @@ -291,7 +292,7 @@ describe("identityless lineage refresh follows tree membership", () => { ); }); - it("re-reads a cached descendant when its relation is unchanged", async () => { + it("re-reads a cached descendant and keeps its missing token unverified", async () => { const { environment, reads } = environmentForReplacementReads((read) => read === 1 ? "run-token" : undefined, ); @@ -309,7 +310,8 @@ describe("identityless lineage refresh follows tree membership", () => { }), ).resolves.toMatchObject({ status: "unavailable", - reason: "process lineage contains an unowned or PID-reused process", + reason: + "REA_PROCESS_RUN_ID was absent from the readable environment for PID 102; process lineage ownership remains unverified", }); expect(reads()).toBe(2); }); @@ -399,7 +401,7 @@ describe("identityless lineage refresh rechecks the launcher", () => { }); }); - it("rejects a refresh whose launcher PID is no longer owned", async () => { + it("keeps a refreshed launcher with a missing ownership token unverified", async () => { let launcherReads = 0; const environment = vi.fn((pid: number) => { if (pid === 101) @@ -425,14 +427,15 @@ describe("identityless lineage refresh rechecks the launcher", () => { }), ).resolves.toMatchObject({ status: "unavailable", - reason: "process lineage contains an unowned or PID-reused process", + reason: + "REA_PROCESS_RUN_ID was absent from the readable environment for PID 100; process lineage ownership remains unverified", }); expect(launcherReads).toBe(2); }); }); describe("identityless lineage refresh rechecks an initial descendant", () => { - it("rejects a reused PID after an earlier descendant token matched", async () => { + it("keeps a descendant with a missing token unverified after an earlier match", async () => { let reads = 0; const environment = vi.fn((pid: number) => { if (pid === 101) @@ -459,7 +462,8 @@ describe("identityless lineage refresh rechecks an initial descendant", () => { }), ).resolves.toMatchObject({ status: "unavailable", - reason: "process lineage contains an unowned or PID-reused process", + reason: + "REA_PROCESS_RUN_ID was absent from the readable environment for PID 102; process lineage ownership remains unverified", }); expect(reads).toBe(2); });