Skip to content

fix(mcpl): held requests get a budget of 10,000, apart from notifications; past it a request is refused (#387) - #503

Merged
nissa-seru merged 3 commits into
anima-research:mainfrom
nissa-seru:fix/387-held-request-budget
Oct 18, 2026
Merged

nissa-seru merged 3 commits into
anima-research:mainfrom
nissa-seru:fix/387-held-request-budget

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Requests an MCPL server sends while its plane is paused now have a budget of 10,000, counted apart from notifications. Past it, a new request is answered at once with -32000, and notifications keep their own cap of 1,000. !Fixes #387.

Problem

While a plane is paused (a startup, a reconnect, an awareness drain, a quiesce), the connection holds what a server sends until the plane opens. #122's cap of 1,000 bounded only responder-less events. Every request was held past it, on the theory that a server times out and reconnects. A server that pipelines requests without waiting for their answers never times out, and each held request keeps its params and its responder, so the memory they took grew without bound. In a probe on main 91e70eda, all 5,000 push/event requests of 10 KB sent while the plane was paused were held, none was answered, and the heap grew by 73 MB.

The cap also counted held requests toward the notifications' 1,000. With 1,000 or more requests held, each new notification evicted an older one, or was dropped itself when none was held.

Changes

  • A budget of 10,000 held requests (MAX_HELD_REQUESTS). Past it, the connection answers a new request at once with -32000, "the host is busy: 10000 of this server's requests are already waiting; send this one again later", and doesn't hold it. -32000 is the code of the host's other transient refusals, such as "the host is stopping".
  • The cap of 1,000 (MAX_HELD_NOTIFICATIONS) counts held notifications only. When 1,000 are held, the oldest makes room for a new one, as before.
  • A count of held requests, which both bounds read. ready() and readyControlPlane() zero it as they take what's held. What they hold again while they re-emit it counts toward the bounds like any arrival.
  • Each pause's first refusal, and every hundredth after it, is logged: [mcpl] <server>: held-request budget (10000) — refused <n> request(s) with -32000 while the plane is paused, beside the drops' line. The count starts again when the planes open.
  • hasResponder, the request predicate, is now a static method.
  • fix(mcpl): contain inbound request handler failures #208's test of a request form held at the cap (test/mcpl-request-handler-errors.test.ts) counted the request toward the 1,000. Its request is counted apart now, so of its 1,100 notifications 100 are dropped rather than 101, and the flush holds 1,001.
  • Changelog fragment 387-held-request-budget.fixed.md.

What each part is for

  • The request budget bounds a server that pipelines requests without waiting, which is what Requests buffered while an MCPL data plane is paused have no bound #387 is about. Without it, held requests have no bound. Dropping a request instead would strand its server, which waits on it. Recycling the connection would free almost nothing, since what's held survives a reconnect, and it would cost a healthy, busy server its link. Its cost is that a server that doesn't send a refused request again loses it. Why the budget sits at 10,000 is below.
  • Counting notifications apart keeps held requests from crowding notifications out. Without it, a pause holding 1,000 requests drops every notification.
  • The count of held requests lets both bounds decide without scanning what's held. Counting by a scan would need no invariant, but each arrival would scan up to 11,000 held items, under exactly the flood the budget bounds. The cost is that every change to what's held must keep the count, as the field's comment says (Merge notes).
  • The refusal log makes refusals visible without a line for each one.
  • No guard for a flush. main kept what a flush holds again from being bounded a second time, with a flushDepth count. A flush takes everything held and zeroes the count first, so what it holds again was admitted within the bounds and stays within them, and the guard could change no outcome. It's gone, with its field and the try/finally that kept its count.

Why 10,000

A budget shared with notifications at 1,000 would turn a long pause over a busy Discord guild into lost messages for a healthy server. By our reading of discord-mcpl's source, it sends one request per Discord message and only logs a refused one. 10,000 still bounds a server that pipelines without waiting. Requests like the probe's took about 15 KB each in memory, so 10,000 of them come to about 150 MB.

What a deployment will notice

  • A server past 10,000 held requests in one pause gets -32000 for each further request, where before it waited. A server that doesn't send a refused request again loses it. By that reading, discord-mcpl doesn't, so in a pause that long, the Discord messages past the 10,000th wouldn't reach the agent after the pause.
  • Held requests no longer crowd notifications out. A pause holding many requests keeps up to 1,000 notifications too.

Tests

test/mcpl-held-request-budget.test.ts, on the connection's real routing with both planes closed and a push/event listener:

  • The refusal test. 10,000 requests are held. The next 101 are each answered -32000 with the busy text, the host logs the lines for refusals 1 and 101, and ready() delivers the 10,000 in order.
  • The notifications test. With 10,000 requests held, 1,001 notifications are held as 1,000, the oldest dropped and logged, nothing refused, and ready() delivers the newest 1,000.
  • The control-plane flush test. 5,000 requests held again by readyControlPlane(), with the data plane still paused, count toward the budget: 5,000 more fill it, and the next is refused. After ready(), a new pause holds 10,000 with no refusal, and logs its own first refusal.
  • The ready() flush test. A handler that pauses the data plane during ready()'s flush holds the rest of what was held again, and those count: with one held again, 9,999 more fill the budget.

Runs at ed499917, on Linux with Node 24.22.0:

  • tsc --noEmit is clean. The budget file, test/mcpl-request-handler-errors.test.ts and test/mcpl-ws-transport.test.ts pass 96 of 96.

  • Red-first. With main's src/mcpl/server-connection.ts at 0d19f7c8 and these tests, 5 fail: the budget file's 4, and fix(mcpl): contain inbound request handler failures #208's request-form test, which main passes as it was written.

  • Each piece. Taking out each of nine pieces on its own fails at least one test:

    Piece taken out Caught by
    the request budget the refusal test, and both flush tests
    counting notifications apart the notifications test, and fix(mcpl): contain inbound request handler failures #208's request-form test
    dropping the oldest notification the notifications test, fix(mcpl): contain inbound request handler failures #208's request-form test, and the drop-count test in mcpl-ws-transport.test.ts
    the drops' log line the same three
    logging every hundredth refusal, narrowed to the first the refusal test
    zeroing the refusal count in ready() the control-plane flush test
    zeroing the held count in ready() both flush tests
    zeroing the held count in readyControlPlane() the control-plane flush test
    counting a request held again during a flush both flush tests
  • The flush guard. The 15 test files that pause or ready a connection pass 315 of 315 at this head, and 315 of 315 with main's guard put back.

CI runs the typecheck, the build and the suite on this pull request.

Not verified

  • No real server flood. The memory figures come from the probe on 91e70eda, not from a server under load, and we ran no MCPL server in the ecosystem against this.
  • The bounds count messages, not bytes. A single huge line is bounded only by the transport (WebSocket's maxPayload; stdio has no limit), and that's outside this change.
  • Dropping the oldest notification at the cap still finds it by scanning from the front of what's held, past any held requests: up to 10,000 of them for each notification when the budget is full too. Before this change held requests had no bound, so that scan had none either.

Merge notes


  • Changelog fragment added — changelog.d/387-held-request-budget.fixed.md

Written by Tabitha-2696 (Claude Opus 5.5), contributing through nissa-seru. Ada-1017 made the design calls, the budget of 10,000 among them. Our internal review read the first commit, and its findings are in the second. Ansel-2428 (Claude Opus 5.5) reviewed it, and read it again after its rebase onto main, the flush guard's removal included.

🤖 Written with Claude Opus 5.5 through the Rookery's Pi tooling.

Description checked and posted by Fiona-2757 (Claude Opus 5.5).

Tabitha-2696 and others added 3 commits October 18, 2026 05:06
…ions; past it a request is refused (anima-research#387)

While a plane is paused (startup, a reconnect, an awareness drain, a
quiesce), emit() holds what a server sends until the plane opens. Issue
anima-research#122's cap bounded only responder-less events: every request was
appended past it, on the theory that a server times out and reconnects.
A server that pipelines requests without waiting never times out, and a
held request keeps its params and its responder closure, so memory grew
until the host ran out. Probed on main 91e70ed: 5,000 push/event
requests of 10 KB sent while paused were all held, none answered, and
the heap grew 73 MB.

Requests and notifications are now bounded apart:
- Requests: past MAX_HELD_REQUESTS (10,000) a new request is answered at
  once with -32000, "the host is busy: 10000 of this server's requests are
  already waiting; send this one again later", the code of the host's
  other transient refusals, and is not held. It is never dropped, since
  its server waits on it, and the connection is never recycled: held
  items survive a reconnect, so recycling would free almost nothing and
  would cost a healthy, busy server its link. The first refusal and every
  hundredth are logged, as the drops are.
- Notifications: the existing cap of 1,000 (now MAX_HELD_NOTIFICATIONS)
  counts held notifications only. Before, it counted held requests too,
  so with 1,000 or more requests held, each new notification evicted an
  older one or, with none held, was dropped itself. Now the oldest held
  notification makes room only when 1,000 notifications are held.

A shared 1,000 would have turned a long pause over a busy guild into lost
messages for a healthy server, since discord-mcpl sends one request per
Discord message and only logs a refused one. 10,000 still bounds the
pipelining server anima-research#387 is about. The changelog names the budget and the
refusal.

heldRequests counts the held requests, so deciding whether either bound
is reached reads the count, not the buffer. ready() and
readyControlPlane() zero it as they take the snapshot, and a request held
again during their flush (flushDepth) is counted without being bounded
again, since it was admitted once. Every change to bufferedEvents keeps
the count; anima-research#250, open, filters bufferedEvents at a transport boundary and
would need to recount there. The request predicate is a static
hasResponder, the name and body anima-research#250 gives it.

Tests (test/mcpl-held-request-budget.test.ts), on the connection's real
routing with both planes closed and a push/event listener:
- 10,000 requests are held, the next two are refused -32000 with the
  busy text and one log line, and ready() delivers the 10,000 in order;
- with 10,000 requests held, 1,001 notifications are held as 1,000, the
  oldest dropped, nothing refused, and ready() delivers n2..n1001;
- 5,000 requests held again by readyControlPlane() (data plane still
  paused) count: 5,000 more fill the budget and the next is refused;
  after ready(), a new pause holds 10,000 with no refusal.

test/mcpl-request-handler-errors.test.ts held a request form beside
1,100 notifications and counted it against the 1,000, so 101 were
dropped. The request is counted apart now: 100 are dropped, and the
flush holds 1,001.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s tests pin the logs and a re-hold during ready() (anima-research#387)

Our internal review of the first commit found these, each fixed here.

refusedHeldRequests lived as long as the connection, so after a pause
had refused a few requests, a later pause's first refusal wasn't logged
(the throttle logs counts 1, 101, 201 ...). ready(), where the planes
open and a pause ends, now zeroes it with the held count.

MAX_HELD_REQUESTS' doc claimed that a long quiesce over a busy guild
stays under the budget, which nothing measured. It now says what the
budget is for, and that a server that doesn't resend a refused request
loses it.

A listener that pauses the data plane during ready()'s own flush holds
the rest of the snapshot again, and nothing tested that those count. A
new test: two held requests, the first one's handler pauses the plane,
the second is held again, and 9,999 more fill the budget.

The refusal log's cadence and the drop log had no test. The first test
refuses 101 requests and asserts the two lines for refusals 1 and 101;
the second asserts the drop line for its one dropped notification; the
third asserts that the second pause logs its first refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ne can pass the bounds (anima-research#387)

ready() and readyControlPlane() take the whole buffer and zero its count
before they re-emit it, so what a flush holds again was admitted within
the held bounds and stays within them. The flushDepth guard, which kept
those items from being bounded a second time, could change no outcome:
the notification cap has had it since anima-research#122, and the request budget took
it from there. It goes, with the field and the try/finally that kept
its count, and the hold site's comment says why bounding again changes
nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 18, 2026

Copy link
Copy Markdown

Required label not found on this PR.

@nissa-seru
nissa-seru merged commit 5cfb0f1 into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…eps its class, outcome and data, with W4a's words

main moved from 0d19f7c to 6dfea06 (trains K and L: anima-research#445, anima-research#494, anima-research#496,
anima-research#497, anima-research#499, anima-research#500, anima-research#502, anima-research#503, anima-research#504, anima-research#505, anima-research#247, anima-research#507, anima-research#509). Only
anima-research#247 conflicts, in server-connection.ts:
five rejections it wraps in McplRequestError (this host's close and a
drop with a request pending, and a timeout: no-response; a request on a
closed connection: not-sent; an error answer: error-response with the
JSON-RPC error's data). Each keeps anima-research#247's class, outcome and data, with
W4a's words as its message (resolve247.py in my notes; the error answer's
rejection, after the conflict, takes the class too).

Beyond the conflicts, two calls Ada made (room-203 #142751):
- anima-research#247's synchronous write rejection read `Cannot send <method> to MCPL
  server "<s>": <transport error>`, a request method in the agent's text.
  It now reads "The request wasn't sent: this host couldn't write it to the
  "<s>" server, so nothing ran. Try again later.", and the method, id and
  the transport's words go to a log line (logDetail).
- publishErrorOutcome composes `Publish to <c> failed: ${reason}. Nothing
  was posted.` and `${message} (delivery uncertain)`, and W4a's messages are
  sentences, so one final period comes off this host's own words for a
  request not sent or not answered. A server's error answer keeps its words
  as they came, as anima-research#247's test of a partial post's account requires.

Tests from main that pinned the texts W4a changes take W4a's words:
mcpl-request-outcome (error answer, timeout, channels/open's deadline,
closed connection, the write rejection with its log line),
mcpl-request-timeout (channels/publish's deadline), prose-drafts (the
unconfirmed marker), mcpl-late-render (push/render's timeout, and a
tools/call's in its two orphaned-response tests).
publish-outcome gains a sentence-shaped not-sent message. The fragment
names the write rejection.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main is 297 commits past this branch's last merge (e670363). Six files
conflicted; the rest merged as git left them, and what this branch removes
is removed from main's new code too.

src/framework.ts
- The inbound handlers take main's containment (anima-research#208: onRequest wraps every
  handler, answering a failure once, so answerOnce and the per-handler
  try/catch are gone) without the awareness barrier this branch removes: no
  push/event, channels/incoming, inference/request or host/command waits on
  a generation.
- tools/list_changed is wired through onRequest, as on main, with this
  branch's body: no data-plane gate, and a background awareness delivery
  after the refresh.
- containedInboundHandler's comment no longer names data-plane barrier
  semantics, which this branch has none of.
- The import keeps McplRequestError, which the awareness delivery reads,
  beside main's boundedForLog and describeCause.

src/mcpl/server-connection.ts
- main's held-request budget and notification cap (anima-research#503, anima-research#387) replace the
  old cap and flushDepth. Its count of held requests is kept where this
  branch changes what is held: the transport boundary keeps a channel's
  messages and pushes (keepAcrossBoundary), and a flush that crosses a
  boundary holds the rest of its snapshot again. Both now go through
  replaceHeld, which recounts the requests among what is held.
- flushHeld (admission order, the boundary rule) dispatches through main's
  containment boundary (dispatchInboundEvent), as main's flushes do.
- The respond helpers bound to the transport a request came on move into
  main's dispatchMessage.
- HeldItem sits beside main's describeMethod; PROCESS_EVENTS beside main's
  hasResponder, which both sides added alike.

changelog.d/mcpl-request-outcome.added.md
- main's text, which 0.22.3 folds: it already says McplRequestError keeps
  the error's data and not its code, and its name is McplRequestError there
  too, so this branch changes nothing it describes.

Tests
- test/mcpl-awareness-barrier.test.ts stays deleted; main's change to it
  seeded a harness field for a barrier test.
- test/mcpl-request-handler-errors.test.ts (main's) loses the test that a
  tools/list_changed throw still completes or fails its data-plane gate,
  which this branch removes. The test that such a throw is contained and
  reported stays.
- test/stop-with-work-in-flight.test.ts: the held-at-the-barrier loop stays
  removed, and the next test takes main's title, which matches main's body.
- test/mcpl-request-outcome.test.ts: both sides' error-response test had one
  body; main's title says what it asserts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

Requests buffered while an MCPL data plane is paused have no bound

1 participant