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

openedOct 12, 2026byBbompus

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.

ts
1import * as Effect from "effect/Effect";
2import * as Queue from "effect/Queue";
3import * as AcpProtocol from "./src/protocol.ts";
4import { makeInMemoryStdio } from "./src/_internal/stdio.ts";
5
6const MIB = 1024 * 1024;
7const sizeMiB = Number(process.argv[2] ?? 8);
8const count = Number(process.argv[3] ?? 40);
9const gc = (globalThis as { gc?: () => void }).gc!;
10const mib = (n: number) => Math.round((n / MIB) * 10) / 10;
11const used = () => {
12 gc();
13 gc();
14 const m = process.memoryUsage();
15 return m.heapUsed + m.external;
16};
17
18// Built in a helper so no local keeps the payload alive during measurement.
19const encodeLine = (i: number): Uint8Array =>
20 new TextEncoder().encode(
21 `{"jsonrpc":"2.0","method":"x/large","params":{"index":${i},"text":"${String.fromCharCode(97 + (i % 26)).repeat(sizeMiB * MIB)}"}}\n`,
22 );
23
24const program = Effect.gen(function* () {
25 const { stdio, input } = yield* makeInMemoryStdio();
26 const handled = yield* Queue.unbounded<number>();
27 let seen = 0;
28 yield* AcpProtocol.makeAcpPatchedProtocol({
29 stdio,
30 serverRequestMethods: new Set(),
31 onNotification: () => Queue.offer(handled, ++seen).pipe(Effect.asVoid),
32 });
33 const base = used();
34 const rows: Array<{ notifications: number; retainedMiB: number }> = [];
35 for (let i = 1; i <= count; i++) {
36 yield* Queue.offer(input, encodeLine(i));
37 yield* Queue.take(handled);
38 if (i === 1 || i % 8 === 0 || i === count) rows.push({ notifications: i, retainedMiB: mib(used() - base) });
39 }
40 return rows;
41}).pipe(Effect.scoped);
42
43const rows = await Effect.runPromise(program);
44console.log(JSON.stringify({ sizeMiB, count, rows }));

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

Version

pingdotgg/t3code 511d20c6f2 (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

shell
1Retained MiB after N notifications of 8 MiB each, nobody reading the raw stream (3 runs, identical):
2
3notifications before with fix
4 8 64.1 8.1
516 128.2 8.2
624 192.2 8.2
732 256.2 8.2
840 251.8 3.8
9
101 MiB each, 100 sent: plateaus at 32.2 MiB before, 15.2 MiB with the fix.

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

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.

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.