Skip to content

fix: fail pending requests on disconnect, report a refused reconnect, and give the connection one state - #1

Merged
NickPadilla merged 17 commits into
mainfrom
fix/fail-pending-on-disconnect
Sep 14, 2026
Merged

NickPadilla merged 17 commits into
mainfrom
fix/fail-pending-on-disconnect

Conversation

@NickPadilla

@NickPadilla NickPadilla commented Sep 12, 2026

Copy link
Copy Markdown
Member

A client attached to a server that went away hung — with or without a request in flight — and never recovered. Seen in production; reproduced against a real cluster from structures.

Three defects

Pending requests were never failed on the transport's own reconnect. Only cleanup() failed them, and it ran on an explicit disconnect(), not on a socket close. The reply address a request waited on belonged to a server that could no longer answer, and nothing told the caller, so request() waited forever.

A refused reconnect deactivated the transport blind. A STOMP ERROR frame — a refused connect, or a reconnect presenting a session the server no longer had — was handled by calling rxStomp.deactivate() directly, skipping the manager's deactivation handler. Pending requests weren't failed, the connection went down without a word, and every later call threw a "call connect first" the caller had no way to anticipate. fatalErrors was meant to surface this, but its cleanup lived in a map operator nobody subscribed to.

A sticky connect deleted the caller's credentials. The library replaced the credentials in the caller's own connectHeaders object with the session id, so after a refused reconnect the caller could not connect() again with the ConnectionInfo it already held — which is precisely what it is expected to do, since the library holds no credentials.

Why the shape changed, not just the bugs

The first cut fixed those three by adding a second callback and more readable state to the manager. Review of that found five more defects, all the same shape: the manager and the EventBus shared the connection's lifecycle through two callbacks and five public fields that the EventBus read back mid-callback to reconstruct what had happened. fatalErrors emitted while the client was still set, so a handler that called connect() was refused; deactivate() re-entered during its own teardown and reported the same refusal twice; a deliberate disconnect() was indistinguishable from a loss; a failed stream's teardown queued a CANCEL that rx-stomp replayed after reconnect; three delete loops fought over one shared header map.

So the final commit gives the connection one state and one event stream instead.

The contract now

