[GH-ISSUE #2535] fix(frida): cancelled queued session requests still dispatch native operations #851

Closed
opened 2026-10-15 18:28:59 +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/2535

Summary

Cancelling an MCP script load, process resume or script unload while it waits behind another operation on the same Frida session does not stop the queued native operation. The client receives its cancellation error, but REA later loads the cancelled script, resumes the paused process or unloads the selected script. A production MCP and session-manager replay with controlled Frida objects reproduced all three cases.

Steps to reproduce

Use the source baseline and locked dependencies stated under Runtime or environment. Save this fixture as src/application/frida/fridaQueuedCancellation.test.ts. It supplies native Frida doubles, so no executable is launched or attached. Each case starts a paused session, loads an original script, holds a second load, queues a valid operation, cancels that request, and then releases the queue. The passing assertions record the current failure.

import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import type { DeviceManager } from "frida";
import { expect, it } from "vitest";
import { FridaInstrumentationManager } from "../../frida/FridaInstrumentationManager.js";
import { createServer } from "../../server/createServer.js";
import { createTestBinarySession } from "../../../tests/fixtures/binarySession.js";

const latch = () => {
  let release!: () => void;
  const promise = new Promise<void>((resolve) => { release = resolve; });
  return { promise, release };
};
const nativeSignal = () => {
  let callback: ((...args: unknown[]) => void) | undefined;
  return {
    connect(handler: (...args: unknown[]) => void) { callback = handler; },
    disconnect(handler: (...args: unknown[]) => void) {
      if (callback === handler) callback = undefined;
    },
    emit(...args: unknown[]) { callback?.(...args); },
  };
};

it.each(["load", "resume", "unload"] as const)(
  "dispatches a cancelled queued %s request",
  async (operation) => {
    const created: string[] = [], loaded: string[] = [], unloaded: string[] = [];
    const held = latch(), entered = latch();
    let resumes = 0, detached = false;
    const nativeSession = {
      pid: 512,
      detached: nativeSignal(),
      isDetached: () => detached,
      async createScript(source: string) {
        created.push(source);
        return {
          message: nativeSignal(), isDestroyed: false,
          async load() {
            if (source === "blocking") { entered.release(); await held.promise; }
            loaded.push(source);
          },
          async unload() { unloaded.push(source); this.isDestroyed = true; },
        };
      },
      async detach() { detached = true; this.detached.emit(); },
    };
    const device = {
      id: "local", name: "Local System", type: "local",
      async spawn() { return 512; },
      async attach() { return nativeSession; },
      async resume() { resumes += 1; },
    };
    const manager = new FridaInstrumentationManager({
      deviceManager: { async getDeviceById() { return device; } } as unknown as DeviceManager,
    });
    const binary = createTestBinarySession(() => { throw new Error("Unexpected binary provider"); });
    const server = createServer({ kind: "session", session: binary }, { fridaInstrumentation: manager });
    const client = new Client({ name: "cancellation-test", version: "1" });
    const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
    try {
      await server.connect(serverTransport);
      await client.connect(clientTransport);
      const start = await client.callTool({
        name: "start_frida_session",
        arguments: { mode: "spawn", program: "/fixture", device_id: "local" },
      });
      const id = (start.structuredContent as { session_id: string }).session_id;
      const load = (source: string) => client.callTool({
        name: "load_frida_script",
        arguments: { session_id: id, source_kind: "inline", source },
      });
      const original = await load("original");
      const scriptId = (original.structuredContent as {
        normalized_result: { script_id: string };
      }).normalized_result.script_id;
      const blocking = load("blocking");
      await entered.promise;
      const controller = new AbortController();
      const request = operation === "load"
        ? { name: "load_frida_script", arguments: { session_id: id, source_kind: "inline", source: "cancelled" } }
        : operation === "resume"
          ? { name: "resume_frida_session", arguments: { session_id: id } }
          : { name: "unload_frida_script", arguments: { session_id: id, script_id: scriptId } };
      const cancelled = client.callTool(request, { signal: controller.signal });
      const rejected = expect(cancelled).rejects.toThrow("cancel queued operation");
      await client.ping();
      expect(created).toEqual(["original", "blocking"]);
      expect(resumes).toBe(0);
      expect(unloaded).toEqual([]);
      controller.abort(new Error("cancel queued operation"));
      await client.ping();
      await rejected;
      held.release();
      await blocking;
      await load("barrier");
      expect(loaded).toEqual(operation === "load"
        ? ["original", "blocking", "cancelled", "barrier"]
        : ["original", "blocking", "barrier"]);
      expect(unloaded).toEqual(operation === "unload" ? ["original"] : []);
      expect(resumes).toBe(operation === "resume" ? 1 : 0);
      expect(manager.status(id)?.state).toBe(operation === "resume" ? "running" : "paused");
      console.log(JSON.stringify({ operation, created, loaded, unloaded, resumes, state: manager.status(id)?.state }));
    } finally {
      held.release();
      await client.close();
      await server.close();
      await manager.closeAll();
      await binary.close();
    }
  },
);

Run the focused test from the repository root with VITEST_CACHE_DIR set to a writable disposable directory:

npm run test:focused -- src/application/frida/fridaQueuedCancellation.test.ts

Expected behavior

A cancelled request that has not started its native Frida operation must cause no native provider effects. After releasing the queue, the cancelled load must not create or load its script, the cancelled resume must leave the selected spawn paused, and the cancelled unload must leave the original script loaded. The uncancelled loads must still complete.

Actual behavior

The independent replay passed all three assertions of the failure. For load, both the creation and loading lists contained ["original", "blocking", "cancelled", "barrier"]. For resume, the native resume counter reached 1 and the manager state changed to running. For unload, the native unload list contained ["original"]. Each client call rejected with cancel queued operation before the blocking load was released. Native counters were clear before cancellation, and an uncancelled barrier load completed before the final assertions.

Evidence

  • Expected source: docs/frida-instrumentation.md:7 lists the persistent MCP operations; docs/frida-instrumentation.md:11 promises no provider effects for a request cancelled before a Frida operation starts; docs/mcp-contracts.md:349 promises request-signal propagation to providers.
  • Failure source: The inline replay observes native dispatch after cancellation through the public MCP tools. src/server/registerFridaTools.ts:136-193, src/application/frida/FridaInstrumentationService.ts:181-201 and src/application/frida/FridaInstrumentationPort.ts:103-111 omit the request signal for these operations. src/frida/FridaInstrumentationManager.ts:326-480 queues the operations, and its queue helper at line 721 invokes them after waiting without a cancellation check.
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: complete

The inline fixture ran with exit status 0, one test file passed and three tests passed. These tests observe the defect. Production request validation, the MCP transport, the application service and the session manager ran unchanged. The native device, session and scripts were controlled doubles; this run does not establish real-device compatibility or actual target-side script effects.

Read-only issue searches found the Frida roadmap, which discusses instrumentation scope and discovery rather than this cancellation failure. No matching report was found in the tool-specific searches.

Restoration check

Repeat the same queued cancellation sequence and require no native create/load call for cancelled, no native resume call, and no unload call for original. The load case must contain only ["original", "blocking", "barrier"] in both creation and loading lists. The resume case must remain paused with a resume counter of 0. The unload case must retain the original script with an empty native unload list. Keep the original, blocking and barrier loads successful, and separately verify that the same valid load, resume and unload operations succeed when their requests are not cancelled. This distinguishes cancellation handling from disabling the operations.

Runtime or environment

Failure verified at a2d8b94dd50dab8d966239dd63ab84718aa8b4b4 and still present at 8b181716aca2cb467be499cba226abfb5ae19141; rea-agents 6.4.0; Node v26.10.0; npm 11.19.1; Vitest 5.0.3; linked in-memory MCP transport and controlled native Frida objects with the installed locked dependencies. No native target or Frida server was used.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: Explicitly cancelled queued requests still change the session through script loading, process resume or script unload. The replay establishes bounded lifecycle failures for these inputs; it does not establish severe target damage or a failure for every session operation.
  • Workaround status: unverified
  • Workaround basis: Unloading or closing can release instrumentation, but it cannot promise reversal of a script's prior effects, a process resume or an unloaded script's runtime state. No workaround preserving the no-effects cancellation contract was verified.
Originally created by @coygeek on GitHub (Oct 13, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/2535 ## Summary Cancelling an MCP script load, process resume or script unload while it waits behind another operation on the same Frida session does not stop the queued native operation. The client receives its cancellation error, but REA later loads the cancelled script, resumes the paused process or unloads the selected script. A production MCP and session-manager replay with controlled Frida objects reproduced all three cases. ## Steps to reproduce Use the source baseline and locked dependencies stated under Runtime or environment. Save this fixture as `src/application/frida/fridaQueuedCancellation.test.ts`. It supplies native Frida doubles, so no executable is launched or attached. Each case starts a paused session, loads an original script, holds a second load, queues a valid operation, cancels that request, and then releases the queue. The passing assertions record the current failure. ```ts import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { DeviceManager } from "frida"; import { expect, it } from "vitest"; import { FridaInstrumentationManager } from "../../frida/FridaInstrumentationManager.js"; import { createServer } from "../../server/createServer.js"; import { createTestBinarySession } from "../../../tests/fixtures/binarySession.js"; const latch = () => { let release!: () => void; const promise = new Promise<void>((resolve) => { release = resolve; }); return { promise, release }; }; const nativeSignal = () => { let callback: ((...args: unknown[]) => void) | undefined; return { connect(handler: (...args: unknown[]) => void) { callback = handler; }, disconnect(handler: (...args: unknown[]) => void) { if (callback === handler) callback = undefined; }, emit(...args: unknown[]) { callback?.(...args); }, }; }; it.each(["load", "resume", "unload"] as const)( "dispatches a cancelled queued %s request", async (operation) => { const created: string[] = [], loaded: string[] = [], unloaded: string[] = []; const held = latch(), entered = latch(); let resumes = 0, detached = false; const nativeSession = { pid: 512, detached: nativeSignal(), isDetached: () => detached, async createScript(source: string) { created.push(source); return { message: nativeSignal(), isDestroyed: false, async load() { if (source === "blocking") { entered.release(); await held.promise; } loaded.push(source); }, async unload() { unloaded.push(source); this.isDestroyed = true; }, }; }, async detach() { detached = true; this.detached.emit(); }, }; const device = { id: "local", name: "Local System", type: "local", async spawn() { return 512; }, async attach() { return nativeSession; }, async resume() { resumes += 1; }, }; const manager = new FridaInstrumentationManager({ deviceManager: { async getDeviceById() { return device; } } as unknown as DeviceManager, }); const binary = createTestBinarySession(() => { throw new Error("Unexpected binary provider"); }); const server = createServer({ kind: "session", session: binary }, { fridaInstrumentation: manager }); const client = new Client({ name: "cancellation-test", version: "1" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); try { await server.connect(serverTransport); await client.connect(clientTransport); const start = await client.callTool({ name: "start_frida_session", arguments: { mode: "spawn", program: "/fixture", device_id: "local" }, }); const id = (start.structuredContent as { session_id: string }).session_id; const load = (source: string) => client.callTool({ name: "load_frida_script", arguments: { session_id: id, source_kind: "inline", source }, }); const original = await load("original"); const scriptId = (original.structuredContent as { normalized_result: { script_id: string }; }).normalized_result.script_id; const blocking = load("blocking"); await entered.promise; const controller = new AbortController(); const request = operation === "load" ? { name: "load_frida_script", arguments: { session_id: id, source_kind: "inline", source: "cancelled" } } : operation === "resume" ? { name: "resume_frida_session", arguments: { session_id: id } } : { name: "unload_frida_script", arguments: { session_id: id, script_id: scriptId } }; const cancelled = client.callTool(request, { signal: controller.signal }); const rejected = expect(cancelled).rejects.toThrow("cancel queued operation"); await client.ping(); expect(created).toEqual(["original", "blocking"]); expect(resumes).toBe(0); expect(unloaded).toEqual([]); controller.abort(new Error("cancel queued operation")); await client.ping(); await rejected; held.release(); await blocking; await load("barrier"); expect(loaded).toEqual(operation === "load" ? ["original", "blocking", "cancelled", "barrier"] : ["original", "blocking", "barrier"]); expect(unloaded).toEqual(operation === "unload" ? ["original"] : []); expect(resumes).toBe(operation === "resume" ? 1 : 0); expect(manager.status(id)?.state).toBe(operation === "resume" ? "running" : "paused"); console.log(JSON.stringify({ operation, created, loaded, unloaded, resumes, state: manager.status(id)?.state })); } finally { held.release(); await client.close(); await server.close(); await manager.closeAll(); await binary.close(); } }, ); ``` Run the focused test from the repository root with `VITEST_CACHE_DIR` set to a writable disposable directory: ```sh npm run test:focused -- src/application/frida/fridaQueuedCancellation.test.ts ``` ## Expected behavior A cancelled request that has not started its native Frida operation must cause no native provider effects. After releasing the queue, the cancelled load must not create or load its script, the cancelled resume must leave the selected spawn paused, and the cancelled unload must leave the original script loaded. The uncancelled loads must still complete. ## Actual behavior The independent replay passed all three assertions of the failure. For load, both the creation and loading lists contained `["original", "blocking", "cancelled", "barrier"]`. For resume, the native resume counter reached `1` and the manager state changed to `running`. For unload, the native unload list contained `["original"]`. Each client call rejected with `cancel queued operation` before the blocking load was released. Native counters were clear before cancellation, and an uncancelled barrier load completed before the final assertions. ## Evidence - Expected source: `docs/frida-instrumentation.md:7` lists the persistent MCP operations; `docs/frida-instrumentation.md:11` promises no provider effects for a request cancelled before a Frida operation starts; `docs/mcp-contracts.md:349` promises request-signal propagation to providers. - Failure source: The inline replay observes native dispatch after cancellation through the public MCP tools. `src/server/registerFridaTools.ts:136-193`, `src/application/frida/FridaInstrumentationService.ts:181-201` and `src/application/frida/FridaInstrumentationPort.ts:103-111` omit the request signal for these operations. `src/frida/FridaInstrumentationManager.ts:326-480` queues the operations, and its queue helper at line 721 invokes them after waiting without a cancellation check. - Evidence provenance: observed - Local verification: reproduced - Reproduction completeness: complete The inline fixture ran with exit status `0`, one test file passed and three tests passed. These tests observe the defect. Production request validation, the MCP transport, the application service and the session manager ran unchanged. The native device, session and scripts were controlled doubles; this run does not establish real-device compatibility or actual target-side script effects. Read-only issue searches found the [Frida roadmap](https://github.com/morluto/rea/issues/968), which discusses instrumentation scope and discovery rather than this cancellation failure. No matching report was found in the tool-specific searches. ## Restoration check Repeat the same queued cancellation sequence and require no native create/load call for `cancelled`, no native resume call, and no unload call for `original`. The load case must contain only `["original", "blocking", "barrier"]` in both creation and loading lists. The resume case must remain paused with a resume counter of `0`. The unload case must retain the original script with an empty native unload list. Keep the original, blocking and barrier loads successful, and separately verify that the same valid load, resume and unload operations succeed when their requests are not cancelled. This distinguishes cancellation handling from disabling the operations. ## Runtime or environment Failure verified at `a2d8b94dd50dab8d966239dd63ab84718aa8b4b4` and still present at `8b181716aca2cb467be499cba226abfb5ae19141`; rea-agents 6.4.0; Node v26.10.0; npm 11.19.1; Vitest 5.0.3; linked in-memory MCP transport and controlled native Frida objects with the installed locked dependencies. No native target or Frida server was used. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: Explicitly cancelled queued requests still change the session through script loading, process resume or script unload. The replay establishes bounded lifecycle failures for these inputs; it does not establish severe target damage or a failure for every session operation. - Workaround status: unverified - Workaround basis: Unloading or closing can release instrumentation, but it cannot promise reversal of a script's prior effects, a process resume or an unloaded script's runtime state. No workaround preserving the no-effects cancellation contract was verified.
bsakaguchi 2026-10-15 18:28:59 +00:00
Author
Owner

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

Fixed on published main in the session operation queue. A cancelled queued load, resume, or unload is now removed before native dispatch. Validation: tests/boundary/mcp/fridaLifecycleMcp.test.ts, “does not dispatch a cancelled queued Frida load/resume/unload through MCP,” passed in the phase-five public log and checks provider call records plus resulting session state. The test uses controlled Frida bindings; no real target or Frida runtime was exercised. Thanks for covering all three operations.

<!-- gh-comment-id:6163978776 --> @morluto commented on GitHub (Oct 14, 2026): Fixed on published main in the session operation queue. A cancelled queued load, resume, or unload is now removed before native dispatch. Validation: `tests/boundary/mcp/fridaLifecycleMcp.test.ts`, “does not dispatch a cancelled queued Frida load/resume/unload through MCP,” passed in the phase-five public log and checks provider call records plus resulting session state. The test uses controlled Frida bindings; no real target or Frida runtime was exercised. Thanks for covering all three operations.
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#851
No description provided.