Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ While pre-1.0, the public API may change between 0.x releases.
applied cursor. ID-scoped receipts (`committed`/`rejected`/`page`) still
settle their waiters from a stale socket — they are not re-covered by any
replay — but never advance the cursor.
- **`WebSocketTransport.close()` is now revivable (ADR-0020).** A later
`connect()` clears the intentional-close latch, restoring auto-reconnect and
`onClosed` delivery on a reused transport. Previously `close()` was permanent
across a `connect()`: a revived transport (connection pools reuse instances)
silently lost both. Code that relied on `close()` being permanently terminal
must not re-`connect()` the same instance.
- **The injectable `open` gains an optional `AbortSignal` (ADR-0020).**
Additive and backward-compatible — existing openers compile and run
unchanged — but a custom opener should honour it (close its socket, reject)
so `close()` during an in-flight handshake frees a still-CONNECTING socket.

### Fixed

Expand All @@ -61,6 +71,20 @@ While pre-1.0, the public API may change between 0.x releases.
a benign no-op — the socket's `webSocketClose` already tore down its subs.
Covers the `Broadcaster` egress path too (it routes through the same `#send`).
Outbound-only; no state impact, no behavior change for OPEN sockets (issue #40).
- **`connect()` never resolves disconnected (ADR-0020, issue #37).** The
close-epoch discard previously `return`ed, resolving `connect()` with no
socket adopted; the awaiting `subscribe`/`sendMut`/`fetch` then sent on a
null socket and threw, floating an unhandled rejection and leaving the
collection silently empty. `connect()` now re-dials a revived transport or
rejects the new typed `TransportClosedError`; a racing `subscribe` either
lands its frame on the next connection or rejects typed (routed to
`markError`, so `preload()` fails loud and recovers on retry).
- **`close()` can abort a socket whose `open()` is still in flight (ADR-0020,
issue #38).** Previously `close()` disposed through `this.ws`, which is
`null` during the handshake, so a slow or never-completing handshake leaked a
CONNECTING socket forever. `close()` now aborts the in-flight `open()` via the
`AbortSignal`; the default browser opener honours it, and the close-epoch
discard remains the backstop for signal-ignoring openers.
- `unsubscribe` during an in-flight `subscribe`'s connect no longer sends the
sub after the socket opens — previously the server persisted a ghost
subscription (ADR-0019) with no local consumer until the socket dropped.
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,45 @@ same socket: `transport.call.<name>(args)` (typed sugar) or the low-level
`transport.sendCall("clearRoom", undefined)` — both mint the txId for you and
resolve with the command's result on `committed`.

#### Transport lifecycle & reconnect

The transport auto-reconnects on an unexpected drop and resubscribes every
collection from its single applied cursor (a windowed catch-up, not a
re-snapshot). Tune the pacing with `reconnectDelay` (a base-delay `number` for
the default jittered backoff, or a full `(attempt, closeCode?, closeReason?) =>
number | null` policy — return `null` to stop). Application close codes
(4000-4999) are terminal by default; wire `onClosed(code, reason)` to observe a
deliberate server close (e.g. an auth rejection) — see
[ADR-0016](./docs/adr/0016-reconnect-policy.md).

`close()` disposes the transport, and `connect()` **revives** it: a later
`connect()` clears the intentional-close latch, so a reused transport
auto-reconnects and delivers `onClosed` again. A `connect()` (and any
`subscribe`/`sendMut`/`fetch` awaiting it) never resolves disconnected — it
rejects `TransportClosedError` if the transport was closed and stays closed
([ADR-0020](./docs/adr/0020-connect-contract-abortable-open.md)).

The socket opener is injectable (`open`, for non-browser runtimes or tests). It
receives an optional `AbortSignal` that `close()` aborts while the handshake is
still in flight — honour it (close the socket, reject) so disposing a transport
before its socket finishes connecting never leaks a CONNECTING socket:

```ts
new WebSocketTransport<Api>({
url,
open: (signal) =>
new Promise((resolve, reject) => {
const ws = new WebSocket(url)
signal?.addEventListener("abort", () => { ws.close(); reject(new Error("aborted")) }, { once: true })
ws.addEventListener("open", () => resolve(ws))
ws.addEventListener("error", () => reject(new Error("ws error")))
}),
})
```

An opener that ignores the signal still works (the transport closes the socket
once `open()` resolves), but a handshake that never resolves then leaks.

### 4. SSR (experimental)

Built on TanStack DB's SSR support (`DbClient` `dehydrate()`/`hydrate()` and
Expand Down
151 changes: 151 additions & 0 deletions docs/adr/0020-connect-contract-abortable-open.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# 0020 — connect() never resolves disconnected; open() is abortable via AbortSignal

**Status:** Accepted. Fixes issues #37 and #38. Amends ADR-0016 (reconnect
policy) and ADR-0011's `Transport` seam; does not supersede either.

## Context

`WebSocketTransport.connect()` and `close()` share one race-prone window: the
in-flight `open()` between "start dialing" and "socket adopted". Two real
defects lived there, both observed against 0.6.0 in a shipping product and
verified on `main` post-#36.

### #37 — connect() could resolve having adopted no socket

The close-epoch guard (ADR-0016's third race guard) discards a socket whose
`open()` was in flight when `close()` landed, then **`return`ed** — resolving
the connect promise with `this.ws === null`. `subscribe`/`sendMut`/`fetch` all
`await connect()` and then send; the send threw `transport not connected`, the
subscription registered with no frame ever on the wire, and the collection's
`preload()` promise floated as an unhandled rejection (measured: 9 per cold
page load). The natural React shape — `createCollection(...)` + `preload()` in
one render pass, StrictMode's mount→unmount→remount — hit it deterministically.

Two residues shared the same surface:

- **`intentionallyClosed` latched with no reset.** Set `true` by `close()` and
never cleared — not even by a later `connect()`. A transport revived after
`close()` (connection pools reuse instances) lost auto-reconnect forever
(measured: a 1006 on a revived transport never re-dialed).
- **The latch also silenced `onClosed`.** The unexpected-close handler returns
early when `intentionallyClosed` is set, skipping both `scheduleReconnect`
*and* the terminal `onClosed` — so an app wiring `onClosed` to learn about a
server's deliberate 4xxx close heard nothing on any revived transport.

### #38 — close() could not abort an in-flight open()

`connect()` assigns `this.ws` only *after* the handshake resolves; `close()`
disposes through that field (`this.ws?.close()`). While `open()` is in flight
`this.ws` is `null`, so `close()` is a no-op on the socket. The only path that
disposed the in-flight socket was the close-epoch discard — which runs **only
when `open()` resolves**. A handshake that is slow or never completes (a dev
proxy that doesn't upgrade, a hung upgrade, a half-open LB connection) leaked a
socket stuck in CONNECTING forever. Deterministic on any dispose-before-open;
benign in prod (handshakes complete in ms), user-visible in dev and a real
leak wherever a handshake can hang.

## Decision

### 1. `connect()` never resolves disconnected

`await connect()` resolves only with a live socket adopted, and never resolves
with `this.ws === null`. At the close-epoch discard it now either:

- **re-dials** — if a later `connect()` revived the transport (see §3), it
defers to whichever dial now owns the transport (`return this.connect()`),
so the original awaiter rides the socket that dial installs; or
- **rejects** `TransportClosedError` — a new typed, catchable error — if the
transport was closed and *stays* closed.

This is the contract issue #37 asked for: fix `connect()`, not add a queue in
`subscribe`. `subscribe`/`sendMut`/`fetch` inherit it for free — a racing
`subscribe` now either lands its frame on the next connection or rejects typed;
`do-collection` already routes that rejection to `markError`, so `preload()`
fails loud (and a retried `preload()` / the policy-driven reconnect recovers)
rather than resolving a silently-empty collection.

Only the dial that still **owns** `connectPromise` drives failure recovery (the
`catch` guards on `this.connectPromise === p`): a superseded dial that re-dialed
via the epoch branch must not null a newer attempt's promise or double-schedule
its reconnect.

### 2. `open()` takes an `AbortSignal`; `close()` aborts the in-flight handshake

The pluggable opener's contract widens (backward-compatibly):

```ts
open?: (signal?: AbortSignal) => WebSocketLike | Promise<WebSocketLike>
```

The transport creates one `AbortController` per dial, holds it in `openAbort`
for exactly as long as a dial is parked on `await open()`, and `close()` aborts
it. The default browser `open()` honours the signal: on abort it closes its
still-CONNECTING socket and rejects `TransportClosedError`. This is the one
path that disposes a handshake that *never resolves* — the acceptance criterion
the epoch discard could not meet.

**Backward compatibility is preserved.** An existing `open()` that takes no
argument (or ignores the signal) still works: `close()` cannot abort it
mid-flight, but the epoch guard still closes the orphan the instant `open()`
resolves — the pre-fix behaviour, minus the never-resolves leak. A custom
opener that wants dispose-before-open to free its socket immediately (including
the never-resolves case) **must** honour the signal. This is the *only*
residual gap and it is opt-in: the default opener closes it, and a custom
opener closes it in the ~15 lines the reporting consumer already wrote at this
seam. `openAbort` tracks the current in-flight dial only; a pathological
close→connect→close chain can leave an earlier signal-ignoring dial's socket to
the epoch discard, same as today.

`openAbort` is released in a `finally` the instant `open()` settles — before
the epoch check and install — so a `close()` arriving during install-processing
never aborts the socket about to be adopted; it bumps `closeEpoch` instead, and
the epoch guard handles it.

### 3. Dialing clears the intentional-close latch

`connect()` sets `intentionallyClosed = false` at its head. Dialing is the
clearest possible statement of intent to be connected, so a revived transport
regains **both** auto-reconnect and `onClosed` delivery — the two residues fall
out of one line. This is orthogonal to the close-epoch guard: the epoch still
discards a socket opened before *this* dial's `close()`, while the latch governs
whether a *future* unexpected drop reconnects.

**Behaviour change (deliberate).** `close()` is no longer permanent across a
later `connect()`. ADR-0016's `intentionallyClosed` still governs auto-reconnect
suppression; only its clearing point moves — from "never" to "the next
`connect()`". The `reconnect-policy` test that pinned the old permanence is
rewritten to pin revival (issue #37's acceptance).

## Alternatives considered

- **A re-subscribe queue in `subscribe`.** Rejected by #37 itself: the defect
is `connect()`'s contract, and `sendMut`/`fetch` share it. Fixing one caller
leaves the others broken.
- **`open()` returns `{ socket, whenOpen }`** so the transport owns the handle
from creation. Strictly more capable, but a *breaking* signature change for
every existing opener; an `AbortSignal` is additive and the default opener
implements the whole contract. If a future need forces transport-owned socket
creation, that is a separate ADR.
- **The full generation-counter unification** ADR-0016 deferred (collapse the
tracked timer, the `this.ws !== ws` close guard, and the close-epoch check
into one counter). Not adopted: it does not *fall out* of this fix, the diff
is already surgical, and the existing race tests (its intended harness) stay
green. The deferral stands; unify when the fragility actually bites.

## Consequences

- **New export:** `TransportClosedError`. `connect()`/`subscribe`/`sendMut`/
`fetch` can reject with it around a close race — catchable and distinct from
`MutationRejectedError`.
- **`open` gains an optional `AbortSignal`.** Additive; existing openers compile
and run unchanged. Custom openers should honour it to be abortable-before-open.
- **`close()` is revivable.** A later `connect()` restores auto-reconnect and
`onClosed`. Code that relied on `close()` being permanently terminal must not
re-`connect()` the same instance (or must track terminality itself).
- No new timers, no idle work: `openAbort` exists only while a dial is parked on
`open()`; the no-idle-timers invariant (ADR-0016) is preserved.

## Out of scope

Issue #39 (in-flight `pendingTx` settlement on an unexpected close) is a
separate follow-up and is untouched here.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ explains the displacement.
| [0017](./0017-blob-wire-normalization.md) | BLOB wire normalization: bare ArrayBuffer becomes Uint8Array at emission | Accepted |
| [0018](./0018-oversize-frames.md) | Oversize frames: client pre-send guard is the rejection surface; outbound is warn-only | Accepted |
| [0019](./0019-subscription-persistence-across-hibernation.md) | Subscriptions persist in SQLite and restore on hibernation wake | Accepted |
| [0020](./0020-connect-contract-abortable-open.md) | connect() never resolves disconnected; open() is abortable via AbortSignal | Accepted (amends 0016 + 0011 seam) |
Loading