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.
Unread raw ACP notifications pin up to 32 payloads per connection (256 MiB for 8 MiB notifications, synthetic)#18799
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.
| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Queue from "effect/Queue"; |
| 3 | import * as AcpProtocol from "./src/protocol.ts"; |
| 4 | import { makeInMemoryStdio } from "./src/_internal/stdio.ts"; |
| 5 | |
| 6 | const MIB = 1024 * 1024; |
| 7 | const sizeMiB = Number(process.argv[2] ?? 8); |
| 8 | const count = Number(process.argv[3] ?? 40); |
| 9 | const gc = (globalThis as { gc?: () => void }).gc!; |
| 10 | const mib = (n: number) => Math.round((n / MIB) * 10) / 10; |
| 11 | const 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. |
| 19 | const 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 | |
| 24 | const 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 | |
| 43 | const rows = await Effect.runPromise(program); |
| 44 | 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 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
| 1 | Retained MiB after N notifications of 8 MiB each, nobody reading the raw stream (3 runs, identical): |
| 2 | |
| 3 | notifications before with fix |
| 4 | 8 64.1 8.1 |
| 5 | 16 128.2 8.2 |
| 6 | 24 192.2 8.2 |
| 7 | 32 256.2 8.2 |
| 8 | 40 251.8 3.8 |
| 9 | |
| 10 | 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
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.