[GH-ISSUE #2544] fix(frida): cancelled in-flight script load is recorded as successful #859

Closed
opened 2026-10-15 18:29:04 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @coygeek on GitHub (Oct 13, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/2544

Summary

Cancelling an MCP load_frida_script request while native script loading is in progress does not produce the documented cancellation result after loading finishes. The server records ordinary successful load Evidence and keeps the newly loaded script in the existing session. The client receives its own abort error and must inspect the session to discover the completed load. This was reproduced through the production MCP server and Frida manager with controlled native bindings; real target instrumentation was not exercised.

Steps to reproduce

Use the baseline under Runtime or environment with its locked dependencies installed. The following fixture creates a controlled native device and uses the production session, manager, MCP registration and Evidence recorder. It does not start a real process or require a running Frida server.

  1. Save the fixture below as src/application/frida/fridaLoadCancellationReproduction.test.ts.
  2. Run the focused command below from the repository root.
  3. The fixture opens a session, waits until the native load has begun, cancels its MCP request, confirms the cancellation notification reached the server, and releases the load gate. It waits for Evidence registration before checking retained state.
  4. Observe that the cancelled load records normal load Evidence and leaves one script installed with zero unload calls. The fixture then verifies that status plus explicit unload can recover the script, and that an uncancelled load succeeds.
npm run test:focused -- src/application/frida/fridaLoadCancellationReproduction.test.ts
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import type { DeviceManager, Script, Session } from "frida";
import { expect, it, vi } from "vitest";
import { FridaInstrumentationManager } from "../../frida/FridaInstrumentationManager.js";
import { createServer } from "../../server/createServer.js";
import { createTestBinarySession, createDeferred } from "../../../tests/fixtures/binarySession.js";
import type { Evidence } from "../../domain/evidence.js";

it("records normal successful Evidence after cancellation during native load", async () => {
  const entered = createDeferred<void>();
  const release = createDeferred<void>();
  const recorded = createDeferred<Evidence>();
  let unloads = 0;
  let loads = 0;
  let destroyed = false;
  let cancelledNotificationSeen = false;
  const nativeScript = {
    message: { connect() {}, disconnect() {} },
    get isDestroyed() { return destroyed; },
    async load() { loads++; entered.resolve(); await release.promise; },
    async unload() { unloads++; destroyed = true; },
  } as Script;
  const nativeSession = {
    detached: { connect() {}, disconnect() {} },
    isDetached: () => false,
    async createScript() { destroyed = false; return nativeScript; },
    async detach() {},
  } as unknown as Session;
  const device = {
    id: "local", name: "fixture", type: "local",
    async getProcessByPid() { return { pid: 512, name: "fixture" }; },
    async attach() { return nativeSession; },
  };
  const provider = new FridaInstrumentationManager({
    deviceManager: { async getDeviceById() { return device; } } as unknown as DeviceManager,
  });
  const session = createTestBinarySession(() => { throw new Error("Unexpected binary startup"); });
  const originalRecord = session.recordEvidence.bind(session);
  vi.spyOn(session, "recordEvidence").mockImplementation((evidence) => {
    const result = originalRecord(evidence);
    if (evidence.operation === "load_frida_script") recorded.resolve(evidence);
    return result;
  });
  const server = createServer({ kind: "session", session }, { fridaInstrumentation: provider });
  const client = new Client({ name: "verification", version: "1" });
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  await server.connect(serverTransport);
  const onmessage = serverTransport.onmessage!;
  serverTransport.onmessage = (message, extra) => {
    if ("method" in message && message.method === "notifications/cancelled") cancelledNotificationSeen = true;
    onmessage(message, extra);
  };
  await client.connect(clientTransport);
  try {
    const started = await client.callTool({ name: "start_frida_session", arguments: { mode: "attach", device_id: "local", pid: 512 } });
    expect(started.isError).not.toBe(true);
    const id = (started.structuredContent as { session_id: string }).session_id;
    const abort = new AbortController();
    const call = client.callTool({ name: "load_frida_script", arguments: { session_id: id, source_kind: "inline", source: "send(1);" } }, { signal: abort.signal });
    const rejected = expect(call).rejects.toThrow();
    await entered.promise;
    abort.abort(new Error("cancel during native load"));
    await client.ping();
    await rejected;
    expect(cancelledNotificationSeen).toBe(true);
    release.resolve();
    const evidence = await recorded.promise;
    await client.ping();
    expect(evidence.operation).toBe("load_frida_script");
    expect(session.evidenceById(evidence.evidence_id)).toBeDefined();
    expect(loads).toBe(1);
    expect(unloads).toBe(0);
    expect(provider.status(id)?.scripts).toHaveLength(1);
    const scriptId = provider.status(id)!.scripts[0]!.scriptId;
    const status = await client.callTool({ name: "frida_session_status", arguments: { session_id: id } });
    expect(JSON.stringify(status.structuredContent)).toContain(scriptId);
    const removed = await client.callTool({ name: "unload_frida_script", arguments: { session_id: id, script_id: scriptId } });
    expect(removed.isError).not.toBe(true);
    expect(unloads).toBe(1);
    expect(provider.status(id)?.scripts).toHaveLength(0);
    const control = await client.callTool({ name: "load_frida_script", arguments: { session_id: id, source_kind: "inline", source: "send(2);" } });
    expect(control.isError).not.toBe(true);
    expect(provider.status(id)?.scripts).toHaveLength(1);
    expect(unloads).toBe(1);
    console.log("cancel notification received; completed load recorded as success; script retained; explicit unload succeeds; uncancelled control succeeds");
  } finally {
    release.resolve();
    await client.close();
    await server.close();
    await provider.closeAll();
    await session.close();
    vi.restoreAllMocks();
  }
}, 15000);

Expected behavior

For a valid script load cancelled before completion, the server must observe the request cancellation signal and return cancelled rather than ordinary success once the native call settles. Completed observations must describe the cancelled operation and its actual session state. If the client has already stopped waiting, retained observations must still let it account for the completed work. This operation uses a session the caller opened earlier, so cancellation must preserve that existing session and its prior scripts.

Actual behavior

The cancellation notification reached the server before the controlled native load completed. After completion, the production Evidence recorder received ordinary load_frida_script Evidence and the ledger retained it. The manager listed one script and had made zero native unload calls. The client rejected locally on abort. Public session status exposed the retained script, explicit unload removed it, and the uncancelled control load succeeded. The focused fixture passed its assertions describing this incorrect behavior.

cancel notification received; completed load recorded as success; script retained; explicit unload succeeds; uncancelled control succeeds
Test Files  1 passed (1)
Tests  1 passed (1)

Evidence

  • Expected source: docs/mcp-contracts.md:349-352,373-379 requires request-signal propagation and a cancellation result before completion; docs/frida-instrumentation.md:11 describes waiting for native calls and reporting the observed session as partial observation.
  • Failure source: src/server/registerFridaTools.ts:136-161 omits request context and records the completed load as ordinary Evidence; src/application/frida/FridaInstrumentationService.ts:181-193 accepts no signal and returns success after the provider settles; src/frida/FridaInstrumentationManager.ts:326-341,381-402 retains the native script after successful load with no cancellation check. The inline fixture directly observed server cancellation arrival, Evidence registration and retained script state.
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: complete

Controlled native device, session and script bindings make the load timing deterministic. Production schema validation, session lookup, session serialization, script ownership and Evidence recording remain active. This establishes the application and MCP failure; it does not claim real Frida device compatibility or rollback of arbitrary script effects.

Related scope is tracked in the Frida adapter proposal. That proposal belongs to another author and does not report this cancellation failure.

Restoration check

Repeat cancellation after native loading starts and before it completes. After the call settles, the server must produce the documented cancellation outcome and retain accurate completed facts instead of ordinary successful load Evidence. Verify any released or retained script state agrees with the reported observation, and preserve the existing session and earlier scripts. The uncancelled control must still succeed and retain its script. Status and explicit unload must remain usable for resources that are retained. Select cleanup behavior and result representation according to the existing contracts; this report does not require closing the pre-existing session or a new cleanup field.

Runtime or environment

The failure was verified at source baseline a2d8b94dd50dab8d966239dd63ab84718aa8b4b4 and is still present at 8b181716aca2cb467be499cba226abfb5ae19141, package version 6.4.0, Node v26.10.0, npm 11.19.1, Vitest 5.0.3, macOS. The native device, process and script are test doubles; no real Frida session was created.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: A caller-cancelled load on one selected session is recorded as ordinary success and retains its script, so the caller must reconcile target state without the promised cancellation observation. The controlled reproduction is deterministic; frequency on real devices was not measured.
  • Workaround status: available
  • Workaround basis: Public session status exposed the new script ID and explicit unload removed it in the reproduction. This recovers retained instrumentation and keeps the session available; it does not undo earlier script effects or restore the missing cancellation result.
Originally created by @coygeek on GitHub (Oct 13, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/2544 ## Summary Cancelling an MCP `load_frida_script` request while native script loading is in progress does not produce the documented cancellation result after loading finishes. The server records ordinary successful load Evidence and keeps the newly loaded script in the existing session. The client receives its own abort error and must inspect the session to discover the completed load. This was reproduced through the production MCP server and Frida manager with controlled native bindings; real target instrumentation was not exercised. ## Steps to reproduce Use the baseline under Runtime or environment with its locked dependencies installed. The following fixture creates a controlled native device and uses the production session, manager, MCP registration and Evidence recorder. It does not start a real process or require a running Frida server. 1. Save the fixture below as `src/application/frida/fridaLoadCancellationReproduction.test.ts`. 2. Run the focused command below from the repository root. 3. The fixture opens a session, waits until the native load has begun, cancels its MCP request, confirms the cancellation notification reached the server, and releases the load gate. It waits for Evidence registration before checking retained state. 4. Observe that the cancelled load records normal load Evidence and leaves one script installed with zero unload calls. The fixture then verifies that status plus explicit unload can recover the script, and that an uncancelled load succeeds. ```sh npm run test:focused -- src/application/frida/fridaLoadCancellationReproduction.test.ts ``` ```ts import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { DeviceManager, Script, Session } from "frida"; import { expect, it, vi } from "vitest"; import { FridaInstrumentationManager } from "../../frida/FridaInstrumentationManager.js"; import { createServer } from "../../server/createServer.js"; import { createTestBinarySession, createDeferred } from "../../../tests/fixtures/binarySession.js"; import type { Evidence } from "../../domain/evidence.js"; it("records normal successful Evidence after cancellation during native load", async () => { const entered = createDeferred<void>(); const release = createDeferred<void>(); const recorded = createDeferred<Evidence>(); let unloads = 0; let loads = 0; let destroyed = false; let cancelledNotificationSeen = false; const nativeScript = { message: { connect() {}, disconnect() {} }, get isDestroyed() { return destroyed; }, async load() { loads++; entered.resolve(); await release.promise; }, async unload() { unloads++; destroyed = true; }, } as Script; const nativeSession = { detached: { connect() {}, disconnect() {} }, isDetached: () => false, async createScript() { destroyed = false; return nativeScript; }, async detach() {}, } as unknown as Session; const device = { id: "local", name: "fixture", type: "local", async getProcessByPid() { return { pid: 512, name: "fixture" }; }, async attach() { return nativeSession; }, }; const provider = new FridaInstrumentationManager({ deviceManager: { async getDeviceById() { return device; } } as unknown as DeviceManager, }); const session = createTestBinarySession(() => { throw new Error("Unexpected binary startup"); }); const originalRecord = session.recordEvidence.bind(session); vi.spyOn(session, "recordEvidence").mockImplementation((evidence) => { const result = originalRecord(evidence); if (evidence.operation === "load_frida_script") recorded.resolve(evidence); return result; }); const server = createServer({ kind: "session", session }, { fridaInstrumentation: provider }); const client = new Client({ name: "verification", version: "1" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); const onmessage = serverTransport.onmessage!; serverTransport.onmessage = (message, extra) => { if ("method" in message && message.method === "notifications/cancelled") cancelledNotificationSeen = true; onmessage(message, extra); }; await client.connect(clientTransport); try { const started = await client.callTool({ name: "start_frida_session", arguments: { mode: "attach", device_id: "local", pid: 512 } }); expect(started.isError).not.toBe(true); const id = (started.structuredContent as { session_id: string }).session_id; const abort = new AbortController(); const call = client.callTool({ name: "load_frida_script", arguments: { session_id: id, source_kind: "inline", source: "send(1);" } }, { signal: abort.signal }); const rejected = expect(call).rejects.toThrow(); await entered.promise; abort.abort(new Error("cancel during native load")); await client.ping(); await rejected; expect(cancelledNotificationSeen).toBe(true); release.resolve(); const evidence = await recorded.promise; await client.ping(); expect(evidence.operation).toBe("load_frida_script"); expect(session.evidenceById(evidence.evidence_id)).toBeDefined(); expect(loads).toBe(1); expect(unloads).toBe(0); expect(provider.status(id)?.scripts).toHaveLength(1); const scriptId = provider.status(id)!.scripts[0]!.scriptId; const status = await client.callTool({ name: "frida_session_status", arguments: { session_id: id } }); expect(JSON.stringify(status.structuredContent)).toContain(scriptId); const removed = await client.callTool({ name: "unload_frida_script", arguments: { session_id: id, script_id: scriptId } }); expect(removed.isError).not.toBe(true); expect(unloads).toBe(1); expect(provider.status(id)?.scripts).toHaveLength(0); const control = await client.callTool({ name: "load_frida_script", arguments: { session_id: id, source_kind: "inline", source: "send(2);" } }); expect(control.isError).not.toBe(true); expect(provider.status(id)?.scripts).toHaveLength(1); expect(unloads).toBe(1); console.log("cancel notification received; completed load recorded as success; script retained; explicit unload succeeds; uncancelled control succeeds"); } finally { release.resolve(); await client.close(); await server.close(); await provider.closeAll(); await session.close(); vi.restoreAllMocks(); } }, 15000); ``` ## Expected behavior For a valid script load cancelled before completion, the server must observe the request cancellation signal and return `cancelled` rather than ordinary success once the native call settles. Completed observations must describe the cancelled operation and its actual session state. If the client has already stopped waiting, retained observations must still let it account for the completed work. This operation uses a session the caller opened earlier, so cancellation must preserve that existing session and its prior scripts. ## Actual behavior The cancellation notification reached the server before the controlled native load completed. After completion, the production Evidence recorder received ordinary `load_frida_script` Evidence and the ledger retained it. The manager listed one script and had made zero native unload calls. The client rejected locally on abort. Public session status exposed the retained script, explicit unload removed it, and the uncancelled control load succeeded. The focused fixture passed its assertions describing this incorrect behavior. ```text cancel notification received; completed load recorded as success; script retained; explicit unload succeeds; uncancelled control succeeds Test Files 1 passed (1) Tests 1 passed (1) ``` ## Evidence - Expected source: `docs/mcp-contracts.md:349-352,373-379` requires request-signal propagation and a cancellation result before completion; `docs/frida-instrumentation.md:11` describes waiting for native calls and reporting the observed session as partial observation. - Failure source: `src/server/registerFridaTools.ts:136-161` omits request context and records the completed load as ordinary Evidence; `src/application/frida/FridaInstrumentationService.ts:181-193` accepts no signal and returns success after the provider settles; `src/frida/FridaInstrumentationManager.ts:326-341,381-402` retains the native script after successful load with no cancellation check. The inline fixture directly observed server cancellation arrival, Evidence registration and retained script state. - Evidence provenance: observed - Local verification: reproduced - Reproduction completeness: complete Controlled native device, session and script bindings make the load timing deterministic. Production schema validation, session lookup, session serialization, script ownership and Evidence recording remain active. This establishes the application and MCP failure; it does not claim real Frida device compatibility or rollback of arbitrary script effects. Related scope is tracked in [the Frida adapter proposal](https://github.com/morluto/rea/issues/968). That proposal belongs to another author and does not report this cancellation failure. ## Restoration check Repeat cancellation after native loading starts and before it completes. After the call settles, the server must produce the documented cancellation outcome and retain accurate completed facts instead of ordinary successful load Evidence. Verify any released or retained script state agrees with the reported observation, and preserve the existing session and earlier scripts. The uncancelled control must still succeed and retain its script. Status and explicit unload must remain usable for resources that are retained. Select cleanup behavior and result representation according to the existing contracts; this report does not require closing the pre-existing session or a new cleanup field. ## Runtime or environment The failure was verified at source baseline `a2d8b94dd50dab8d966239dd63ab84718aa8b4b4` and is still present at `8b181716aca2cb467be499cba226abfb5ae19141`, package version 6.4.0, Node v26.10.0, npm 11.19.1, Vitest 5.0.3, macOS. The native device, process and script are test doubles; no real Frida session was created. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: A caller-cancelled load on one selected session is recorded as ordinary success and retains its script, so the caller must reconcile target state without the promised cancellation observation. The controlled reproduction is deterministic; frequency on real devices was not measured. - Workaround status: available - Workaround basis: Public session status exposed the new script ID and explicit unload removed it in the reproduction. This recovers retained instrumentation and keeps the session available; it does not undo earlier script effects or restore the missing cancellation result.
bsakaguchi 2026-10-15 18:29:04 +00:00
Author
Owner

@morluto commented on GitHub (Oct 14, 2026):

Fixed on published main in the Frida load cancellation lifecycle. Cancellation is observed across native create/load; the newly created or loaded script is released, and retained script state records cleanup status when unload fails instead of presenting an ordinary successful load. Validation: tests/boundary/mcp/fridaLifecycleMcp.test.ts, “releases only the new script when MCP cancellation arrives during native create/load” and “retains the identity of a cancelled native create/load for an explicit MCP unload retry,” passed in the phase-five public log. These use controlled native bindings and verify MCP/session/Evidence behavior; real Frida instrumentation was not run. Thanks for the controlled reproduction.

<!-- gh-comment-id:6163978783 --> @morluto commented on GitHub (Oct 14, 2026): Fixed on published main in the Frida load cancellation lifecycle. Cancellation is observed across native create/load; the newly created or loaded script is released, and retained script state records cleanup status when unload fails instead of presenting an ordinary successful load. Validation: `tests/boundary/mcp/fridaLifecycleMcp.test.ts`, “releases only the new script when MCP cancellation arrives during native create/load” and “retains the identity of a cancelled native create/load for an explicit MCP unload retry,” passed in the phase-five public log. These use controlled native bindings and verify MCP/session/Evidence behavior; real Frida instrumentation was not run. Thanks for the controlled reproduction.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#859
No description provided.