Describe the bug
In the TypeScript SDK, the ws.onerror handler treats every websocket error as a connect failure, regardless of whether the connection was already established:
https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/bindings-typescript/src/sdk/db_connection_impl.ts
this.ws.onclose = () => {
this.isActive = false;
this.#emitter.emit('disconnect', this);
};
this.ws.onerror = (e: ErrorEvent) => {
this.isActive = false;
this.#emitter.emit('connectError', this, e);
};
For an error on an already established connection (one that received InitialConnection and fired onConnect), three things go wrong:
onConnectError fires for a mid-session transport error. The builder documents onConnectError as a connect-time failure callback, and consumers commonly wire it to boot-time recovery (retry auth, clear stored token, show a login screen). A mid-session error takes that path instead of the disconnect/reconnect path.
isActive = false disables the outbound send path (#sendEncodedMessage checks this.ws && this.isActive) while the socket may still be OPEN. Reducer and procedure calls are silently queued from that point on, and nothing tears the socket down, so onDisconnect never fires and no reconnect logic runs. The client stalls in a zombie state until the browser eventually delivers onclose, if it ever does.
- Even when
onclose does arrive, the disconnect emit carries no error argument, so the error that ended the connection is lost.
This contradicts the SDK's own documented contract on onDisconnect in db_connection_builder.ts:
Registers a callback to run when a DbConnection whose connection initially succeeded is disconnected, either after a DbConnection.disconnect() call or due to an error.
If the connection ended because of an error, the error is passed to the callback.
Note that this does not trigger if build fails or in cases where DbConnectionBuilder.onConnectError would trigger.
The docs draw a clear line: connect-time failures go to onConnectError, post-connect errors go to onDisconnect with the error attached. The implementation routes both to onConnectError and never passes an error to onDisconnect.
We hit this in production (Tidefall, spacetimedb@2.7.1): a mid-session websocket error fired our onConnectError handler, which is wired to login recovery, while our reconnect logic (wired to onDisconnect) never ran. The player kept an apparently live UI whose reducer calls were being silently queued.
Expected behavior
A websocket error on an established connection should surface through the disconnect path: the socket is torn down, onDisconnect fires with the error, and existing reconnect handling takes over. onConnectError should only fire for failures before the initial connection succeeds.
Suggested fix
Track whether InitialConnection was received. In onerror, if the connection was established, record the error and close the socket so the existing onclose path emits disconnect, and pass the recorded error through to the disconnect emit:
this.ws.onclose = () => {
this.isActive = false;
this.#emitter.emit('disconnect', this, this.#connectionError);
};
this.ws.onerror = (e: ErrorEvent) => {
this.isActive = false;
if (this.#everConnected) {
this.#connectionError = e;
this.ws?.close();
return;
}
this.#emitter.emit('connectError', this, e);
};
onDisconnect callbacks already accept an optional error argument, so passing it is additive and matches the documented behavior.
Describe the bug
In the TypeScript SDK, the
ws.onerrorhandler treats every websocket error as a connect failure, regardless of whether the connection was already established:https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/bindings-typescript/src/sdk/db_connection_impl.ts
For an error on an already established connection (one that received
InitialConnectionand firedonConnect), three things go wrong:onConnectErrorfires for a mid-session transport error. The builder documentsonConnectErroras a connect-time failure callback, and consumers commonly wire it to boot-time recovery (retry auth, clear stored token, show a login screen). A mid-session error takes that path instead of the disconnect/reconnect path.isActive = falsedisables the outbound send path (#sendEncodedMessagechecksthis.ws && this.isActive) while the socket may still beOPEN. Reducer and procedure calls are silently queued from that point on, and nothing tears the socket down, soonDisconnectnever fires and no reconnect logic runs. The client stalls in a zombie state until the browser eventually deliversonclose, if it ever does.onclosedoes arrive, thedisconnectemit carries no error argument, so the error that ended the connection is lost.This contradicts the SDK's own documented contract on
onDisconnectindb_connection_builder.ts:The docs draw a clear line: connect-time failures go to
onConnectError, post-connect errors go toonDisconnectwith the error attached. The implementation routes both toonConnectErrorand never passes an error toonDisconnect.We hit this in production (Tidefall,
spacetimedb@2.7.1): a mid-session websocket error fired ouronConnectErrorhandler, which is wired to login recovery, while our reconnect logic (wired toonDisconnect) never ran. The player kept an apparently live UI whose reducer calls were being silently queued.Expected behavior
A websocket error on an established connection should surface through the disconnect path: the socket is torn down,
onDisconnectfires with the error, and existing reconnect handling takes over.onConnectErrorshould only fire for failures before the initial connection succeeds.Suggested fix
Track whether
InitialConnectionwas received. Inonerror, if the connection was established, record the error and close the socket so the existingonclosepath emitsdisconnect, and pass the recorded error through to thedisconnectemit:onDisconnectcallbacks already accept an optional error argument, so passing it is additive and matches the documented behavior.