Skip to content

fix: bound per-connection memory in AcpServer - #261

Merged
benbrandt merged 1 commit into
mainfrom
fix/bound-connection-memory
Sep 25, 2026
Merged

benbrandt merged 1 commit into
mainfrom
fix/bound-connection-memory

Conversation

@benbrandt

Copy link
Copy Markdown
Member

Summary

Bound the memory AcpServer keeps for each connection. Session IDs a client sends no longer create lasting per-connection state, and agent output a client has not read is held to a fixed budget by pausing the agent instead of queuing without limit. Follows #259, which bounded individual incoming messages.

Changes

  • Session state is created on demand and limited. A session stream exists only while it holds messages or has a receiver, and is removed when its receiver leaves with nothing queued. A connection that would buffer output for more than maxBufferedSessionStreams (default 1024) sessions that nobody is receiving closes. Client notifications create no session state, and routes are recorded only for valid requests, which the agent always answers.
  • IDs are bounded. Session IDs and string request IDs longer than maxIdLength (default 1024) are rejected with HTTP 400 or WebSocket close code 1008. A connection whose agent issues a longer session ID closes.
  • Agent output is flow-controlled. Each connection holds at most maxBufferedBytes (default 64 MiB) of output its client has not read: the JSON text queued across its HTTP streams, or the socket's bufferedAmount on WebSocket. At the limit the router stops taking output, so an agent that awaits its sends pauses, and client requests (not notifications) wait before reaching the agent. SSE bodies take a message only when their reader asks for one.
  • Stalled clients are dropped. A connection holding maxBufferedBytes whose client reads none of it for maxOutputStallMs (default 60 s) closes. On WebSocket, any bytes the socket sends count as reading.
  • WebSocket input is bounded. Frames waiting behind a slow initialize or a held-back request count toward maxBufferedBytes, at their length plus 1 KiB each, and the socket closes past the limit.
  • Shutdown reaches everything. Streams, held requests and sockets end when their connection shuts down, including an SSE response stuck writing to a client that stopped reading and a WebSocket whose send buffer is full. A router failure shuts the connection down with its error instead of ending streams cleanly. Writes an agent queued before its closed resolved are still delivered.
  • Smaller fixes: outbound queues drain in linear time instead of quadratic shift(); the Node adapter destroys a response whose body fails after headers were sent, and cancels the body when the response fails; the example server caps ws messages at 16 MiB.

New exports from @agentclientprotocol/sdk/experimental/server: the ConnectionLimits type and DEFAULT_MAX_BUFFERED_BYTES, DEFAULT_MAX_OUTPUT_STALL_MS, DEFAULT_MAX_BUFFERED_SESSION_STREAMS and DEFAULT_MAX_ID_LENGTH. AcpServerOptions accepts the four limits, and WebSocketLike gains an optional bufferedAmount.

Compatibility and scope

  • Existing call sites remain valid. new AcpServer() now throws RangeError for a limit that is not a positive safe integer, or a maxOutputStallMs above 2^31 − 1.
  • Behavior changes, all under experimental:
    • A POST request can be held back until its client reads its output, for up to maxOutputStallMs, and then gets 404. Held requests keep their bodies, so the HTTP server or proxy should limit concurrent requests per client.
    • Notifications are never held back, so a client that sends POSTs without waiting for each response can have a session/cancel reach the agent before the prompt it cancels. The SDK's HTTP client sends POSTs one at a time; WebSocket keeps order.
    • Connections close on a stall or on too many buffered sessions: WebSocket with 1008 and the reason, SSE bodies with an error.
    • A router failure, such as an ACP v1 agent sending a batch, closes the connection with an error: SSE bodies error and WebSocket closes with 1011.
    • On shutdown, an SSE body whose reader is stuck mid-write errors instead of ending cleanly, so the Node adapter destroys the response.
    • A request to a connection that shut down mid-request gets 404, and one aborted while held back gets 499.
  • One stalled stream pauses every session on its connection, since they share one agent channel. Only agents that await their sends are paced; the SDK's connections do.
  • maxBufferedBytes counts UTF-16 code units for HTTP output and waiting WebSocket frames, and bytes for the WebSocket send buffer. WebSocket flow control needs a socket that reports bufferedAmount; it was tested with Node's ws only.
  • Out of scope: connections a client abandons without DELETE still stay open, and limiting connections per client remains the embedder's job.

Validation

  • CI=1 npm run check passed: schema generation check, lint, formatting, spelling, build, 958 tests, and TypeDoc verification.
  • New tests cover each limit and shutdown path, and each fails with its fix removed. The timing-sensitive tests passed 12 concurrent runs.
  • Loopback probes against the Node adapter with real HTTP and ws clients:
    • 3,000 requests with random session IDs on one connection: it closes after 1,024 buffered sessions, leaving no routes or streams. Notifications with random session IDs leave no state.
    • A client that never reads 1 MiB replies: output holds at 64 MiB, further requests wait, and after the stall timeout the held HTTP request gets 404 and the WebSocket closes with 1008.
    • Empty WebSocket frames queued behind a slow initialize: the socket closes at the limit after about 90K frames, with the heap under 50 MiB. Before, the frames queued without bound.
    • A burst of 100 × 1 MiB messages to a fast reader: 207 ms over HTTP, 111 ms over WebSocket.

Session IDs a client sends no longer create lasting per-connection state,
and agent output a client has not read is held to a fixed budget by
pausing the agent rather than queuing without limit.

- Create session streams on demand, drop them once empty and unreceived,
  and close a connection that buffers output for more than
  maxBufferedSessionStreams sessions.
- Reject session IDs and request IDs longer than maxIdLength.
- Flow-control agent output to maxBufferedBytes per connection: the router
  pauses, held-back client requests wait, and SSE bodies pull on demand.
- Close connections whose client reads nothing for maxOutputStallMs, and
  bound WebSocket frames waiting to be handled.
- End streams, held requests and sockets when a connection shuts down.
@benbrandt
benbrandt merged commit 4356253 into main Sep 25, 2026
6 checks passed
@benbrandt
benbrandt deleted the fix/bound-connection-memory branch September 25, 2026 13:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant