fix: fail pending requests on disconnect, report a refused reconnect, and give the connection one state - #1
Merged
Conversation
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
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
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 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 explicitdisconnect(), 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, sorequest()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.fatalErrorswas meant to surface this, but its cleanup lived in amapoperator nobody subscribed to.A sticky connect deleted the caller's credentials. The library replaced the credentials in the caller's own
connectHeadersobject with the session id, so after a refused reconnect the caller could notconnect()again with theConnectionInfoit 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.
fatalErrorsemitted while the client was still set, so a handler that calledconnect()was refused;deactivate()re-entered during its own teardown and reported the same refusal twice; a deliberatedisconnect()was indistinguishable from a loss; a failed stream's teardown queued a CANCEL that rx-stomp replayed after reconnect; threedeleteloops fought over one shared header map.So the final commit gives the connection one state and one event stream instead.
The contract now
StompConnectionManagerisinactive → active → closing → inactive, with a single privateclose(error?, force?)as the only way out ofactive. It marks itselfclosingbefore the socket watcher can mistake the close for a loss, returns toinactivebefore emitting, and rejects a still-pendingactivate()with the same error after that. Overlapping calls share the in-flight promise. It reports throughevents:lost— the socket of an established connection closed. Reconnection carries on underneath; anything in flight is failed now withConnectionLostError, 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 callconnect()right there.erroris present when the server refused us (ConnectionRefusedError, carrying the server's own message and frame) ormaxConnectionAttemptswas reached; absent whendisconnect()was called.The EventBus is a policy layer on that stream:
fatalErrorsemits theclosederror only after a successfulconnect()— before that, the rejectedconnect()promise carries it, as before. It never touchesrxStomp; it usesmanager.publish()/watch().connectHeaderscan say exactly what happens on reconnect in each mode.ConnectionRefusedErrorreplaces guessingAuthenticationErrorfrom the message wording.connect()rejects with typedContinuumErrors, not strings.requestStreammarks a request finished when the connection fails it, so its teardown never sends a CANCEL into a dead client.Tests
StickySession_GatewayRestartpins the contract against the real gateway: the in-flight request fails on close withConnectionLostError; the refused reconnect is reported onfatalErrorsas aConnectionRefusedErrorexactly once, with the connection already down; and the recoveryconnect()is made from inside thefatalErrorshandler, which is what pins the ordering. Depends on the delayedtestMethodWithDelayin the companion continuum-framework PR (merged) to hold a request open deterministically.ConnectionContractpins 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 whoseterminate()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 overlappingclose()now contributes itsforce/reason instead of being folded into the firstterminate()is synchronous)maxConnectionAttemptsbounds each reconnect, not the life of the connectionconnectHeaders()rejectsconnect()/ reports onfatalErrorsrather than hanging with the manager activefatalErrors → connect()—observe()now follows the connection across activationsA 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-liteHeaderCodec), and answer a DISCONNECT with an ERROR:reply-to, so the recovered connection is not ended by the gateway's ERRORdisconnect()cannot touch the connection that replaced itdisconnect()is not reported as fatalobserve()made whileconnect()is pending is subscribed once; one made beforeconnect()attaches when there is a connection; one result subscribed twice is one subscription on the wireconnectTimeoutMs(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 rejectsconnect()instead of hanging inside stompjsA 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:discardWebsocketOnCommFailure) instead of paying the socket library's ~30 s close timeout on top ofconnectTimeoutMs; the watcher is cleared on close; zero is not "off"; each attempt reports its own first errordisconnect()is logged, not thrown from a handler nothing awaitsrequestStreamre-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 inheritdisconnect()during a server-started close is still a requested close — not fatal, no reconnect mid-shutdownconnected-infois refusedA fifth pass, weighted at the double's fidelity, found four more (pinned failing first in
e710827, fixed next) and one merge-blocker: the committedpackage.jsonstill named@mindignited/continuum-client; it is now@kinotic/continuum-client, what npm publishes and every consumer pins.connectTimeoutMscovers the caller'sconnectHeaders()toomaxConnectionAttemptsafter a working connection is pinned as afatalErrorsemissionheartbeatStrategy: Worker, so a throttled hidden tab keeps its heartbeats;ConnectionLostError.causecarries the close error so a caller can tell a request the server refused from a socket loss before retrying; docs forfatalErrors,disconnect()andconnectTimeoutMsnow state the full behaviourFull suite against the published develop snapshot: 10/10 files, 71/71 tests.
Left deliberately, noted for continuum-framework: the gateway's
HeaderCodecescapes 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 (EndpointConnectionHandlerFIXME) — the client now at least reports that onfatalErrorsinstead of dying silently; a SEND whose destination is not a validjava.net.URIpauses 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, andfatalErrorscarries a different contract — consumers have to look. NoIEventBussignature changed;ConnectionInfogains the optionalconnectTimeoutMs.Harness
Every test that starts a gateway does so through
test/GatewayContainer.ts, with one rule:CONTINUUM_GATEWAY_IMAGEnames 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 ismindsignited/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 targetsorg.kinotic, the package develop actually publishes.🤖 Generated with Claude Code
https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH