Skip to content

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 into
anima-research:mainfrom
nissa-seru:fix/391-bare-connection-transport-error
Oct 18, 2026
Merged

nissa-seru merged 5 commits into
anima-research:mainfrom
nissa-seru:fix/391-bare-connection-transport-error

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

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 McplServerConnection with 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

  • A bare connection (A bare McplServerConnection throws on any transport error when nothing listens for 'error' #391). setupLifecycle passed 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. But McplServerConnection is exported (src/index.ts), and a probe, a playtest or a harness holding one often attaches none. In a probe on main 91e70eda, a separate process holding a bare WebSocket connection died with Unhandled 'error' event when its server sent a frame with a reserved opcode.
  • A transport before the handshake listens. This reaches further than A bare McplServerConnection throws on any transport error when nothing listens for 'error' #391, since it ends framework hosts. WebSocketTransport passed ws's 'error' on with no listener check. ws's setSocket unshifts any bytes that came with the 101 upgrade response before it emits 'open', and the connection constructs the transport in that 'open' handler. ws raises a bad frame's error on a nextTick, and Node runs nextTick callbacks before the promise continuation where handshake() 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. With main's src/mcpl/ at 0d19f7c8, the fixture test/fixtures/mcpl-ws-upgrade-error-probe.mjs exits with Unhandled 'error' event and RangeError: Invalid WebSocket frame: invalid opcode 3. StdioTransport passed the child's own 'error' on the same way, though we found nothing that triggers it in that window.

Changes

  • McplTransport.reportError gives 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.
  • Each transport keeps its server's id from the config, as an optional constructor argument, so that line names the server.
  • 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's reportInboundFailure, 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).
  • A transport error with no connection listener is logged as [mcpl] <server>: transport error: <message>. With a listener, the listener gets the same Error as before, and nothing is logged. reportInboundFailure logs its own line as before, and hands its error to the helper.
  • Changelog fragment 391-transport-error-no-listener.fixed.md.

What each part is for

  • reportError keeps 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.
  • The server's id in its line. In a host with several servers, a line that names none can't be acted on, and before the handshake listens that line is the only record of the error. The argument is optional so that a test's stand-in transport constructs as before.
  • emitToErrorListeners puts 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.
  • The log line for a transport error with no connection listener is what a bare connection's holder sees in place of the crash.
  • Not kept. This change had a process test of a bare WebSocket connection, with its fixture 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

  • A WebSocket server that sends a malformed frame with its upgrade response no longer ends the host. The error is logged with the server's id, and the connect is refused. ws reads nothing more once it has failed the connection, so the refusal comes when the server closes the connection, as a WebSocket server does after answering the close frame ws sends. In the fixture's runs at this head, the refusal came in 11 to 14 ms over three runs when the server closes (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 to reconnect, as it does any connect that fails before its handshake (test/mcpl-reconnect-events.test.ts).
  • A read error on a stdio child's stderr with nothing listening is logged, where it was dropped. It ends nothing.
  • Nothing else changes for a host the framework wires. A transport error is still traced as mcpl:server-error.
  • A tool holding a bare connection survives a transport error, and logs it. After a WebSocket error, ws closes the socket, and the connection's '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:

  • The no-listener test. With no 'error' listener on the connection, a transport error doesn't throw and is logged.
  • The listener test. With one, the listener gets the error and nothing is logged. It's a control, which main passes too.
  • The throwing-listener test. A listener that throws on a transport error is caught and logged, with the error it was handling.
  • The stand-in transports test. A stand-in child, its stderr and a stand-in socket each emit '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.
  • The upgrade process test. A separate process connecting to a server that sends that frame with its upgrade response, then answers ws's close frame and ends the connection (fixture 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 --noEmit is clean. This file, test/mcpl-request-handler-errors.test.ts, mcpl-transport-close.test.ts and mcpl-ws-transport.test.ts pass 109 of 109.

  • Red-first. With main's src/mcpl/server-connection.ts and src/mcpl/transport.ts at 0d19f7c8 and 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:

    Piece taken out Caught by
    reportError's listener check (it always emits) the stand-in transports test, the upgrade process test, and fix(mcpl): SIGTERM a stdio child whose stdin or stdout fails #384's stderr read-error test
    reportError's log line the stand-in transports test, and the upgrade process test
    the server's id in reportError's line the stand-in transports test, and the upgrade process test
    reportError in the ws 'error' forwarder (a plain emit instead) the stand-in transports test, and the upgrade process test
    reportError in the child 'error' forwarder (a plain emit instead) the stand-in transports test
    reportError in the stderr read-error listener (an emit only when something listens) the stand-in transports test
    the helper's listener check the no-listener test
    the log line for a transport error with no listener the no-listener test
    the helper's catch of a throwing listener the throwing-listener test, and fix(mcpl): contain inbound request handler failures #208's test of a throwing 'error' listener
    the error being handled, in the listener-failed line the throwing-listener test
    reportInboundFailure handing its error to the helper 8 of fix(mcpl): contain inbound request handler failures #208's tests
    the same, with a plain emit in its place fix(mcpl): contain inbound request handler failures #208's test of a throwing 'error' listener, and its separate stdio host in throwing-listener mode

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

Not verified

  • No real MCPL server. The servers here are the tests' own scripts, and we ran no server in the ecosystem against this.
  • A connect to a server that never closes the connection after ws's close frame waits about 30 s before it's refused, until ws's close timeout or the handshake's own timeout ends it. This change doesn't shorten that wait, and the suite doesn't run that case, since it would hold a test for 30 s. The two runs above are the fixture run by hand.

Merge notes


  • Changelog fragment added — changelog.d/391-transport-error-no-listener.fixed.md

Written 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).

Tabitha-2696 and others added 5 commits October 18, 2026 05:08
… 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>
@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 823beba 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
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.
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.

A bare McplServerConnection throws on any transport error when nothing listens for 'error'

1 participant