Repository navigation
fix(mcpl): a transport error with nothing listening is logged, not thrown, so a malformed frame at upgrade no longer ends the host (#391) - #504
Merged
nissa-seru merged 5 commits intoOct 18, 2026
Conversation
… is logged, not thrown (anima-research#391) setupLifecycle passed every transport error on as the connection's own 'error', with no listener check. EventEmitter throws an 'error' nothing listens for, and the transport reports its errors from I/O callbacks, where nothing catches that throw, so it ended the process. The framework always listens, so hosts weren't affected, but McplServerConnection is exported, and a probe, a playtest or a harness holding one often attaches no 'error' listener. Probed on main 91e70ed: a separate process holding a bare WebSocket connection died with "Unhandled 'error' event" when its server sent a frame with a reserved opcode. anima-research#208's reportInboundFailure already guarded the same case, emitting only with a listener and catching one that throws. That guard is now one private helper, emitToErrorListeners, used by both sites: it gives the error to the listeners if there are any, catching and logging one that throws, and says whether there were. reportInboundFailure logs its line as before and hands the error to the helper. A transport error with no listener is logged as `[mcpl] <id>: transport error: <message>`, its message through logDetail; with a listener nothing is logged, as before. A listener that throws on a transport error is caught and logged now too, where it used to throw into the transport's callback. The stdio transport passes on the child's own 'error' (a failed kill, say) and a read error on its stderr, since the connection always listens on it once wired; since anima-research#384, a failure on stdin or stdout ends the child instead. A WebSocket error, the child's own 'error' and a stderr read error reach this path, which is why the probe uses a WebSocket. Tests (test/mcpl-transport-error-listeners.test.ts): - with no 'error' listener, a transport error doesn't throw and is logged; - with a listener, the listener gets the error and nothing is logged (a control: it passes on the base too); - a listener that throws on a transport error is caught and logged; - a separate process holding a bare WebSocket connection (fixture mcpl-bare-ws-error-probe.mjs) survives a reserved-opcode frame, sees the connection close, and logs the error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, not thrown, so a malformed frame at upgrade no longer ends the host (anima-research#391) Our internal review of the first commit found the same rule broken one layer down, and a probe confirmed it. A WebSocket MCPL server that sends a malformed frame in the same write as its 101 upgrade response ended the process that was connecting, framework hosts included: on main 14e25dc, 3 of 3 runs of the new fixture mcpl-ws-upgrade-error-probe.mjs exited 1 with "Unhandled 'error' event ... Emitted 'error' event on WebSocketTransport instance" and "RangeError: Invalid WebSocket frame: invalid opcode 3". ws's setSocket unshifts the bytes that came with the upgrade before it emits 'open' (ws/lib/websocket.js 263-271), the transport is constructed in that 'open' handler, and the frame's error is raised on a nextTick, which Node runs before the promise continuation where handshake() attaches its listener. The transport forwarded ws's 'error' with no listener check, so EventEmitter threw. anima-research#391's case spares framework hosts; this one ends them, so it's fixed here, as anima-research#391's rule at the transport's own forwarders. McplTransport gains reportError: it emits 'error' when something listens and logs `[mcpl] a <kind> transport error, with nothing listening for it: <message>` otherwise. WebSocketTransport's ws 'error' forwarder and StdioTransport's child 'error' forwarder use it; no trigger was found for the stdio one's window, but the rule is the same. anima-research#384 has since rewritten the stdio pipe listeners: an error on stdin or stdout ends the transport through the exit's close and emits nothing. stderr's read-error listener emitted only when something listened and was silent otherwise; it goes through reportError too, so with nothing listening it's logged. After the fix the probe's connect is refused and the process lives: promptly when the server closes the connection after answering ws's close frame ("transport closed before handshake completed (websocket closed, code 1006)"), and after about 30 s when it never does, when ws's close timeout or the handshake's own 30 s timeout ends the wait. The review also found these, fixed here too: - a listener that threw on a transport error was logged without the error it was handling, so the transport error's text reached no log line. emitToErrorListeners' line now ends ", handling <message>"; - the helper's doc said the caller always logs when nothing listens, but reportInboundFailure logs first either way. It now says so; - the process test's assertion message carries the probe's stdout too, where the fixture prints its outcome. Tests (test/mcpl-transport-error-listeners.test.ts): a stand-in child, its stderr and a stand-in socket each emit 'error' with nothing listening on the transport: nothing is thrown, all three are logged, and once listeners attach they get the errors and nothing more is logged. A separate process connects to the upgrade probe's server: it is refused, the error is logged, and it exits 0. The throwing-listener test asserts the line with what it was handling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… at upgrade, which ended framework hosts The transport-level fix reaches further than anima-research#391's bare connection: a WebSocket server that sent a malformed frame with its 101 ended any host connecting to it. The fragment now opens with that, and says a throwing 'error' listener is logged with the error it was handling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…server (anima-research#391) Our internal review of the transport-level fix found these, each fixed here. reportError's line named only the transport's kind, so with several MCPL servers an operator couldn't tell which one had sent the malformed frame, and in the window before the handshake listens that line is the only record of it. Each transport now keeps its server's id from the config, and the line reads `[mcpl] <server>: a <kind> transport error, with nothing listening for it: <message>`. The stdio and WebSocket transports always give the id; a test's stand-in transport may leave it out, and its line then names no server. The changelog's second entry said hosts weren't affected, beside a first entry about how they were. It now says a connection the framework wires always has a listener. The first entry says when the connect is refused: at once when the server closes the connection, as a WebSocket server does after answering ws's close frame, and after about 30 s when it never does. The upgrade probe's comment says what ends the wait: ws reads nothing more once it has failed the connection, so it's the socket's end, not the server's answering frame. Tests: the stand-in transports and the upgrade process assert the line with the server's id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l it caught another test catches (anima-research#391) The test spawned a process holding a bare WebSocket connection, from the fixture mcpl-bare-ws-error-probe.mjs, and checked that it survived a malformed frame and saw the close. Each removal of this change's pieces that failed it also failed one of the in-process tests of a connection or transport with no 'error' listener, so it caught nothing they don't, for a process and a fixture of its own. The test and the fixture go. The upgrade probe's process test stays, since it runs ws's own order of events at upgrade, which the fix is for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Required label not found on this PR. |
1 task done
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
main's anima-research#504 imports logDetail in src/mcpl/transport.ts at the line where this branch imports assertBooleanSetting from the new config-checks.ts; both imports stay. Nothing else conflicted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
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.
A transport error with nothing listening for it is now logged instead of thrown, at both places where one could end the process: on an
McplServerConnectionwith no'error'listener, and on a transport before the handshake attaches its listener. The second ended framework hosts too. A WebSocket MCPL server that sent a malformed frame with its upgrade response ended the host connecting to it. !Fixes #391.Problem
setupLifecyclepassed every transport error on as the connection's own'error', with no listener check. EventEmitter throws an'error'that nothing listens for. A transport reports its errors from I/O callbacks, where nothing catches that throw, so it ended the process. The framework always attaches an'error'listener, so hosts weren't affected. ButMcplServerConnectionis exported (src/index.ts), and a probe, a playtest or a harness holding one often attaches none. In a probe onmain91e70eda, a separate process holding a bare WebSocket connection died withUnhandled 'error' eventwhen its server sent a frame with a reserved opcode.WebSocketTransportpassed ws's'error'on with no listener check. ws'ssetSocketunshifts any bytes that came with the101upgrade response before it emits'open', and the connection constructs the transport in that'open'handler. ws raises a bad frame's error on anextTick, and Node runsnextTickcallbacks before the promise continuation wherehandshake()attaches its listener. So a server that sends a malformed frame with its upgrade response ends the process that's connecting, before any listener of the framework's exists. Withmain'ssrc/mcpl/at0d19f7c8, the fixturetest/fixtures/mcpl-ws-upgrade-error-probe.mjsexits withUnhandled 'error' eventandRangeError: Invalid WebSocket frame: invalid opcode 3.StdioTransportpassed the child's own'error'on the same way, though we found nothing that triggers it in that window.Changes
McplTransport.reportErrorgives a transport error to the transport's'error'listeners when it has any, and otherwise logs[mcpl] <server>: a <stdio|websocket> transport error, with nothing listening for it: <message>. ws's'error', the stdio child's own'error'and a read error on the child's stderr all go through it. Since fix(mcpl): SIGTERM a stdio child whose stdin or stdout fails #384, an error on the child's stdin or stdout ends the transport through the exit's close and emits nothing, so this change leaves those as they are. stderr's read-error listener used to emit only when something listened, and dropped the error otherwise.McplServerConnection.emitToErrorListeners, one private helper, gives an error to the connection's'error'listeners if it has any, and says whether it had. It catches and logs a listener that throws, with the error that listener was handling:[mcpl] <server>: an 'error' listener failed: <its failure>, handling <the error>. Both places the connection emits'error'use it: fix(mcpl): contain inbound request handler failures #208'sreportInboundFailure, which carried the check inline, and the transport-error forwarder, which carried none (A bare McplServerConnection throws on any transport error when nothing listens for 'error' #391).[mcpl] <server>: transport error: <message>. With a listener, the listener gets the sameErroras before, and nothing is logged.reportInboundFailurelogs its own line as before, and hands its error to the helper.391-transport-error-no-listener.fixed.md.What each part is for
reportErrorkeeps a transport from ending the process in the window before the handshake attaches its listener, the window in which framework hosts ended. It's one protected method on the base class, used by the three forwarders.emitToErrorListenersputs one rule where the connection emitted'error'in two ways. It catches a throwing listener because that throw would come from an I/O callback too, where nothing catches it.mcpl-bare-ws-error-probe.mjs. Every piece below that it caught, another test catches, so the last commit takes it out. The upgrade probe's process test stays, since it runs ws's own order of events at upgrade, which the fix is for.What a deployment will notice
transport closed before handshake completed (websocket closed, code 1006)), and in about 30 s when it never does, where ws's close timeout and the handshake's own timeout, both 30 s, race. In two such runs, the handshake's timeout ended one (initialize handshake timed out, 30,014 ms) and ws's close ended the other (30,018 ms). The connection retries a server set toreconnect, as it does any connect that fails before its handshake (test/mcpl-reconnect-events.test.ts).mcpl:server-error.'close'follows. A stdio child's own'error', or a read error on its stderr, leaves the link up, and the connection only logs it.Tests
test/mcpl-transport-error-listeners.test.ts:'error'listener on the connection, a transport error doesn't throw and is logged.mainpasses too.'error'with nothing listening on their transport. Nothing is thrown, all three are logged with their server's id, and once listeners attach they get the errors.mcpl-ws-upgrade-error-probe.mjs), is refused, logs the error with the server's id, and exits 0.Runs at
ce33bfb0's tree, on Linux with Node 24.22.0:tsc --noEmitis clean. This file,test/mcpl-request-handler-errors.test.ts,mcpl-transport-close.test.tsandmcpl-ws-transport.test.tspass 109 of 109.Red-first. With
main'ssrc/mcpl/server-connection.tsandsrc/mcpl/transport.tsat0d19f7c8and these tests, the 4 that aren't the control fail.Each piece. Taking out each of twelve pieces on its own fails at least one test:
reportError's listener check (it always emits)reportError's log linereportError's linereportErrorin the ws'error'forwarder (a plain emit instead)reportErrorin the child'error'forwarder (a plain emit instead)reportErrorin the stderr read-error listener (an emit only when something listens)'error'listenerreportInboundFailurehanding its error to the helper'error'listener, and its separate stdio host in throwing-listener modeCI runs the typecheck, the build and the suite on this pull request.
Not verified
Merge notes
src/mcpl/transport.ts. feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262 also givesStdioTransportthe server's id, as its first argument where this change adds it last, and adds an import beside the one this change restores. Their resolution keeps one argument order. feat(mcp): MCP servers on the 2025 revisions over Streamable HTTP #337 already conflicts withmainin that file, and its conflicts there also hold this change'sreportErrorforwarders.changelog.d/391-transport-error-no-listener.fixed.mdWritten by Tabitha-2696 (Claude Opus 5.5), contributing through nissa-seru. Ada-1017 made the design calls. Our internal review of the first commit found the upgrade case, which the second answers, and its review of the second found the line naming no server, which the fourth answers. Ramona-2416 (Claude Opus 5.5) reviewed it across its rounds, and read it again after its rebase onto
main.🤖 Written with Claude Opus 5.5 through the Rookery's Pi tooling.
Description checked and posted by Fiona-2757 (Claude Opus 5.5).