Skip to content

Unread raw ACP notifications pin up to 32 payloads per connection (256 MiB for 8 MiB notifications, synthetic) #18799

Closed

Description

@bompus

What happened

Every ACP connection keeps its last 32 incoming notifications in memory, whether or not anything reads them. Nothing in apps/ reads raw.notifications, so a connection that receives large notifications holds up to 32 of them for as long as it lives. In a synthetic run of 40 notifications of 8 MiB each, a connection that nobody reads from held 256 MiB.

I have not measured a real session. Most ACP notifications are small. I first saw large ones in Cursor sessions that failed to resume at the old 16 MiB line limit (#11217, closed).

Diagnosis

  • makeAcpPatchedProtocol in packages/effect-acp/src/protocol.ts offers every incoming notification to Queue.sliding(32) and exposes it as raw.notifications on the ACP client and agent (client.ts, agent.ts). A sliding queue limits how many entries it holds, not how large they are, and it keeps them until they are read.
  • A search of apps/ and packages/ finds no reader of raw.notifications outside effect-acp's own tests, so the backlog is never drained.
  • Retained memory is the payload size times the number of unread notifications, up to 32.

Steps to reproduce

From a checkout at 511d20c6f2, after pnpm install --frozen-lockfile --ignore-scripts --filter effect-acp..., save the script below as packages/effect-acp/measure-retention.ts and run node --expose-gc measure-retention.ts 8 40 from packages/effect-acp. It feeds 40 notifications of 8 MiB through makeAcpPatchedProtocol with no reader on the raw stream, and prints the memory still held after garbage collection.

import * as Effect from "effect/Effect";
import * as Queue from "effect/Queue";
import * as AcpProtocol from "./src/protocol.ts";
import { makeInMemoryStdio } from "./src/_internal/stdio.ts";

const MIB = 1024 * 1024;
const sizeMiB = Number(process.argv[2] ?? 8);
const count = Number(process.argv[3] ?? 40);
const gc = (globalThis as { gc?: () => void }).gc!;
const mib = (n: number) => Math.round((n / MIB) * 10) / 10;
const used = () => {
  gc();
  gc();
  const m = process.memoryUsage();
  return m.heapUsed + m.external;
};

// Built in a helper so no local keeps the payload alive during measurement.
const encodeLine = (i: number): Uint8Array =>
  new TextEncoder().encode(
    `{"jsonrpc":"2.0","method":"x/large","params":{"index":${i},"text":"${String.fromCharCode(97 + (i % 26)).repeat(sizeMiB * MIB)}"}}\n`,
  );

const program = Effect.gen(function* () {
  const { stdio, input } = yield* makeInMemoryStdio();
  const handled = yield* Queue.unbounded<number>();
  let seen = 0;
  yield* AcpProtocol.makeAcpPatchedProtocol({
    stdio,
    serverRequestMethods: new Set(),
    onNotification: () => Queue.offer(handled, ++seen).pipe(Effect.asVoid),
  });
  const base = used();
  const rows: Array<{ notifications: number; retainedMiB: number }> = [];
  for (let i = 1; i <= count; i++) {
    yield* Queue.offer(input, encodeLine(i));
    yield* Queue.take(handled);
    if (i === 1 || i % 8 === 0 || i === count) rows.push({ notifications: i, retainedMiB: mib(used() - base) });
  }
  return rows;
}).pipe(Effect.scoped);

const rows = await Effect.runPromise(program);
console.log(JSON.stringify({ sizeMiB, count, rows }));

The "with fix" numbers below come from my local branch, which is not pushed yet.

Version

pingdotgg/t3code 511d20c (main, 2026-10-12)

Environment

Linux 7.2.9 (WSL2, x86_64), Node 26.11.1, pnpm 11.10.0, effect 4.0.2 as pinned by the repo

Evidence

Retained MiB after N notifications of 8 MiB each, nobody reading the raw stream (3 runs, identical):

notifications   before   with fix
 8               64.1      8.1
16              128.2      8.2
24              192.2      8.2
32              256.2      8.2
40              251.8      3.8

1 MiB each, 100 sent: plateaus at 32.2 MiB before, 15.2 MiB with the fix.

Related issues

None found. #11217 is closed and was about the 16 MiB wire cap, which upstream's own line parser now avoids; this is the separate retained-backlog part of it.

Fix applied or workaround

None applied. A 38-line fix with two tests is ready on a local branch. It keeps the newest notification and as many older ones as fit in 16 MiB of JSON, still capped at 32, and a reader that keeps up receives every notification. I will open a pull request once this is triaged.

Filed by

Claude Sonnet 5.5, in Claude Code

Activity

  1. juliusmarminge commented on Oct 22, 2026

    @juliusmarminge
    Member

    Reproduced on main 70e5e384da, Linux x86_64 / Node 24.21.0. The supplied node --expose-gc measure-retention.ts 8 40 run retained 256.2 MiB at 32 unread notifications; the 1 MiB / 100-notification variant plateaued at 32.2 MiB. A browser-started local synthetic ACP provider also grew the isolated dev server’s retained heap. These are synthetic payloads; real provider-session impact remains unmeasured. No duplicate found among the open issues.

  2. juliusmarminge commented on Oct 22, 2026

    @juliusmarminge
    Member

    Fix opened as #23141. The unread backlog now has a character budget as well as the 32-notification cap. The regression test, runtime evidence, verification and remaining limits are in the PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions