Repository navigation
Description
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
makeAcpPatchedProtocolinpackages/effect-acp/src/protocol.tsoffers every incoming notification toQueue.sliding(32)and exposes it asraw.notificationson 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/andpackages/finds no reader ofraw.notificationsoutside 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
juliusmarminge commentedon Oct 22, 2026
Reproduced on main 70e5e384da, Linux x86_64 / Node 24.21.0. The supplied
node --expose-gc measure-retention.ts 8 40run 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.juliusmarminge commentedon Oct 22, 2026
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.