Repository navigation
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 intoOct 18, 2026
Conversation
…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>
|
Required label not found on this PR. |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
main91e70eda, all 5,000push/eventrequests 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
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.-32000is the code of the host's other transient refusals, such as "the host is stopping".MAX_HELD_NOTIFICATIONS) counts held notifications only. When 1,000 are held, the oldest makes room for a new one, as before.ready()andreadyControlPlane()zero it as they take what's held. What they hold again while they re-emit it counts toward the bounds like any arrival.[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.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.387-held-request-budget.fixed.md.What each part is for
mainkept what a flush holds again from being bounded a second time, with aflushDepthcount. 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 thetry/finallythat 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
-32000for 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.Tests
test/mcpl-held-request-budget.test.ts, on the connection's real routing with both planes closed and apush/eventlistener:-32000with the busy text, the host logs the lines for refusals 1 and 101, andready()delivers the 10,000 in order.ready()delivers the newest 1,000.readyControlPlane(), with the data plane still paused, count toward the budget: 5,000 more fill it, and the next is refused. Afterready(), a new pause holds 10,000 with no refusal, and logs its own first refusal.ready()flush test. A handler that pauses the data plane duringready()'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 --noEmitis clean. The budget file,test/mcpl-request-handler-errors.test.tsandtest/mcpl-ws-transport.test.tspass 96 of 96.Red-first. With
main'ssrc/mcpl/server-connection.tsat0d19f7c8and these tests, 5 fail: the budget file's 4, and fix(mcpl): contain inbound request handler failures #208's request-form test, whichmainpasses as it was written.Each piece. Taking out each of nine pieces on its own fails at least one test:
mcpl-ws-transport.test.tsready()ready()readyControlPlane()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
91e70eda, not from a server under load, and we ran no MCPL server in the ecosystem against this.maxPayload; stdio has no limit), and that's outside this change.Merge notes
maininsrc/mcpl/server-connection.ts, and with this change those conflicts also hold the held-request count, which their resolution has to recount after feat(awareness): make Discord awareness marks an explicit, one-shot choice that never gates MCPL #250's filter.changelog.d/387-held-request-budget.fixed.mdWritten 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).