StompConnectionManager is inactive → active → closing → inactive, with a single private close(error?, force?) as the only way out of active. It marks itself closing before the socket watcher can mistake the close for a loss, returns to inactive before emitting, and rejects a still-pending activate() with the same error after that. Overlapping calls share the in-flight promise. It reports through events:

  • lost — the socket of an established connection closed. Reconnection carries on underneath; anything in flight is failed now with ConnectionLostError, distinct from an application error so a caller can retry an idempotent call.
  • closed { error? } — emitted exactly once per activation, with the manager already inactive, so a handler may call connect() right there. error is present when the server refused us (ConnectionRefusedError, carrying the server's own message and frame) or maxConnectionAttempts was reached; absent when disconnect() was called.

The EventBus is a policy layer on that stream: fatalErrors emits the closed error only after a successful connect() — before that, the rejected connect() promise carries it, as before. It never touches rxStomp; it uses manager.publish()/watch().

  • CONNECT headers are built fresh per attempt — the session alone on a sticky reconnect, the caller's headers otherwise (copied if static, called again if a function). Nothing is mutated, so connectHeaders can say exactly what happens on reconnect in each mode.
  • A server ERROR frame closes with force — nothing left to say, and the report no longer waits on the peer's close (the sticky restart test dropped from ~3 min to ~40 s).
  • ConnectionRefusedError replaces guessing AuthenticationError from the message wording. connect() rejects with typed ContinuumErrors, not strings.
  • requestStream marks a request finished when the connection fails it, so its teardown never sends a CANCEL into a dead client.
  • No default request timeout — long-running requests are legitimate, and with pending requests failed on close the hang it would have guarded is gone. No re-authentication on the caller's behalf — a stored token may be stale; recovery is the caller's decision.

Tests

StickySession_GatewayRestart pins the contract against the real gateway: the in-flight request fails on close with ConnectionLostError; the refused reconnect is reported on fatalErrors as a ConnectionRefusedError exactly once, with the connection already down; and the recovery connect() is made from inside the fatalErrors handler, which is what pins the ordering. Depends on the delayed testMethodWithDelay in the companion continuum-framework PR (merged) to hold a request open deterministically.

ConnectionContract pins the second review's nine findings against a scripted in-process STOMP gateway (test/ScriptedGateway.ts), because a real gateway cannot be made to produce them on demand: a peer that never answers a DISCONNECT, a CONNECTED frame with no or malformed connected-info, a browser socket whose terminate() is synchronous, an instance with no memory of a session. Every case was committed failing (be4a423) before its fix (2de1cc9):

  • disconnect(true) breaks a graceful close the server is not answering — an overlapping close() now contributes its force/reason instead of being folded into the first
  • a stream failed by socket loss still cancels its server-side invocation once reconnected — the cancel is owed while the session lives, and goes out on the replacement connection
  • a CONNECTED frame the client cannot use ends in a DISCONNECT, not a discarded socket, and the close is deferred out of rx-stomp's callback (browser terminate() is synchronous)
  • credentials are cleared, in place, once CONNECTED arrives
  • maxConnectionAttempts bounds each reconnect, not the life of the connection
  • a rejecting connectHeaders() rejects connect() / reports on fatalErrors rather than hanging with the manager active
  • malformed connected-info is treated as absent, not thrown from a subscriber
  • errors carry their names
  • a registered service is served again after fatalErrors → connect()observe() now follows the connection across activations

A third pass found ten more, again pinned failing first (f7d9b18) then fixed (next commit), after teaching the scripted gateway three things the real one does: answer a reply-to-less SEND to nothing with an ERROR, double backslashes in CONNECTED values (vertx-stomp-lite HeaderCodec), and answer a DISCONNECT with an ERROR:

  • the cancel for a stream failed by socket loss carries a reply-to, so the recovered connection is not ended by the gateway's ERROR
  • a reconnect attempt abandoned by disconnect() cannot touch the connection that replaced it
  • an ERROR during a requested disconnect() is not reported as fatal
  • connected-info survives the gateway's CONNECTED escaping; JSON that is not an object is refused, not thrown
  • an observe() made while connect() is pending is subscribed once; one made before connect() attaches when there is a connection; one result subscribed twice is one subscription on the wire
  • connectTimeoutMs (new, default 10 s) bounds a handshake that never completes; a polite close is bounded at 5 s before the socket is discarded; an unusable host rejects connect() instead of hanging inside stompjs

A fourth pass found eight more, pinned failing first (eeba723) then fixed, after the scripted gateway learned to stall at the TCP level (not even a close frame answered) and to invoke a hosted service:

  • abandoning a stalled attempt discards the socket (discardWebsocketOnCommFailure) instead of paying the socket library's ~30 s close timeout on top of connectTimeoutMs; the watcher is cleared on close; zero is not "off"; each attempt reports its own first error
  • a hosted service's reply landing during a requested disconnect() is logged, not thrown from a handler nothing awaits
  • requestStream re-checks the connection when subscribed, so a stream created before a close cannot set up the reply address of a dead connection for the next one to inherit
  • disconnect() during a server-started close is still a requested close — not fatal, no reconnect mid-shutdown
  • an unknown control value fails the request rather than the process
  • a URL the WebSocket constructor refuses ends the attempt with the reason (socket factory) instead of hanging inside stompjs; a JSON-array connected-info is refused

A fifth pass, weighted at the double's fidelity, found four more (pinned failing first in e710827, fixed next) and one merge-blocker: the committed package.json still named @mindignited/continuum-client; it is now @kinotic/continuum-client, what npm publishes and every consumer pins.

  • a hosted service whose return value cannot be serialised, or whose promise rejects with no value, answers with an error instead of ending the process
  • a backslash in a credential is doubled on the way out — the gateway decodes CONNECT values though STOMP says not to, and dropped the frame silently
  • connectTimeoutMs covers the caller's connectHeaders() too
  • exhausting maxConnectionAttempts after a working connection is pinned as a fatalErrors emission
  • heartbeatStrategy: Worker, so a throttled hidden tab keeps its heartbeats; ConnectionLostError.cause carries the close error so a caller can tell a request the server refused from a socket loss before retrying; docs for fatalErrors, disconnect() and connectTimeoutMs now state the full behaviour

Full suite against the published develop snapshot: 10/10 files, 71/71 tests.

Left deliberately, noted for continuum-framework: the gateway's HeaderCodec escapes CONNECT/CONNECTED values though STOMP 1.2 says those frames are unescaped (the client compensates in both directions; the two must change together); a JS-hosted service's reply to a caller that has already hung up is answered with an ERROR that ends the host's connection (EndpointConnectionHandler FIXME) — the client now at least reports that on fatalErrors instead of dying silently; a SEND whose destination is not a valid java.net.URI pauses the gateway connection without resuming it (DefaultStompServerHandler.send); frame-limit violations arrive as bare closes with no ERROR. Heartbeat timing (120000/30000) is unchanged from 2.14, which puts silent-partition detection at 4–6 minutes — a deliberate non-change in a fix release.

Version

3.0.0. connect() rejects with typed errors rather than strings, requests that used to wait on a lost connection now fail, and fatalErrors carries a different contract — consumers have to look. No IEventBus signature changed; ConnectionInfo gains the optional connectTimeoutMs.

Harness

Every test that starts a gateway does so through test/GatewayContainer.ts, with one rule: CONTINUUM_GATEWAY_IMAGE names the image, a supplied image is never pulled (a local build is not replaced by whatever CI last published mid-run), the default snapshot always is. The default is mindsignited/continuum-gateway-server:3.1.0-SNAPSHOT — the namespace the suite pointed at before does not exist on Docker Hub. The global teardown no longer stops a container it never started, and the test proxy targets org.kinotic, the package develop actually publishes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH

NickPadilla and others added 5 commits September 12, 2026 11:50
A client attached to a server that went away hung, with or without a request in
flight, and never recovered. Three defects in the connection lifecycle:

Requests waiting on a reply were only ever failed by cleanup(), which ran on an
explicit disconnect and never on the transport's own reconnect. When the socket
closed the reply address the request was waiting on belonged to a server that could
no longer answer, and nothing told the caller, so request() waited forever.

A STOMP ERROR frame - a refused connect, or a reconnect presenting a session the
server no longer had - was handled by calling rxStomp.deactivate() directly, which
skipped the manager's deactivation handler. Pending requests were not failed, the
connection was torn down without a word, and every later call threw a "call connect
first" the caller had no way to anticipate. fatalErrors was meant to surface this,
but its cleanup lived in a map operator nobody subscribed to, so it never ran.

On a sticky connect the library replaced the credentials in the caller's own
connectHeaders object with the session id. After a refused reconnect the caller
therefore could not connect again with the ConnectionInfo it already held - which
is precisely what it is expected to do, since the library holds no credentials.

Now: an established connection's socket closing fails every pending request with
ConnectionLostError, distinct from an application error so a caller can retry an
idempotent call. A refused connect tears down through the manager's deactivate(),
and once the connection is actually down fatalErrors emits a typed error - an
AuthenticationError for a refused session or credential, a ContinuumError when the
caller's maxConnectionAttempts bound is exhausted after a working connection. It is a
hot subject, emitting whether or not anyone subscribed. A refused initial connect
still reaches the caller only as the rejected connect() promise, as before. And the
caller's connectHeaders object is copied, never mutated.

No default request timeout: long running requests are legitimate, and with pending
requests failed on close the hang it would have guarded against is gone. The library
does not re-authenticate on the caller's behalf either; a stored token may be stale,
and recovery is the caller's decision.

StickySession_GatewayRestart pins the contract against a gateway built from continuum
develop: the in-flight request is failed on close with ConnectionLostError, the
refused reconnect is reported as an AuthenticationError with the connection already
down, and connect() with the original ConnectionInfo succeeds with a fresh session.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
The suite is pinned to mindignited/continuum-gateway-server:latest, which is private,
so on a machine without login every gateway backed test dies in setup before running.
CONTINUUM_GATEWAY_IMAGE now names the image, with the default unchanged, so a gateway
built from continuum develop can be used instead.

A supplied image is never pulled. CI publishes 3.1.0-SNAPSHOT to Docker Hub, and
alwaysPull replaced a local build carrying a fix not yet on develop with whatever CI
last published - which is how a freshly rebuilt gateway went back to crashing on boot
mid run. The pull policy stays alwaysPull for the default remote image only.

The global teardown stopped a container that was never started when the gateway was
not enabled, turning every such run into a failed exit regardless of its tests. It
now returns when there is nothing to stop.

ContinuumUnavailable asserted the exhausted-attempts rejection was a bare Error. It
is a ConnectionLostError now, with the same message; the assertion says so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
The test service proxy and one raw event address still named
org.mindignited.continuum.gatewayserver.clienttest, the package from before the
rename. Continuum develop publishes it as org.kinotic, so against a gateway built
from develop every RPC through the proxy failed with NO_HANDLERS - five tests
across four files. Both sites now use org.kinotic, and the full suite passes:
nine files, thirty nine tests, against a gateway built from develop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
The harness defaulted to mindignited/continuum-gateway-server:latest - the org's
pre-rename namespace. That repository no longer exists on Docker Hub, so every CI run
has died in setup since it went away; this branch was simply the first to show it.
The org's images live under mindsignited, which is public, and continuum CI publishes
3.1.0-SNAPSHOT there on every push to develop.

That snapshot is now the default everywhere, including the sticky session test, so
the suite runs against the same gateway structures runs against with no registry
login. The default keeps alwaysPull, so CI always tests the freshest develop
gateway; a developer testing a local build passes CONTINUUM_GATEWAY_IMAGE, which is
never pulled.

Green once the profile yml fix reaches develop and the snapshot is republished;
until then that image cannot boot under the clienttest profile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
The manager and the EventBus shared the connection's lifecycle through two
callbacks and five public fields the EventBus read back mid-callback to work
out what had happened. Every defect the review found was a symptom of that:
fatalErrors emitted while the client was still set, so a handler that called
connect() was refused; deactivate() re-entered during its own teardown and
reported the same refusal twice; a deliberate disconnect() looked like a loss;
a failed stream's teardown queued a CANCEL that rx-stomp replayed after
reconnect; and three delete loops over one shared header map.

StompConnectionManager is now inactive -> active -> closing -> inactive with a
single private close(): it marks itself closing before the socket watcher can
mistake the close for a loss, returns to inactive before emitting `closed`, and
rejects a pending activate() with the same error after that. Overlapping calls
share the in-flight promise. Its `events` stream - `lost` and `closed{error?}` -
is all the EventBus consumes; nothing is read back. CONNECT headers are built
fresh per attempt: the session alone on a sticky reconnect, the caller's
headers otherwise, never mutated. A server ERROR frame closes with force, so the
report does not wait on the peer.

ConnectionRefusedError carries the server's message and frame in place of the
/authenticat/i guess. connect() rejects with typed errors, not strings.
requestStream marks a request finished when the connection fails it, so its
teardown never sends a cancel into a dead client.

Every test starts the gateway through one helper with one pull policy. The
sticky restart test now reconnects from inside the fatalErrors handler and
asserts a single emission, which is what pins the ordering.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
@NickPadilla NickPadilla changed the title fix: fail pending requests on disconnect and report a refused reconnect fix: fail pending requests on disconnect, report a refused reconnect, and give the connection one state Sep 12, 2026
NickPadilla and others added 12 commits September 12, 2026 17:03
connect() rejects with typed errors rather than strings, requests that were
left waiting on a lost connection are now failed, and fatalErrors carries a
different contract. Consumers have to look, so a major.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
… first

Ten cases, one per way the second review found a client able to hang, leak or
lie, each driven against an in-process STOMP server the test scripts frame by
frame. A real gateway cannot be made to produce these on demand - a peer that
never answers a DISCONNECT, a CONNECTED frame with no or malformed
connected-info, a browser socket whose terminate() is synchronous, an instance
with no memory of a session - and they are exactly the situations that decide
whether a client hangs.

Every case fails on the behaviour it describes as of this commit:

  disconnect(true) is folded into a graceful close it was meant to break
  a stream failed on socket loss never cancels its invocation on reconnect
  a bad CONNECTED frame is discarded, not DISCONNECTed, so the session leaks
  credentials stay on the client after a sticky session is established
  maxConnectionAttempts is counted over the connection's life, not per reconnect
  a rejecting connectHeaders() leaves connect() hanging with the manager active
  a rejecting connectHeaders() on reconnect is never reported
  a malformed connected-info hangs connect() with an uncaught SyntaxError
  errors are named Error, so a stringified rejection loses its type
  a registered service is not served again after reconnecting from fatalErrors

Passing is what fixed means.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
Each of these is pinned by a case in ConnectionContract.test.ts that failed
before this commit and passes after it.

close(): a call that overlaps a close in progress now contributes what it
knows - its reason, if the first had none, and force, which stompjs honours
while already deactivating - instead of being folded into the first. So
disconnect(true) breaks a polite close that a half-open peer will never answer.

A request the connection fails is not finished: with a sticky session the
server is still running it, so its cancel is still owed and goes out on the
connection that replaces the lost one. Marking it finished had leaked one
server-side invocation per socket loss until the session timed out.

A close asked for from inside one of rx-stomp's own callbacks is deferred a
tick. A browser WebSocket has no terminate(); the one stompjs installs runs
the close handlers synchronously, which from inside onConnect tore the client
down before rx-stomp had finished bringing it up. And a CONNECTED frame the
client cannot use now ends in a DISCONNECT rather than a discarded socket: the
server had accepted a session and would otherwise hold it until timeout.

The credentials that opened a connection are cleared once CONNECTED arrives,
in place, because stompjs keeps its own reference to the object. A
connectHeaders function that throws closes the connection with the reason,
where before stompjs let the rejection escape and everything hung, still
active. A connected-info header that is not JSON is treated as absent rather
than thrown from inside a subscriber. Each reconnect gets the full
maxConnectionAttempts rather than spending a shared budget across the life of
the connection. Errors carry their names, so a stringified rejection keeps its
type.

observe() follows the connection: it detaches on closed and attaches again on
each successful connect(), so a registered service is served on whatever
connection the caller establishes next, which is what makes recovering from
fatalErrors by calling connect() actually recover.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
That is what npm publishes and what every consumer pins; the README was the
last place still naming the old scope.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
The scripted gateway now does what the real one does in the three places it
had been lenient: a SEND with no reply-to to a destination nothing handles is
answered with an ERROR that ends the connection, every backslash in a CONNECTED
header value is doubled as vertx-stomp-lite's HeaderCodec does, and a
DISCONNECT can be met with an ERROR rather than a RECEIPT. Destinations can be
given handlers, as a registered service would be.

Eleven cases, each failing on the behaviour it describes as of this commit:

  the cancel sent for a stream failed by socket loss has no reply-to, so the
    gateway ends the recovered connection with an ERROR
  a reconnect attempt abandoned by disconnect() acts on the connection that
    replaced it when the old connectHeaders() finally answers
  an ERROR frame during a requested disconnect is reported as fatal
  connected-info with a backslash-escaped quote is unreadable after the
    gateway's doubling, so an authenticated participant is refused
  an observe() made while connect() is pending is subscribed twice, with the
    first torn down
  a peer that accepts the socket and never completes the handshake leaves
    connect() pending forever (connectTimeoutMs is declared here, honoured next)
  connected-info that is JSON but not an object throws from a subscriber
  a polite close a peer never acknowledges keeps connect() pending
  a service registered before connect() throws and leaves a zombie supervisor
  an unusable host is a synchronous throw inside stompjs and connect() hangs
  one observe() result subscribed twice is two subscriptions on the wire

Two earlier cases are tightened so that removing their fix fails them again:
the bad-CONNECTED case now has a subscription in flight when the frame arrives,
and the credentials case checks the copy stompjs's handler holds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
Each pinned by a case in ConnectionContract.test.ts committed failing in
f7d9b18; all pass after this, and the full suite is 10 files, 59 tests.

The cancel sent for a stream failed by socket loss carries a reply-to. A
gateway with nothing to cancel answers on it, and the answer lands on a
correlation nothing is waiting for; without one its only way to say so was an
ERROR that ended the connection just recovered.

A reconnect attempt waits on jitter and on the caller's connectHeaders(); if
the caller disconnects and connects again meanwhile, the old attempt resumes
against a manager that has moved on. Every step after an await now checks it
still belongs to the current activation and otherwise does nothing - it had
been re-calling the old credentials function and, when that failed, closing the
new connection. The reply-to-id bookkeeping moved out of the header builder for
the same reason.

deactivate() latches that the close was requested; from then on nothing that
happens during it - an ERROR for something the server was still processing -
is the reason it ended, so a close the caller asked for is never fatal.

The gateway's codec doubles every backslash in a CONNECTED header value though
STOMP says CONNECTED is not escaped, so connected-info with a JSON escape in it
was unreadable and an authenticated participant refused. The doubling is undone
before parsing. Parsed JSON that is not an object is refused rather than thrown.

An observe() made while connect() was pending is not attached a second time
when connect() resolves: rx-stomp made it as the client came up, and the
re-attach tore that down with anything already dispatched to it. And it no
longer needs a connection to be made at all - a service registered before
connect() attaches when there is a connection, as the doc had already claimed.
One observe() result subscribed twice is one subscription on the wire again.

connectTimeoutMs, default 10 seconds, bounds an attempt against a peer that
accepts the socket and never completes the handshake. A polite close waits 5
seconds for the peer's part before the socket is discarded. A URL the
WebSocket constructor would throw on is refused before anything starts, where
before the throw escaped inside stompjs and connect() never settled.

Not test-pinnable any more: closeAfterCallback's deferral. The path that made
a synchronous browser terminate() observable now closes politely, so removing
the deferral fails nothing; it stays as documented defence for a forced close
from inside a callback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
The scripted gateway can now stall - accept the socket and then read and write
nothing, not even the answer to a close frame - and can deliver a MESSAGE to a
service subscribed by its base resource.

Eight cases, each failing on the behaviour it describes as of this commit:

  a stalled handshake is abandoned at the socket library's close timeout, some
    30 seconds after connectTimeoutMs, because the abandon is a polite close
  connectTimeoutMs of zero switches the bound off
  a hosted service's reply during a requested disconnect throws from send()
    inside a handler nothing awaits, and escapes the process
  a stream created before a close, subscribed after it, sets up the reply
    address of a connection that no longer exists; the next connection's
    requests are never answered
  disconnect() during a close the server started is reported as fatal
  a control value the client does not know throws inside next() and the
    request hangs
  a host the WebSocket constructor refuses throws inside stompjs and connect()
    hangs
  connected-info that is a JSON array is accepted

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
Each pinned by a case in ConnectionContract.test.ts committed failing in
eeba723; all pass after this, and the full suite is 10 files, 67 tests.

Abandoning a stalled attempt discards the socket. stompjs closed it politely,
and against a peer that has stopped reading that is the socket library's own
close timeout - some 30 seconds - charged to every attempt on top of
connectTimeoutMs. The watcher stompjs arms for that timeout is cleared on
close, where before it stayed armed into nothing and kept the process alive.
Zero for connectTimeoutMs no longer means no bound. And each attempt reports
its own first error: abandoning an attempt closes the socket, which the socket
library reports as an error of its own, after the real one.

A hosted service's reply can land after its host asked to disconnect, when
there is nowhere to send it. That is logged, where before send() threw inside
a handler nothing awaited and the process took the rejection.

requestStream checks the connection again when subscribed, not only when
created: an Observable made while connected and subscribed after the close was
setting up the reply address of a connection that no longer existed, which the
next connection then inherited, and its requests were never answered. A
control value the client does not know now fails the request; thrown from
inside next() it reached no one but the process.

deactivate() latches that the close was requested whenever one is in progress,
not only when the connection was still up: a close the server started that the
caller then asks for too is a requested close, and is not reported as fatal to
a recovery handler in the middle of the caller's shutdown. A connect() still
pending learns the real reason either way.

A URL that parses but the WebSocket constructor refuses - a fragment, say, or
no WebSocket at all - threw inside a stompjs call nothing awaited, and connect()
hung. The socket is now created through a factory that catches that and ends
the attempt with the reason. connected-info that is a JSON array is refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
What npm publishes and every consumer pins. The README was corrected in
0ebfc5a; the manifest the publish workflow reads had not been.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
The scripted gateway decodes CONNECT header values as the real one does -
vertx-stomp-lite runs HeaderCodec.decode on them though STOMP says CONNECT is
not escaped - and drops the frame on the floor, socket left open, for an
escape it does not know, which is what the real one does too.

Three cases fail on the behaviour they describe as of this commit:

  a hosted service whose return value cannot be serialised, or whose promise
    rejects with no value, throws from inside a promise handler nothing awaits
  a backslash in a credential is sent verbatim, decoded by the gateway as an
    escape, and the CONNECT is dropped; the connect times out on every attempt
  a connectHeaders() that never settles is not covered by connectTimeoutMs, so
    connect() hangs with the attempt counted once and never concluded

A fourth pins what no test had: exhausting maxConnectionAttempts after a
working connection is reported on fatalErrors, once, with the connection down.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
Each pinned by a case in ConnectionContract.test.ts committed failing in
e710827; all pass after this, and the full suite is 10 files, 71 tests.

A hosted service's reply is made inside a promise handler nothing awaits. A
return value that cannot be serialised, or a rejection with no value, threw
there and the process took it; the chain now ends in a catch that answers
with an error, and a valueless rejection is answered as unknown.

STOMP says CONNECT values are not escaped and stompjs sends them as they are;
the gateway's parser decodes them all the same and drops the frame on an
escape it does not know, so a backslash in a credential - a Windows domain
login - meant a CONNECT that was never answered, timed out, and tried again,
with no word why. Backslashes are doubled on the way out, the counterpart of
the CONNECTED handling.

The caller's connectHeaders() is part of the attempt, so connectTimeoutMs
covers it: a token endpoint that never answers is a stalled attempt like any
other, where before it was a hang the bound never saw.

Outgoing heartbeats come from a worker where there is one, so a browser's
throttling of a hidden tab's timers does not have the gateway close the
socket for silence; Node falls back to an interval.

ConnectionLostError carries the error the connection closed with as its
cause: one of the requests failed by the close may be what the server
refused, and the gateway ends the whole connection over a single SEND it will
not accept, so retrying that one ends the next connection too. The docs for
fatalErrors now name every error it can carry and why, disconnect() no longer
claims to clear subscriptions that are in fact remade on the next connect(),
and connectTimeoutMs says what it covers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
STOMP escapes header values in every frame but CONNECT and CONNECTED, and
stompjs undoes that only once CONNECTED has arrived on a socket. A refusal is
an ERROR that arrives instead of CONNECTED, so its message reached the caller
with the gateway's escapes still in it - a colon as \c - which is how it
showed up in structures' single-node restart test once it ran on this client.
The headers of an ERROR that arrives before CONNECTED are unescaped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
@NickPadilla
NickPadilla merged commit 25ba73e into main Sep 14, 2026
1 check passed
@NickPadilla
NickPadilla deleted the fix/fail-pending-on-disconnect branch September 14, 2026 01:18
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