fix(frida): retain cancellation through final cleanup (#9963)

fix(frida): retain caller cancellation through failed session cleanup
This commit is contained in:
morluto authored and GitHub committed 2026-11-11 17:25:27 +08:00
1 parent fcf8b99a77
commit 2bb266b39b
2 files changed
+91 -24

No files matched your search

@@ -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);
},
);
@@ -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<Result<never, AnalysisError>> {
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);
}