mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(frida): drain admitted operations and honor queued cancellation (#2305)
* fix(frida): drain admitted operations and honor queued cancellation * fix(frida): settle cancelled loads before queued resume * test(frida): share lifecycle fixtures without duplicating assertions * fix(frida): preserve operation admission before session close * test(apple): bound cold archive fixture compilation * fix(frida): retain cancelled script creation evidence * test(release): scope preflight schema to selected jobs * fix(process): report no remaining time after a provider deadline fires Node timers can fire while performance.now() is still a fraction of a millisecond short of the monotonic deadline. ProviderStartupDeadline then reported interruption "timeout" and an aborted signal while remainingMs() returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0 (Hopper and Ghidra startup) could run past an elapsed deadline, and the real-timer test "aborts a bounded wait when its real monotonic deadline expires" failed intermittently in CI (expected 1 to be 0). Once the deadline's own timer has classified a timeout, report zero remaining time. A deterministic regression test fires the timer with the monotonic clock 0.4 ms short of the deadline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(comparison): recheck historical calls-only changes (#2223) * fix(comparison): preserve incomplete function call evidence Keep missing, unknown, partial and unresolved call relationships unknown in function comparisons and reconstruction claims. Aggregate IDA caller and callee coverage without overwriting uncertainty. Validate with 144 focused tests, typecheck/lint, docs checks and test discovery. Real providers were not verified; the full headless IDA session suite requires unavailable Windows native controls. * fix(comparison): recheck historical calls-only changes --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> (cherry picked from commit688defca0f) * fix(ci): isolate Linux Hopper process fixture inventory (cherry picked from commit8cac51e121) * fix(android): make DEX descriptor pattern portable (cherry picked from commit1335bcddc5) * fix: repair the static-check failures on the default branch (#2895) * fix: repair the static-check failures on the default branch The default branch fails its own required `Static checks` job three ways, so every pull request reports that job as failed regardless of its own change. - `npm run format:check` reports `src/domain/functionComparison.test.ts` and `src/ida/IdaOperationRunner.ts`. - `npm run lint` reports a duplicate `import { join } from "node:path"` in `tests/boundary/mcp/ghidraInventoryMcp.test.ts` (lines 4 and 17). - `npm run typecheck` reports `format: "devin"` missing from the client format union in `src/application/SupportedClients.ts`, and an unchecked index in `tests/boundary/cli/clientProfileLifecycle.test.ts`, whose pair array is inferred as `string[][]`. Apply the pinned formatter, drop the duplicate import, list the `"devin"` format that its own definition already uses, and mark the pair array `as const`. Validation: `npm run check:fast` and `npm run format:check` pass. * fix(setup): keep Devin on the JSON configuration dialect --------- Co-authored-by: 吴佳浩 <wujiahao@supremind.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> (cherry picked from commitdf3d96da80) --------- Co-authored-by: morluto <morluto@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Lucien <164857476+dakjdakd@users.noreply.github.com> Co-authored-by: 吴佳浩Alben <35291372+XiaoBinGan@users.noreply.github.com> Co-authored-by: 吴佳浩 <wujiahao@supremind.com> Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
@@ -6,8 +6,8 @@ For remote devices, pass a host with an optional port (`host[:port]`; bracket IP
|
||||
|
||||
MCP exposes `list_frida_devices`, `list_frida_processes`, `start_frida_session`, `load_frida_script`, `resume_frida_session`, `unload_frida_script`, `frida_session_status`, and `close_frida_session`. A spawned process starts paused so scripts can load first. Closing unloads session scripts and detaches. If a spawn remains paused, REA resumes it only after script cleanup or session detach confirms the instrumentation is released; if both cleanup paths fail, the target stays paused and the cleanup error is retained. REA never kills the target. MCP sessions end with the server process. The one-shot CLI command `instrument-with-frida` accepts one inline script or script file, observes messages for a bounded duration, and returns Evidence with the source hash and captured messages.
|
||||
|
||||
Failed startup cleanup retains acquired Frida handles, including a paused spawn that has not attached, for later cleanup retries. Its remote connection stays leased until session detach and target resume complete. CLI commands await provider cleanup before returning; MCP retains these resources for cleanup when its server closes. A failed cleanup remains reported rather than treating an unregistered session as fully released.
|
||||
Failed startup cleanup retains acquired Frida handles, including a paused spawn that has not attached, for later cleanup retries. Its remote connection stays leased until session detach and target resume complete. CLI commands await provider cleanup before returning; MCP retains these resources for cleanup when its server closes. Server shutdown stops admitting new Frida operations and waits for admitted startup, discovery, and queued work before releasing their resources. A failed cleanup remains reported and can be retried by closing the server again, rather than treating an unregistered session as fully released.
|
||||
|
||||
Frida scripts execute inside the selected target with that process's memory, filesystem, and network authority. Message capture is bounded to 16 MiB and reports truncation. Script source is not stored in Evidence; its selected path and SHA-256 digest are. Evidence retains non-authentication target selection such as device or endpoint, mode, program or PID, and spawn arguments. Authentication values remain excluded. Results report the observed session and messages, while unsupported platform metadata remains unknown. A request cancelled before a Frida operation starts has no provider effects. If cancellation arrives during a native Frida call, REA waits for that call to return, then closes any session it created and returns the observed session as a partial observation. Cancellation Evidence separately reports whether a target resume was observed, whether instrumentation cleanup completed, and that final target liveness remains unknown. REA does not kill the target. Remote discovery retains its observations and reports a separate cleanup error if the temporary connection cannot be removed.
|
||||
Frida scripts execute inside the selected target with that process's memory, filesystem, and network authority. Message capture is bounded to 16 MiB and reports truncation. Script source is not stored in Evidence; its selected path and SHA-256 digest are. Evidence retains non-authentication target selection such as device or endpoint, mode, program or PID, and spawn arguments. Authentication values remain excluded. Results report the observed session and messages, while unsupported platform metadata remains unknown. A request cancelled before a Frida operation starts has no provider effects. If cancellation arrives during a native Frida call, REA waits for that call to return, then closes any session it created and returns the observed session as a partial observation. A cancelled script load on an existing session releases only its new script before queued operations can run; the session and previously loaded scripts remain available. Once the provider commits a successful load, later request cancellation does not unload that script; use `unload_frida_script` to release it. If script cleanup fails, the script remains owned and a paused target cannot resume until the pending instrumentation is released. Queued script loads, resumes, and unloads check cancellation before dispatching native effects. Cancellation Evidence separately reports whether a target resume was observed, whether instrumentation cleanup completed, and that final target liveness remains unknown. REA does not kill the target. Remote discovery retains its observations and reports a separate cleanup error if the temporary connection cannot be removed.
|
||||
|
||||
The [Frida Core API](https://frida.re/docs/frida-core-api/) documents device management, sessions, and script loading; the [JavaScript API](https://frida.re/docs/javascript-api/) documents target-side instrumentation. [Luma](https://luma.frida.re/) is Frida's standalone GUI; REA does not automate or control it. `npm run verify:frida` requires a Linux host with the optional Frida binding and uses a short-lived local Node.js fixture created by the verifier. Add `-- --address HOST:PORT --pid PID` to run the separate remote attach lane against a caller-selected target. Mock tests do not establish real device/server compatibility.
|
||||
|
||||
@@ -1023,6 +1023,28 @@ it("serializes script loading against concurrent close and unloads the loaded sc
|
||||
expect(bindings.device.session.isDetached()).toBe(true);
|
||||
});
|
||||
|
||||
it("drains startup when the provider loader initiates shutdown", async () => {
|
||||
const bindings = new FakeDeviceManager();
|
||||
let closing: Promise<void> | undefined;
|
||||
const manager = new FridaInstrumentationManager({
|
||||
deviceManagerLoader: async () => {
|
||||
closing = manager.closeAll();
|
||||
return bindings;
|
||||
},
|
||||
});
|
||||
const started = await manager.startSession({
|
||||
mode: "spawn",
|
||||
deviceId: "local",
|
||||
program: "/tmp/fixture",
|
||||
});
|
||||
if (!started.ok) throw started.error;
|
||||
expect(closing).toBeDefined();
|
||||
await closing;
|
||||
expect(bindings.device.session.isDetached()).toBe(true);
|
||||
expect(bindings.device.resumed).toEqual([512]);
|
||||
expect(manager.status(started.value.sessionId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names the unknown session or script ID it rejects", async () => {
|
||||
const { manager, started } = await startLocalAttachedSession();
|
||||
if (!started.ok) throw started.error;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type {
|
||||
AnalysisError,
|
||||
AnalysisErrorOptions,
|
||||
} from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import type { OptionalProviderLoadFailure } from "../OptionalObservationProviders.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
@@ -60,6 +64,15 @@ export interface FridaScriptObservation {
|
||||
readonly messagesTruncated: boolean;
|
||||
}
|
||||
|
||||
export class FridaScriptLoadCancelledError extends AnalysisCancelledError {
|
||||
constructor(
|
||||
readonly script: FridaScriptObservation,
|
||||
options?: AnalysisErrorOptions,
|
||||
) {
|
||||
super("load_frida_script", options);
|
||||
}
|
||||
}
|
||||
|
||||
export interface FridaSessionStatus extends FridaSessionObservation {
|
||||
/** True only after REA has successfully resumed this spawned target. */
|
||||
readonly targetResumeObserved: boolean;
|
||||
@@ -100,14 +113,20 @@ export interface FridaInstrumentationPort {
|
||||
startSession(
|
||||
input: StartFridaSessionInput,
|
||||
): Promise<Result<FridaSessionObservation, AnalysisError>>;
|
||||
/** Own cancellation and new-script cleanup before returning a final load outcome. */
|
||||
loadScript(
|
||||
sessionId: string,
|
||||
source: FridaScriptSource,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<FridaScriptObservation, AnalysisError>>;
|
||||
resumeSession(sessionId: string): Promise<Result<null, AnalysisError>>;
|
||||
resumeSession(
|
||||
sessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<null, AnalysisError>>;
|
||||
unloadScript(
|
||||
sessionId: string,
|
||||
scriptId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<null, AnalysisError>>;
|
||||
status(sessionId: string): FridaSessionStatus | undefined;
|
||||
closeSession(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { FridaScriptLoadCancelledError } from "./FridaInstrumentationPort.js";
|
||||
import type { FridaInstrumentationPort } from "./FridaInstrumentationPort.js";
|
||||
import { FridaInstrumentationService } from "./FridaInstrumentationService.js";
|
||||
import {
|
||||
@@ -569,3 +570,74 @@ it("cleans up a session and returns typed cancellation after a pending start", a
|
||||
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(closed).toEqual(["00000000-0000-4000-8000-000000000001"]);
|
||||
});
|
||||
|
||||
it("retains a cancelled provider load's source and messages in one-shot Evidence", async () => {
|
||||
const controller = new AbortController();
|
||||
const script = {
|
||||
...createScriptObservation(),
|
||||
messages: [{ type: "send", payload: "cancelled-load" }],
|
||||
};
|
||||
const service = new FridaInstrumentationService(
|
||||
createProvider({
|
||||
async loadScript() {
|
||||
controller.abort(new Error("cancel during native load"));
|
||||
return {
|
||||
ok: false,
|
||||
error: new FridaScriptLoadCancelledError(script, {
|
||||
cause: controller.signal.reason,
|
||||
}),
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = await service.instrument(
|
||||
{
|
||||
mode: "spawn",
|
||||
program: "/fixture",
|
||||
source: { sourceKind: "inline", source: "send('cancelled-load')" },
|
||||
durationMs: 0,
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
if (result.ok) throw new Error("Cancelled instrumentation succeeded");
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
normalized_result: {
|
||||
source_kind: "inline",
|
||||
source_sha256: script.sourceSha256,
|
||||
messages: script.messages,
|
||||
instrumentation_cleanup: "released",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a provider-committed load when cancellation arrives during result delivery", async () => {
|
||||
const controller = new AbortController();
|
||||
let unloads = 0;
|
||||
const script = createScriptObservation();
|
||||
const service = new FridaInstrumentationService(
|
||||
createProvider({
|
||||
loadScript() {
|
||||
const completed = Promise.resolve({ ok: true as const, value: script });
|
||||
void completed.then(() =>
|
||||
controller.abort(new Error("late cancellation")),
|
||||
);
|
||||
return completed;
|
||||
},
|
||||
async unloadScript() {
|
||||
unloads += 1;
|
||||
return { ok: true, value: null };
|
||||
},
|
||||
}),
|
||||
);
|
||||
const loaded = await service.loadScript(
|
||||
createSession().sessionId,
|
||||
{ sourceKind: "inline", source: "send('ready')" },
|
||||
controller.signal,
|
||||
);
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
if (!loaded.ok) throw loaded.error;
|
||||
expect(loaded.value.normalized_result).toMatchObject({
|
||||
script_id: script.scriptId,
|
||||
});
|
||||
expect(unloads).toBe(0);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
AnalysisInputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { FridaScriptLoadCancelledError } from "./FridaInstrumentationPort.js";
|
||||
import type {
|
||||
FridaDeviceSelector,
|
||||
FridaInstrumentationPort,
|
||||
@@ -181,8 +182,17 @@ export class FridaInstrumentationService {
|
||||
async loadScript(
|
||||
sessionId: string,
|
||||
source: FridaScriptSource,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const loaded = await this.#provider.loadScript(sessionId, source);
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError("load_frida_script", {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
);
|
||||
const loaded = await this.#provider.loadScript(sessionId, source, signal);
|
||||
if (!loaded.ok && loaded.error instanceof FridaScriptLoadCancelledError)
|
||||
return err(cancelledScriptEvidence(sessionId, loaded.error));
|
||||
return loaded.ok
|
||||
? ok(
|
||||
fridaEvidence("load_frida_script", scriptOutput(loaded.value), {
|
||||
@@ -192,12 +202,12 @@ export class FridaInstrumentationService {
|
||||
: loaded;
|
||||
}
|
||||
|
||||
resumeSession(sessionId: string) {
|
||||
return this.#provider.resumeSession(sessionId);
|
||||
resumeSession(sessionId: string, signal?: AbortSignal) {
|
||||
return this.#provider.resumeSession(sessionId, signal);
|
||||
}
|
||||
|
||||
unloadScript(sessionId: string, scriptId: string) {
|
||||
return this.#provider.unloadScript(sessionId, scriptId);
|
||||
unloadScript(sessionId: string, scriptId: string, signal?: AbortSignal) {
|
||||
return this.#provider.unloadScript(sessionId, scriptId, signal);
|
||||
}
|
||||
|
||||
status(sessionId: string): FridaSessionStatus | undefined {
|
||||
@@ -243,14 +253,19 @@ export class FridaInstrumentationService {
|
||||
const loaded = await this.#provider.loadScript(
|
||||
started.value.sessionId,
|
||||
input.source,
|
||||
signal,
|
||||
);
|
||||
let scriptObservation: FridaScriptObservation | undefined;
|
||||
if (loaded.ok) scriptObservation = loaded.value;
|
||||
else if (loaded.error instanceof FridaScriptLoadCancelledError)
|
||||
scriptObservation = loaded.error.script;
|
||||
if (signal?.aborted)
|
||||
return this.#cancelSession(
|
||||
"instrument_with_frida",
|
||||
started.value,
|
||||
input,
|
||||
signal.reason,
|
||||
loaded.ok ? loaded.value : undefined,
|
||||
scriptObservation,
|
||||
);
|
||||
if (!loaded.ok) {
|
||||
const closed = await this.#provider.closeSession(started.value.sessionId);
|
||||
@@ -267,6 +282,7 @@ export class FridaInstrumentationService {
|
||||
if (started.value.mode === "spawn") {
|
||||
const resumed = await this.#provider.resumeSession(
|
||||
started.value.sessionId,
|
||||
signal,
|
||||
);
|
||||
if (signal?.aborted)
|
||||
return this.#cancelSession(
|
||||
@@ -462,3 +478,23 @@ const sessionStatusOutput = (status: FridaSessionStatus) => ({
|
||||
messages: status.messages,
|
||||
messages_truncated: status.messagesTruncated,
|
||||
});
|
||||
|
||||
const cancelledScriptEvidence = (
|
||||
sessionId: string,
|
||||
error: FridaScriptLoadCancelledError,
|
||||
): AnalysisCancelledError =>
|
||||
new AnalysisCancelledError(error.operation, {
|
||||
cause: error.cause,
|
||||
cleanup: error.cleanup,
|
||||
partialObservation: fridaEvidence(
|
||||
error.operation,
|
||||
{
|
||||
...scriptOutput(error.script),
|
||||
cleanup_error: error.cleanup?.reason ?? null,
|
||||
script_cleanup: error.cleanupIncomplete
|
||||
? "incomplete_or_unknown"
|
||||
: "released",
|
||||
},
|
||||
{ session_id: sessionId },
|
||||
),
|
||||
});
|
||||
|
||||
@@ -22,9 +22,11 @@ import type {
|
||||
FridaSessionStatus,
|
||||
StartFridaSessionInput,
|
||||
} from "../application/frida/FridaInstrumentationPort.js";
|
||||
import { FridaScriptLoadCancelledError } from "../application/frida/FridaInstrumentationPort.js";
|
||||
import type { OptionalProviderLoadFailure } from "../application/OptionalObservationProviders.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
@@ -58,7 +60,7 @@ interface ManagedScript {
|
||||
readonly name: string;
|
||||
readonly script: Script;
|
||||
readonly onMessage: (message: FridaMessage, data: Buffer | null) => void;
|
||||
released: boolean;
|
||||
state: "active" | "cleanup-pending" | "released";
|
||||
}
|
||||
|
||||
interface ManagedRemoteConnection {
|
||||
@@ -114,6 +116,9 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort {
|
||||
readonly #deviceManagerLoader: () => Promise<DeviceManager>;
|
||||
#deviceManager: DeviceManager | undefined;
|
||||
#availabilityFailure: OptionalProviderLoadFailure | undefined;
|
||||
readonly #operations = new Set<Promise<unknown>>();
|
||||
#closing = false;
|
||||
#closeAllPromise: Promise<void> | undefined;
|
||||
|
||||
constructor(options: FridaInstrumentationManagerOptions = {}) {
|
||||
this.#deviceManager = options.deviceManager;
|
||||
@@ -126,7 +131,13 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort {
|
||||
return this.#availabilityFailure;
|
||||
}
|
||||
|
||||
async listDevices(remote?: FridaRemoteConnection): Promise<
|
||||
listDevices(remote?: FridaRemoteConnection) {
|
||||
return this.#withOperation("list_frida_devices", () =>
|
||||
this.#listDevices(remote),
|
||||
);
|
||||
}
|
||||
|
||||
async #listDevices(remote?: FridaRemoteConnection): Promise<
|
||||
Result<
|
||||
{
|
||||
readonly devices: readonly FridaDeviceObservation[];
|
||||
@@ -176,7 +187,13 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort {
|
||||
}
|
||||
}
|
||||
|
||||
async listProcesses(selector: FridaDeviceSelector): Promise<
|
||||
listProcesses(selector: FridaDeviceSelector) {
|
||||
return this.#withOperation("list_frida_processes", () =>
|
||||
this.#listProcesses(selector),
|
||||
);
|
||||
}
|
||||
|
||||
async #listProcesses(selector: FridaDeviceSelector): Promise<
|
||||
Result<
|
||||
{
|
||||
readonly deviceId: string;
|
||||
@@ -235,7 +252,13 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort {
|
||||
}
|
||||
}
|
||||
|
||||
async startSession(
|
||||
startSession(input: StartFridaSessionInput) {
|
||||
return this.#withOperation("start_frida_session", () =>
|
||||
this.#startSession(input),
|
||||
);
|
||||
}
|
||||
|
||||
async #startSession(
|
||||
input: StartFridaSessionInput,
|
||||
): Promise<Result<FridaSessionObservation, AnalysisError>> {
|
||||
let manager: DeviceManager | undefined;
|
||||
@@ -326,16 +349,25 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort {
|
||||
loadScript(
|
||||
sessionId: string,
|
||||
source: FridaScriptSource,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<FridaScriptObservation, AnalysisError>> {
|
||||
return this.#withSessionLock(sessionId, () =>
|
||||
this.#loadScript(sessionId, source),
|
||||
return this.#withOperation(
|
||||
"load_frida_script",
|
||||
() =>
|
||||
this.#withSessionLock(sessionId, () =>
|
||||
this.#loadScript(sessionId, source, signal),
|
||||
),
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
async #loadScript(
|
||||
sessionId: string,
|
||||
source: FridaScriptSource,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<FridaScriptObservation, AnalysisError>> {
|
||||
if (signal?.aborted)
|
||||
return err(cancelledOperation("load_frida_script", signal));
|
||||
const managed = this.#sessions.get(sessionId);
|
||||
if (managed === undefined || managed.state === "detached")
|
||||
return err(unknownFridaSession("load_frida_script", sessionId));
|
||||
@@ -368,6 +400,8 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort {
|
||||
);
|
||||
}
|
||||
|
||||
if (signal?.aborted)
|
||||
return err(cancelledOperation("load_frida_script", signal));
|
||||
const sourceSha256 = createHash("sha256")
|
||||
.update(scriptSource)
|
||||
.digest("hex");
|
||||
@@ -387,19 +421,36 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort {
|
||||
name: scriptName,
|
||||
script: nativeScript,
|
||||
onMessage,
|
||||
released: false,
|
||||
state: "active",
|
||||
};
|
||||
managed.scripts.set(scriptId, entry);
|
||||
nativeScript.message.connect(onMessage);
|
||||
await nativeScript.load();
|
||||
return ok({
|
||||
if (!signal?.aborted) await nativeScript.load();
|
||||
const observation: FridaScriptObservation = {
|
||||
scriptId,
|
||||
sourceKind: source.sourceKind,
|
||||
sourcePath: source.sourceKind === "file" ? source.path : null,
|
||||
sourceSha256,
|
||||
messages: managed.messagesByScript.get(scriptId)?.toArray() ?? [],
|
||||
messagesTruncated: managed.messagesTruncated,
|
||||
});
|
||||
};
|
||||
if (signal?.aborted) {
|
||||
const cleanupReason = await this.#releaseScript(managed, entry);
|
||||
return err(
|
||||
new FridaScriptLoadCancelledError(observation, {
|
||||
cause: signal.reason,
|
||||
...(cleanupReason === undefined
|
||||
? {}
|
||||
: {
|
||||
cleanup: {
|
||||
reason: cleanupReason,
|
||||
resources: [sessionId, scriptId],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return ok(observation);
|
||||
} catch (cause: unknown) {
|
||||
const entry = managed.scripts.get(scriptId);
|
||||
const cleanupReason =
|
||||
@@ -418,9 +469,21 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort {
|
||||
}
|
||||
}
|
||||
|
||||
resumeSession(sessionId: string): Promise<Result<null, AnalysisError>> {
|
||||
return this.#withSessionLock(sessionId, () =>
|
||||
this.#resumeSession(sessionId),
|
||||
resumeSession(
|
||||
sessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
return this.#withOperation(
|
||||
"resume_frida_session",
|
||||
() =>
|
||||
this.#withSessionLock(sessionId, () =>
|
||||
signal?.aborted
|
||||
? Promise.resolve(
|
||||
err(cancelledOperation("resume_frida_session", signal)),
|
||||
)
|
||||
: this.#resumeSession(sessionId),
|
||||
),
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -438,6 +501,26 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort {
|
||||
"Only a paused process spawned by this session can be resumed.",
|
||||
),
|
||||
);
|
||||
const pendingScripts = [...managed.scripts.values()].filter(
|
||||
(entry) => entry.state === "cleanup-pending" && !entry.script.isDestroyed,
|
||||
);
|
||||
if (pendingScripts.length > 0) {
|
||||
const reason =
|
||||
"Frida script cleanup is incomplete; unload the retained scripts or close the session before resuming.";
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"frida",
|
||||
"resume_frida_session",
|
||||
reason,
|
||||
{
|
||||
cleanup: {
|
||||
reason,
|
||||
resources: pendingScripts.map((entry) => entry.id),
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await managed.device.resume(managed.pid);
|
||||
managed.state = "running";
|
||||
@@ -452,9 +535,19 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort {
|
||||
unloadScript(
|
||||
sessionId: string,
|
||||
scriptId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
return this.#withSessionLock(sessionId, () =>
|
||||
this.#unloadScript(sessionId, scriptId),
|
||||
return this.#withOperation(
|
||||
"unload_frida_script",
|
||||
() =>
|
||||
this.#withSessionLock(sessionId, () =>
|
||||
signal?.aborted
|
||||
? Promise.resolve(
|
||||
err(cancelledOperation("unload_frida_script", signal)),
|
||||
)
|
||||
: this.#unloadScript(sessionId, scriptId),
|
||||
),
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -487,13 +580,14 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort {
|
||||
parentSessionReleased = false,
|
||||
): Promise<string | undefined> {
|
||||
if (parentSessionReleased || entry.script.isDestroyed)
|
||||
entry.released = true;
|
||||
if (!entry.released) {
|
||||
entry.state = "released";
|
||||
if (entry.state !== "released") {
|
||||
entry.state = "cleanup-pending";
|
||||
try {
|
||||
await entry.script.unload();
|
||||
entry.released = true;
|
||||
entry.state = "released";
|
||||
} catch (cause: unknown) {
|
||||
if (entry.script.isDestroyed) entry.released = true;
|
||||
if (entry.script.isDestroyed) entry.state = "released";
|
||||
else return sanitizeError(cause, managed.remote);
|
||||
}
|
||||
}
|
||||
@@ -629,7 +723,17 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort {
|
||||
return ok({ targetResumeObserved });
|
||||
}
|
||||
|
||||
async closeAll(): Promise<void> {
|
||||
closeAll(): Promise<void> {
|
||||
this.#closing = true;
|
||||
this.#closeAllPromise ??= this.#closeAll().catch((cause: unknown) => {
|
||||
this.#closeAllPromise = undefined;
|
||||
throw cause;
|
||||
});
|
||||
return this.#closeAllPromise;
|
||||
}
|
||||
|
||||
async #closeAll(): Promise<void> {
|
||||
await Promise.allSettled(this.#operations);
|
||||
const startupFailures = (
|
||||
await Promise.all(
|
||||
[...this.#pendingStartups].map((pending) =>
|
||||
@@ -720,6 +824,46 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort {
|
||||
}
|
||||
}
|
||||
|
||||
#withOperation<Value>(
|
||||
name: string,
|
||||
operation: () => Promise<Result<Value, AnalysisError>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<Value, AnalysisError>> {
|
||||
if (this.#closing)
|
||||
return Promise.resolve(
|
||||
err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"frida",
|
||||
name,
|
||||
"Frida instrumentation is shutting down.",
|
||||
),
|
||||
),
|
||||
);
|
||||
let start = () => {};
|
||||
const pending = new Promise<Result<Value, AnalysisError>>(
|
||||
(resolve, reject) => {
|
||||
start = () => {
|
||||
try {
|
||||
resolve(
|
||||
signal?.aborted
|
||||
? err(cancelledOperation(name, signal))
|
||||
: operation(),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
reject(cause);
|
||||
}
|
||||
};
|
||||
},
|
||||
);
|
||||
this.#operations.add(pending);
|
||||
const release = () => {
|
||||
this.#operations.delete(pending);
|
||||
};
|
||||
void pending.then(release, release);
|
||||
start();
|
||||
return pending;
|
||||
}
|
||||
|
||||
async #withSessionLock<Value>(
|
||||
sessionId: string,
|
||||
operation: () => Promise<Value>,
|
||||
@@ -1217,6 +1361,18 @@ const providerError = (
|
||||
remote?: FridaRemoteConnection,
|
||||
cleanupReason?: string,
|
||||
): AnalysisError => {
|
||||
if (cause instanceof AnalysisCancelledError)
|
||||
return new AnalysisCancelledError(operation, {
|
||||
cause: cause.cause,
|
||||
...(cleanupReason === undefined
|
||||
? {}
|
||||
: {
|
||||
cleanup: {
|
||||
reason: cleanupReason,
|
||||
resources: ["Frida provider operation"],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const reason = sanitizeError(cause, remote);
|
||||
return new AnalysisCapabilityUnavailableError("frida", operation, reason, {
|
||||
cause: remote === undefined ? cause : new Error(reason),
|
||||
@@ -1234,6 +1390,12 @@ const providerError = (
|
||||
const errorMessage = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
const cancelledOperation = (
|
||||
operation: string,
|
||||
signal: AbortSignal,
|
||||
): AnalysisCancelledError =>
|
||||
new AnalysisCancelledError(operation, { cause: signal.reason });
|
||||
|
||||
interface CapturedMessage {
|
||||
readonly observation: JsonValue;
|
||||
readonly scriptId: string;
|
||||
|
||||
@@ -136,24 +136,35 @@ export const registerFridaTools = (
|
||||
server.registerTool(
|
||||
load.name,
|
||||
toolRegistrationOptions(load),
|
||||
async (input) => {
|
||||
async (input, context: ServerContext) => {
|
||||
const result = await logToolExecution(logger, load.name, () =>
|
||||
input.source_kind === "inline" && input.source !== undefined
|
||||
? service.loadScript(input.session_id, {
|
||||
sourceKind: "inline",
|
||||
source: input.source,
|
||||
})
|
||||
? service.loadScript(
|
||||
input.session_id,
|
||||
{
|
||||
sourceKind: "inline",
|
||||
source: input.source,
|
||||
},
|
||||
context.mcpReq.signal,
|
||||
)
|
||||
: input.source_kind === "file" && input.path !== undefined
|
||||
? service.loadScript(input.session_id, {
|
||||
sourceKind: "file",
|
||||
path: input.path,
|
||||
})
|
||||
? service.loadScript(
|
||||
input.session_id,
|
||||
{
|
||||
sourceKind: "file",
|
||||
path: input.path,
|
||||
},
|
||||
context.mcpReq.signal,
|
||||
)
|
||||
: Promise.resolve({
|
||||
ok: false as const,
|
||||
error: new AnalysisInputError(load.name),
|
||||
}),
|
||||
);
|
||||
if (!result.ok) return delivery.toCallToolResult(result, load);
|
||||
if (!result.ok) {
|
||||
recordPartialEvidence(result.error, recordEvidence);
|
||||
return delivery.toCallToolResult(result, load);
|
||||
}
|
||||
return delivery.toEvidenceToolResult(
|
||||
result.value,
|
||||
load,
|
||||
@@ -164,9 +175,9 @@ export const registerFridaTools = (
|
||||
server.registerTool(
|
||||
resume.name,
|
||||
toolRegistrationOptions(resume),
|
||||
async (input) => {
|
||||
async (input, context: ServerContext) => {
|
||||
const result = await logToolExecution(logger, resume.name, () =>
|
||||
service.resumeSession(input.session_id),
|
||||
service.resumeSession(input.session_id, context.mcpReq.signal),
|
||||
);
|
||||
return result.ok
|
||||
? delivery.toCallToolResult(
|
||||
@@ -179,9 +190,13 @@ export const registerFridaTools = (
|
||||
server.registerTool(
|
||||
unload.name,
|
||||
toolRegistrationOptions(unload),
|
||||
async (input) => {
|
||||
async (input, context: ServerContext) => {
|
||||
const result = await logToolExecution(logger, unload.name, () =>
|
||||
service.unloadScript(input.session_id, input.script_id),
|
||||
service.unloadScript(
|
||||
input.session_id,
|
||||
input.script_id,
|
||||
context.mcpReq.signal,
|
||||
),
|
||||
);
|
||||
return result.ok
|
||||
? delivery.toCallToolResult(
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
Device,
|
||||
DeviceManager,
|
||||
FridaMessage,
|
||||
Script,
|
||||
Session,
|
||||
} from "frida";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js";
|
||||
import { FridaInstrumentationManager } from "../../../src/frida/FridaInstrumentationManager.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
|
||||
const gate = () => {
|
||||
let markEntered = () => {};
|
||||
let release = () => {};
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
markEntered = resolve;
|
||||
});
|
||||
const released = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return {
|
||||
entered,
|
||||
release,
|
||||
async wait() {
|
||||
markEntered();
|
||||
await released;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const nativeFixture = () => {
|
||||
const created: string[] = [];
|
||||
const loaded: string[] = [];
|
||||
const unloaded: string[] = [];
|
||||
const scripts: Script[] = [];
|
||||
const scriptsAtResume: string[][] = [];
|
||||
const controls = {
|
||||
attach: async () => {},
|
||||
create: async () => {},
|
||||
load: async () => {},
|
||||
afterLoad: () => {},
|
||||
discover: async () => {},
|
||||
unloadFailures: 0,
|
||||
detachFailures: 0,
|
||||
detachCalls: 0,
|
||||
resumes: 0,
|
||||
remoteRemovals: 0,
|
||||
removalFailures: 0,
|
||||
};
|
||||
let detached = false;
|
||||
const nativeSession: Session = {
|
||||
pid: 512,
|
||||
detached: { connect() {}, disconnect() {} },
|
||||
isDetached: () => detached,
|
||||
async detach() {
|
||||
controls.detachCalls += 1;
|
||||
if (controls.detachFailures > 0) {
|
||||
controls.detachFailures -= 1;
|
||||
throw new Error("transient detach failure");
|
||||
}
|
||||
detached = true;
|
||||
},
|
||||
async createScript(source) {
|
||||
created.push(source);
|
||||
await controls.create();
|
||||
let destroyed = false;
|
||||
let listener:
|
||||
| ((message: FridaMessage, data: Buffer | null) => void)
|
||||
| undefined;
|
||||
const script: Script = {
|
||||
get isDestroyed() {
|
||||
return destroyed;
|
||||
},
|
||||
message: {
|
||||
connect(handler) {
|
||||
listener = handler;
|
||||
},
|
||||
disconnect() {
|
||||
listener = undefined;
|
||||
},
|
||||
},
|
||||
async load() {
|
||||
await controls.load();
|
||||
loaded.push(source);
|
||||
listener?.({ type: "send", payload: source }, null);
|
||||
controls.afterLoad();
|
||||
},
|
||||
async unload() {
|
||||
unloaded.push(source);
|
||||
if (controls.unloadFailures > 0) {
|
||||
controls.unloadFailures -= 1;
|
||||
throw new Error("transient unload failure");
|
||||
}
|
||||
destroyed = true;
|
||||
},
|
||||
};
|
||||
scripts.push(script);
|
||||
return script;
|
||||
},
|
||||
};
|
||||
const device: Device = {
|
||||
id: "local",
|
||||
name: "Local fixture",
|
||||
type: "local",
|
||||
async enumerateProcesses() {
|
||||
await controls.discover();
|
||||
return [{ pid: 512, name: "fixture" }];
|
||||
},
|
||||
async getProcessByPid() {
|
||||
return { pid: 512, name: "fixture" };
|
||||
},
|
||||
async spawn() {
|
||||
return 512;
|
||||
},
|
||||
async resume() {
|
||||
scriptsAtResume.push(
|
||||
created.filter((_, index) => scripts[index]?.isDestroyed === false),
|
||||
);
|
||||
controls.resumes += 1;
|
||||
},
|
||||
async attach() {
|
||||
await controls.attach();
|
||||
return nativeSession;
|
||||
},
|
||||
};
|
||||
const deviceManager: DeviceManager = {
|
||||
async enumerateDevices() {
|
||||
return [device];
|
||||
},
|
||||
async getDeviceById() {
|
||||
return device;
|
||||
},
|
||||
async addRemoteDevice() {
|
||||
return device;
|
||||
},
|
||||
async removeRemoteDevice() {
|
||||
controls.remoteRemovals += 1;
|
||||
if (controls.removalFailures > 0) {
|
||||
controls.removalFailures -= 1;
|
||||
throw new Error("transient remote removal failure");
|
||||
}
|
||||
},
|
||||
};
|
||||
return {
|
||||
controls,
|
||||
created,
|
||||
loaded,
|
||||
unloaded,
|
||||
scripts,
|
||||
scriptsAtResume,
|
||||
deviceManager,
|
||||
};
|
||||
};
|
||||
|
||||
const connectedFixture = async () => {
|
||||
const native = nativeFixture();
|
||||
const manager = new FridaInstrumentationManager({
|
||||
deviceManager: native.deviceManager,
|
||||
});
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Binary provider must not start");
|
||||
});
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{ fridaInstrumentation: manager },
|
||||
);
|
||||
const client = new Client({ name: "frida-lifecycle", version: "1" });
|
||||
onTestFinished(async () => {
|
||||
native.controls.detachFailures = 0;
|
||||
native.controls.removalFailures = 0;
|
||||
await client.close();
|
||||
await server.close();
|
||||
await manager.closeAll();
|
||||
await session.close();
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const start = async () => {
|
||||
const result = await client.callTool({
|
||||
name: "start_frida_session",
|
||||
arguments: {
|
||||
mode: "spawn",
|
||||
program: "/fixture",
|
||||
device_id: "local",
|
||||
},
|
||||
});
|
||||
return toolContract("start_frida_session").outputSchema.parse(
|
||||
result.structuredContent,
|
||||
).session_id;
|
||||
};
|
||||
const load = (sessionId: string, source: string, signal?: AbortSignal) =>
|
||||
client.callTool(
|
||||
{
|
||||
name: "load_frida_script",
|
||||
arguments: { session_id: sessionId, source_kind: "inline", source },
|
||||
},
|
||||
signal === undefined ? undefined : { signal },
|
||||
);
|
||||
return { ...native, manager, session, server, client, start, load };
|
||||
};
|
||||
|
||||
const scriptLoadFixture = async (stage: "create" | "load" = "load") => {
|
||||
const fixture = await connectedFixture();
|
||||
const id = await fixture.start();
|
||||
const original = await fixture.load(id, "original");
|
||||
const held = gate();
|
||||
onTestFinished(held.release);
|
||||
fixture.controls[stage] = held.wait;
|
||||
return { fixture, id, original, held };
|
||||
};
|
||||
|
||||
const pendingShutdown = async (server: { close(): Promise<void> }) => {
|
||||
let closed = false;
|
||||
const closing = server
|
||||
.close()
|
||||
.then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
)
|
||||
.finally(() => {
|
||||
closed = true;
|
||||
});
|
||||
await setImmediate();
|
||||
expect(closed).toBe(false);
|
||||
return { closing };
|
||||
};
|
||||
|
||||
const retryCancelledScriptUnload = async (
|
||||
fixture: Awaited<ReturnType<typeof connectedFixture>>,
|
||||
sessionId: string,
|
||||
scriptId = fixture.manager.status(sessionId)?.scripts[1]?.scriptId,
|
||||
) => {
|
||||
if (scriptId === undefined)
|
||||
throw new Error("Cancelled script was not retained");
|
||||
const retry = await fixture.client.callTool({
|
||||
name: "unload_frida_script",
|
||||
arguments: {
|
||||
session_id: sessionId,
|
||||
script_id: scriptId,
|
||||
},
|
||||
});
|
||||
expect(retry.isError).not.toBe(true);
|
||||
};
|
||||
|
||||
const retainedScriptObservation = async (
|
||||
fixture: Awaited<ReturnType<typeof connectedFixture>>,
|
||||
sessionId: string,
|
||||
source: string,
|
||||
) => {
|
||||
const response = await fixture.client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
const bundle = evidenceBundleSchema.parse(
|
||||
toolContract("get_evidence_bundle").outputSchema.parse(
|
||||
response.structuredContent,
|
||||
).result,
|
||||
);
|
||||
const sourceSha256 = createHash("sha256").update(source).digest("hex");
|
||||
const records = bundle.records.filter((record) => {
|
||||
const result = record.normalized_result;
|
||||
return (
|
||||
record.operation === "load_frida_script" &&
|
||||
result !== null &&
|
||||
typeof result === "object" &&
|
||||
!Array.isArray(result) &&
|
||||
result.source_sha256 === sourceSha256
|
||||
);
|
||||
});
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]?.parameters).toEqual({ session_id: sessionId });
|
||||
const observation = records[0]?.normalized_result;
|
||||
if (
|
||||
observation === undefined ||
|
||||
observation === null ||
|
||||
typeof observation !== "object" ||
|
||||
Array.isArray(observation)
|
||||
)
|
||||
throw new Error("Missing retained script observation");
|
||||
expect(observation).toMatchObject({
|
||||
script_id: expect.any(String),
|
||||
source_kind: "inline",
|
||||
source_path: null,
|
||||
source_sha256: sourceSha256,
|
||||
messages_truncated: false,
|
||||
});
|
||||
return observation;
|
||||
};
|
||||
|
||||
it.each(["load", "resume", "unload"] as const)(
|
||||
"finishes an earlier MCP %s request before closing its Frida session",
|
||||
async (operation) => {
|
||||
const fixture = await connectedFixture();
|
||||
const id = await fixture.start();
|
||||
const original = await fixture.load(id, "original");
|
||||
const scriptId = toolContract("load_frida_script").outputSchema.parse(
|
||||
original.structuredContent,
|
||||
).normalized_result.script_id;
|
||||
const pending = fixture.client.callTool({
|
||||
name: {
|
||||
load: "load_frida_script",
|
||||
resume: "resume_frida_session",
|
||||
unload: "unload_frida_script",
|
||||
}[operation],
|
||||
arguments: {
|
||||
session_id: id,
|
||||
...(operation === "load"
|
||||
? { source_kind: "inline", source: "last-script" }
|
||||
: {}),
|
||||
...(operation === "unload" ? { script_id: scriptId } : {}),
|
||||
},
|
||||
});
|
||||
const closing = fixture.client.callTool({
|
||||
name: "close_frida_session",
|
||||
arguments: { session_id: id },
|
||||
});
|
||||
expect((await pending).isError).not.toBe(true);
|
||||
expect((await closing).isError).not.toBe(true);
|
||||
expect(fixture.loaded).toEqual(
|
||||
operation === "load" ? ["original", "last-script"] : ["original"],
|
||||
);
|
||||
expect(fixture.unloaded).toEqual(fixture.loaded);
|
||||
expect(fixture.controls.detachCalls).toBe(1);
|
||||
expect(fixture.controls.resumes).toBe(1);
|
||||
expect(fixture.manager.status(id)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["load", "resume", "unload"] as const)(
|
||||
"does not dispatch a cancelled queued Frida %s through MCP",
|
||||
async (operation) => {
|
||||
const { fixture, id, original, held } = await scriptLoadFixture();
|
||||
const scriptId = toolContract("load_frida_script").outputSchema.parse(
|
||||
original.structuredContent,
|
||||
).normalized_result.script_id;
|
||||
const first = fixture.load(id, "blocking");
|
||||
await held.entered;
|
||||
const controller = new AbortController();
|
||||
const cancelled =
|
||||
operation === "load"
|
||||
? fixture.load(id, "cancelled", controller.signal)
|
||||
: fixture.client.callTool(
|
||||
{
|
||||
name:
|
||||
operation === "resume"
|
||||
? "resume_frida_session"
|
||||
: "unload_frida_script",
|
||||
arguments: {
|
||||
session_id: id,
|
||||
...(operation === "unload" ? { script_id: scriptId } : {}),
|
||||
},
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const rejected = expect(cancelled).rejects.toThrow(
|
||||
"cancel queued operation",
|
||||
);
|
||||
await fixture.client.ping();
|
||||
controller.abort(new Error("cancel queued operation"));
|
||||
await fixture.client.ping();
|
||||
await rejected;
|
||||
held.release();
|
||||
await first;
|
||||
await fixture.load(id, "barrier");
|
||||
expect(fixture.created).toEqual(["original", "blocking", "barrier"]);
|
||||
expect(fixture.unloaded).toEqual([]);
|
||||
expect(fixture.controls.resumes).toBe(0);
|
||||
expect(fixture.manager.status(id)?.scripts).toHaveLength(3);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["create", "load"] as const)(
|
||||
"releases only the new script when MCP cancellation arrives during native %s",
|
||||
async (stage) => {
|
||||
const { fixture, id, held } = await scriptLoadFixture(stage);
|
||||
const controller = new AbortController();
|
||||
const cancelled = fixture.load(id, "cancelled-native", controller.signal);
|
||||
const rejected = expect(cancelled).rejects.toThrow(
|
||||
"cancel native operation",
|
||||
);
|
||||
await held.entered;
|
||||
controller.abort(new Error("cancel native operation"));
|
||||
await fixture.client.ping();
|
||||
await rejected;
|
||||
held.release();
|
||||
await fixture.load(id, "barrier");
|
||||
await fixture.client.ping();
|
||||
expect(fixture.unloaded).toEqual(["cancelled-native"]);
|
||||
expect(fixture.loaded).toEqual(
|
||||
stage === "create"
|
||||
? ["original", "barrier"]
|
||||
: ["original", "cancelled-native", "barrier"],
|
||||
);
|
||||
expect(fixture.controls.detachCalls).toBe(0);
|
||||
expect(fixture.controls.resumes).toBe(0);
|
||||
expect(fixture.manager.status(id)?.scripts).toHaveLength(2);
|
||||
const observation = await retainedScriptObservation(
|
||||
fixture,
|
||||
id,
|
||||
"cancelled-native",
|
||||
);
|
||||
expect(observation).toMatchObject({
|
||||
messages:
|
||||
stage === "create"
|
||||
? []
|
||||
: [expect.objectContaining({ payload: "cancelled-native" })],
|
||||
script_cleanup: "released",
|
||||
cleanup_error: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("awaits an admitted attach on server shutdown and retries failed native cleanup", async () => {
|
||||
const fixture = await connectedFixture();
|
||||
const held = gate();
|
||||
onTestFinished(held.release);
|
||||
fixture.controls.attach = held.wait;
|
||||
fixture.controls.detachFailures = 1;
|
||||
const starting = fixture.client
|
||||
.callTool({
|
||||
name: "start_frida_session",
|
||||
arguments: {
|
||||
mode: "attach",
|
||||
pid: 512,
|
||||
device_id: "local",
|
||||
},
|
||||
})
|
||||
.catch(() => undefined);
|
||||
await held.entered;
|
||||
const { closing } = await pendingShutdown(fixture.server);
|
||||
expect(fixture.controls.detachCalls).toBe(0);
|
||||
held.release();
|
||||
expect(await closing).toBeInstanceOf(AggregateError);
|
||||
await starting;
|
||||
expect(fixture.controls.detachCalls).toBe(1);
|
||||
await fixture.server.close();
|
||||
expect(fixture.controls.detachCalls).toBe(2);
|
||||
await fixture.server.close();
|
||||
expect(fixture.controls.detachCalls).toBe(2);
|
||||
const afterClose = await fixture.manager.startSession({
|
||||
mode: "attach",
|
||||
pid: 512,
|
||||
});
|
||||
expect(afterClose).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||||
});
|
||||
});
|
||||
|
||||
it("drains remote discovery before shutdown and retains failed connection cleanup", async () => {
|
||||
const fixture = await connectedFixture();
|
||||
const held = gate();
|
||||
onTestFinished(held.release);
|
||||
fixture.controls.discover = held.wait;
|
||||
fixture.controls.removalFailures = 2;
|
||||
const discovering = fixture.client
|
||||
.callTool({
|
||||
name: "list_frida_processes",
|
||||
arguments: {
|
||||
remote: { address: "127.0.0.1:27042" },
|
||||
},
|
||||
})
|
||||
.catch(() => undefined);
|
||||
await held.entered;
|
||||
const { closing } = await pendingShutdown(fixture.server);
|
||||
held.release();
|
||||
expect(await closing).toBeInstanceOf(AggregateError);
|
||||
await discovering;
|
||||
expect(fixture.controls.remoteRemovals).toBe(2);
|
||||
await fixture.server.close();
|
||||
expect(fixture.controls.remoteRemovals).toBe(3);
|
||||
});
|
||||
|
||||
it.each(["create", "load"] as const)(
|
||||
"retains the identity of a cancelled native %s for an explicit MCP unload retry",
|
||||
async (stage) => {
|
||||
const { fixture, id, held } = await scriptLoadFixture(stage);
|
||||
fixture.controls.unloadFailures = 1;
|
||||
const controller = new AbortController();
|
||||
const cancelled = fixture.load(id, "cancelled-native", controller.signal);
|
||||
const rejected = expect(cancelled).rejects.toThrow(
|
||||
"cancel retained script",
|
||||
);
|
||||
await held.entered;
|
||||
controller.abort(new Error("cancel retained script"));
|
||||
await fixture.client.ping();
|
||||
await rejected;
|
||||
held.release();
|
||||
await fixture.load(id, "barrier");
|
||||
await fixture.client.ping();
|
||||
expect(fixture.manager.status(id)?.scripts).toHaveLength(3);
|
||||
const observation = await retainedScriptObservation(
|
||||
fixture,
|
||||
id,
|
||||
"cancelled-native",
|
||||
);
|
||||
expect(observation).toMatchObject({
|
||||
messages:
|
||||
stage === "create"
|
||||
? []
|
||||
: [expect.objectContaining({ payload: "cancelled-native" })],
|
||||
script_cleanup: "incomplete_or_unknown",
|
||||
cleanup_error: expect.stringContaining("transient unload failure"),
|
||||
});
|
||||
const scriptId = observation.script_id;
|
||||
if (typeof scriptId !== "string")
|
||||
throw new Error("Missing cancelled script identity");
|
||||
expect(fixture.manager.status(id)?.scripts[1]?.scriptId).toBe(scriptId);
|
||||
const resuming = await fixture.client.callTool({
|
||||
name: "resume_frida_session",
|
||||
arguments: { session_id: id },
|
||||
});
|
||||
expect(resuming.isError).toBe(true);
|
||||
expect(fixture.controls.resumes).toBe(0);
|
||||
await retryCancelledScriptUnload(fixture, id, scriptId);
|
||||
expect(fixture.unloaded).toEqual(["cancelled-native", "cancelled-native"]);
|
||||
expect(fixture.loaded).toEqual(
|
||||
stage === "create"
|
||||
? ["original", "barrier"]
|
||||
: ["original", "cancelled-native", "barrier"],
|
||||
);
|
||||
expect(fixture.manager.status(id)?.scripts).toHaveLength(2);
|
||||
expect(fixture.controls.detachCalls).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([0, 1])(
|
||||
"settles cancelled native-load cleanup before a queued resume (unload failures: %i)",
|
||||
async (unloadFailures) => {
|
||||
const { fixture, id, held } = await scriptLoadFixture();
|
||||
fixture.controls.unloadFailures = unloadFailures;
|
||||
const controller = new AbortController();
|
||||
const loading = fixture.load(id, "cancelled-native", controller.signal);
|
||||
const rejected = expect(loading).rejects.toThrow(
|
||||
"cancel before queued resume",
|
||||
);
|
||||
await held.entered;
|
||||
const resume = () =>
|
||||
fixture.client.callTool({
|
||||
name: "resume_frida_session",
|
||||
arguments: { session_id: id },
|
||||
});
|
||||
const resuming = resume();
|
||||
await fixture.client.ping();
|
||||
controller.abort(new Error("cancel before queued resume"));
|
||||
await fixture.client.ping();
|
||||
await rejected;
|
||||
held.release();
|
||||
const resumed = await resuming;
|
||||
await fixture.client.ping();
|
||||
expect(fixture.unloaded).toEqual(["cancelled-native"]);
|
||||
expect(fixture.scriptsAtResume).toEqual(
|
||||
unloadFailures === 0 ? [["original"]] : [],
|
||||
);
|
||||
expect(fixture.controls.detachCalls).toBe(0);
|
||||
expect(fixture.session.exportEvidenceBundle().records).toContainEqual(
|
||||
expect.objectContaining({
|
||||
operation: "load_frida_script",
|
||||
normalized_result: expect.objectContaining({
|
||||
messages: [expect.objectContaining({ payload: "cancelled-native" })],
|
||||
script_cleanup:
|
||||
unloadFailures === 0 ? "released" : "incomplete_or_unknown",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (unloadFailures === 0) {
|
||||
expect(resumed.isError).not.toBe(true);
|
||||
expect(fixture.manager.status(id)?.scripts).toHaveLength(1);
|
||||
return;
|
||||
}
|
||||
expect(resumed.isError).toBe(true);
|
||||
expect(fixture.manager.status(id)?.state).toBe("paused");
|
||||
expect((await resume()).isError).toBe(true);
|
||||
await retryCancelledScriptUnload(fixture, id);
|
||||
expect((await resume()).isError).not.toBe(true);
|
||||
expect(fixture.scriptsAtResume).toEqual([["original"]]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([0, 1, 2, 3, 4, 8])(
|
||||
"keeps MCP load Evidence consistent with queued resume across settlement timing %i",
|
||||
async (microtasks) => {
|
||||
const { fixture, id, held } = await scriptLoadFixture();
|
||||
const controller = new AbortController();
|
||||
fixture.controls.afterLoad = () => {
|
||||
let remaining = microtasks;
|
||||
const abort = () => {
|
||||
if (remaining-- > 0) queueMicrotask(abort);
|
||||
else controller.abort(new Error("cancel around load completion"));
|
||||
};
|
||||
abort();
|
||||
};
|
||||
const loading = fixture.load(id, "settling-native", controller.signal);
|
||||
const observed = loading.catch(() => undefined);
|
||||
await held.entered;
|
||||
const resuming = fixture.client.callTool({
|
||||
name: "resume_frida_session",
|
||||
arguments: { session_id: id },
|
||||
});
|
||||
await fixture.client.ping();
|
||||
held.release();
|
||||
await observed;
|
||||
expect((await resuming).isError).not.toBe(true);
|
||||
fixture.controls.afterLoad = () => {};
|
||||
await fixture.load(id, "barrier");
|
||||
await fixture.client.ping();
|
||||
const records = fixture.session
|
||||
.exportEvidenceBundle()
|
||||
.records.filter(
|
||||
(record) =>
|
||||
record.operation === "load_frida_script" &&
|
||||
JSON.stringify(record.normalized_result).includes("settling-native"),
|
||||
);
|
||||
expect(records).toHaveLength(1);
|
||||
const recorded = records[0]?.normalized_result;
|
||||
if (
|
||||
recorded === null ||
|
||||
typeof recorded !== "object" ||
|
||||
Array.isArray(recorded)
|
||||
)
|
||||
throw new Error("Missing script load Evidence");
|
||||
if (recorded.script_cleanup === "released") {
|
||||
expect(fixture.scriptsAtResume).toEqual([["original"]]);
|
||||
expect(fixture.unloaded).toEqual(["settling-native"]);
|
||||
} else {
|
||||
expect(recorded.script_cleanup).toBeUndefined();
|
||||
expect(fixture.scriptsAtResume).toEqual([
|
||||
["original", "settling-native"],
|
||||
]);
|
||||
expect(fixture.unloaded).toEqual([]);
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user