diff --git a/src/application/frida/FridaInstrumentationService.test.ts b/src/application/frida/FridaInstrumentationService.test.ts index 35b69d9076..f8b53d213f 100644 --- a/src/application/frida/FridaInstrumentationService.test.ts +++ b/src/application/frida/FridaInstrumentationService.test.ts @@ -68,6 +68,49 @@ const createProvider = ( ...overrides, }); +it.each(["startSession", "instrument"] as const)( + "retains cancellation beside a physical failed %s handshake", + async (method) => { + const controller = new AbortController(); + const reason = { caller: "cancelled during failed start" }; + const primary = new AnalysisCapabilityUnavailableError( + "frida", + "start_frida_session", + "native attach failed", + ); + let closes = 0; + const service = new FridaInstrumentationService( + createProvider({ + async startSession() { + controller.abort(reason); + return { ok: false, error: primary }; + }, + async closeSession() { + closes++; + return { ok: true, value: { targetResumeObserved: false } }; + }, + }), + ); + const input = { mode: "attach" as const, deviceId: "local", pid: 10 }; + const result = + method === "startSession" + ? await service.startSession(input, controller.signal) + : await service.instrument( + { + ...input, + source: { sourceKind: "inline", source: "unused" }, + durationMs: 0, + }, + controller.signal, + ); + if (result.ok) throw new Error("Expected failed start"); + expect(result.error).toBe(primary); + expect(primary.secondaryFailures).toHaveLength(1); + expect(primary.secondaryFailures[0]?.cause).toBe(reason); + expect(closes).toBe(0); + }, +); + it("keeps default Frida status identity and messages and refreshes every summary", () => { let reads = 0; const provider = createProvider({ @@ -514,6 +557,8 @@ it.each([false, true])( } else { expect(result.error).toBe(primary); } + expect(primary.secondaryFailures).toHaveLength(1); + expect(primary.secondaryFailures[0]?.cause).toBe(abortReason); expect(closes).toBe(1); }, ); @@ -1162,6 +1207,8 @@ it.each([ } else { expect(result.error).toBe(primary); } + expect(primary.secondaryFailures).toHaveLength(1); + expect(primary.secondaryFailures[0]?.cause).toBe(abortReason); expect(closes).toBe(1); }, ); diff --git a/src/application/frida/FridaInstrumentationService.ts b/src/application/frida/FridaInstrumentationService.ts index e6ece825d0..e360064310 100644 --- a/src/application/frida/FridaInstrumentationService.ts +++ b/src/application/frida/FridaInstrumentationService.ts @@ -8,6 +8,7 @@ import { type AnalysisError, } from "../../domain/analysisErrorBase.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import { analysisErrorWithCancellation } from "../../domain/analysisErrorCancellation.js"; import { AnalysisAccessDeniedError, AnalysisCancelledError, @@ -165,6 +166,13 @@ export class FridaInstrumentationService { ), ); const started = await this.#provider.startSession(input); + if (!started.ok && signal?.aborted) + return err( + analysisErrorWithCancellation( + started.error, + new AnalysisCancelledError(operation, { cause: signal.reason }), + ), + ); if (!started.ok || !signal?.aborted) return started; return this.#cancelSession(operation, started.value, input, signal.reason); } @@ -323,18 +331,12 @@ export class FridaInstrumentationService { ? loaded.error : undefined, ); - if (!loaded.ok) { - const closed = await this.#provider.closeSession(started.value.sessionId); - return closed.ok - ? err(resolvedAnalysisError(loaded.error)) - : err( - analysisErrorWithCleanupFailure( - loaded.error, - closed.error, - "instrument_with_frida", - ).bindCleanupDependencies([loaded.error, closed.error]), - ); - } + if (!loaded.ok) + return this.#closeFailedInstrumentation( + started.value.sessionId, + loaded.error, + signal, + ); if (signal?.aborted) return this.#cancelSession( "instrument_with_frida", @@ -351,20 +353,12 @@ export class FridaInstrumentationService { if ( !resumed.ok && !(signal?.aborted && resumed.error instanceof AnalysisCancelledError) - ) { - const closed = await this.#provider.closeSession( + ) + return this.#closeFailedInstrumentation( started.value.sessionId, + resumed.error, + signal, ); - return closed.ok - ? resumed - : err( - analysisErrorWithCleanupFailure( - resumed.error, - closed.error, - "instrument_with_frida", - ).bindCleanupDependencies([resumed.error, closed.error]), - ); - } if (signal?.aborted) return this.#cancelSession( "instrument_with_frida", @@ -431,6 +425,32 @@ export class FridaInstrumentationService { }); } + /** Release the failed experiment while retaining its physical and caller outcomes. */ + async #closeFailedInstrumentation( + sessionId: string, + primary: AnalysisError, + signal: AbortSignal | undefined, + ): Promise> { + const closed = await this.#provider.closeSession(sessionId); + const failure = closed.ok + ? resolvedAnalysisError(primary) + : analysisErrorWithCleanupFailure( + primary, + closed.error, + "instrument_with_frida", + ).bindCleanupDependencies([primary, closed.error]); + return err( + signal?.aborted + ? analysisErrorWithCancellation( + failure, + new AnalysisCancelledError("instrument_with_frida", { + cause: signal.reason, + }), + ) + : failure, + ); + } + closeSession(sessionId: string) { return this.#provider.closeSession(sessionId); }