@ultimat3/realtime 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -79,6 +79,21 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
79
79
  prevent. A lane that fails now desyncs its own subscribers and the first failure still reaches
80
80
  the caller, but it costs one query id. The lane chains on a settled shadow of each task: one
81
81
  fanout that threw must not reject every fanout behind it.
82
+ - **Two reads of one entry are ordered by a READ GENERATION, never by an lsn.** `QueryEntry.lsn`
83
+ is optional — a definition with no lsn provider answers `''` from every snapshot — so the
84
+ never-backwards rule expressed purely in lsn terms read `'' >= ''` as "newer" and let the older
85
+ of two concurrent reads land on top of the newer one's window. The interleaving: a cold
86
+ subscriber issues P1; the change stream skips a sequence and `registry.invalidate()` marks the
87
+ entry; a second cold subscriber forces P2, which **clears `stale` on the way in**; P2 lands with
88
+ the post-gap rows; P1 lands last and overwrites them. `stale` is false, so `fanoutChange`'s
89
+ repair never fires, the next change patches the pre-gap window and re-snapshots every desynced
90
+ subscriber out of it — permanently stale on a healthy socket, which is the exact outcome `stale`
91
+ exists to prevent. `entry.generation` is bumped in `startRead` and `entry.applied` records the
92
+ newest read whose rows are on the window: an *identity* check, the same one `startRead` makes on
93
+ `entry.reading` one function down and `packages/cache/src/single-flight.ts:70` makes for the same
94
+ reason. The lsn guard stays beside it for the other question — a read that resolved behind a
95
+ *change* the fanout already folded — because those are two orderings and neither answers the
96
+ other.
82
97
  - **The definition's read is once per entry, not once per subscriber.** A cold subscriber arriving
83
98
  while another's read is in flight joins that read — N cold subscribers on one query id being N
84
99
  reads is the shared window not existing. It is a share, not a cache: the in-flight promise is
@@ -141,6 +156,34 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
141
156
  could reach a different one than the container it is standing in for. The KV bucket and the
142
157
  presence TTL come back with the transport for the same reason: they are one decision.
143
158
  - `sync` is stateless: no sticky sessions, nothing on a socket survives a restart.
159
+ - **A socket the node evicts ITSELF is released through `teardown`, never through
160
+ `sockets.remove`.** Bun's `close` callback runs `teardown`; a drain and the idle sweep have no
161
+ callback behind them — Bun's fires a tick later and `sockets.get` misses by then — so whatever
162
+ they do instead *is* the whole release. `drain()` inlined three of `teardown`'s five steps
163
+ (`close`, `sockets.remove`, `grants.delete`) and skipped the two the rest of the fleet can see:
164
+ `registry.unsubscribeSocket` and `presence.leave` per topic. What that left is a `QueryEntry`
165
+ whose `subscribers` map never empties — matcher, shared window and `WindowLock` pinned, and
166
+ `source.forget(qid)` never called — and, worse because it is cross-node, a presence member every
167
+ other node renders for a full TTL. During a **rolling restart** that is every room showing each
168
+ user twice for up to 30s, beside the same client's reconnection under a new socket id. One
169
+ `evict(socket, code, reason)`, and every path that ends a socket without a callback takes it.
170
+ - **The idle sweep exists, is armed by `start()`, and its budget is an APPLICATION one.**
171
+ `SocketRegistry.sweepIdle` had no caller for as long as it existed, so `touch()`, `idleFor` and
172
+ the 120s default decided nothing and `idleTimeoutMs` was unreachable from `createSyncNode`. The
173
+ only live guard was `websocket.idleTimeout: 120` handed to Bun — which Bun's own ping/pong
174
+ renews, so a client whose frame loop is wedged answers pings and keeps its `GrantBook` entry,
175
+ its `SubscriptionBook` entries and its `#byTopic` membership forever. It is now
176
+ `SocketRegistry.idle()`, a **query**: this table is three of the five things a socket holds, so
177
+ the object that can evict one is the node and not the registry. `start()` arms one `.unref()`ed
178
+ pass every `idleSweepPeriodMs(idleTimeoutMs)` — a quarter of the budget, floored at a second,
179
+ derived rather than configured because a second knob is a second number that can disagree with
180
+ the one it is a fraction of — and `release()` clears it beside the presence sweep. **It measures
181
+ on `Clock.monotonic()`**, the clock `AcceptBudget` already uses: the sweep compares a DURATION,
182
+ and a duration read off `now().getTime()` is decided by whatever NTP last wrote — a step forward
183
+ evicts every socket that is talking, a step backward makes `idleFor` negative and spares every
184
+ socket that is dead, and the sweep had only just gained its first caller when both became
185
+ reachable. The field is named `lastSeenMonotonicMs` so nobody hands it to `new Date()`;
186
+ `openedAt` is the wall-clock one and stays that way, because a human reads it.
144
187
  - **`drain()` and `stop()` both release what `start()` acquired, and releasing twice is a no-op.**
145
188
  A `drain()` is terminal on its own — it closes the hub and evicts every socket — and nothing
146
189
  obliges a `stop()` to follow it, so leaving the change subscription and the presence sweep to
@@ -321,6 +364,13 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
321
364
  `Bridge` comment describes, one state earlier. So `close()` sets `#closed` **before** the walk and
322
365
  `#open` closes its own subscription when it lands after one, dropping the entry with it so a
323
366
  second post-close subscribe opens and closes its own rather than double-unsubscribing this handle.
367
+ It then **raises** `X_TRANSPORT_UNAVAILABLE` rather than returning: returning let `subscribe` fall
368
+ through to `joinTopic`, so the socket became a member of a topic nothing on this node is bridged
369
+ to — silent for the life of the connection, no error on either side, and no reason for the client
370
+ to redial. Reachable between `hub.close()` inside `node.drain()` and the last in-flight subscribe.
371
+ `#release` takes the bridge the caller reserved for the same reason: after `close()` cleared the
372
+ table, that topic name may hold a bridge a LATER subscribe opened, and releasing by name alone
373
+ decrements somebody else's refcount.
324
374
  - Deny by default on topics. No guard = `X_TOPIC_FORBIDDEN`.
325
375
  - **A guard that FAILS is not a guard that denied — the hub's copy of the rule the row gate already
326
376
  follows.** On `onActorChange` (the re-auth pass) only a denial unsubscribes; anything else keeps
package/README.md CHANGED
@@ -168,6 +168,7 @@ wire.
168
168
  | distinct channel topics per node | 10,000 | `new ChannelHub({ maxTopicsPerNode })` | `X_SUBSCRIPTION_LIMIT` |
169
169
  | outbound bytes buffered on one socket | 1 MiB | `createSyncNode({ maxBufferedBytes })` | the frame is dropped and `send` answers `false` |
170
170
  | dropped frames before that socket is closed | 32 | `createSyncNode({ maxDroppedFrames })` | close `1013` (`overloaded`), reason `backpressure` |
171
+ | time one socket may route no frame | 120s | `createSyncNode({ idleTimeoutMs })` | close `4001` (`idle`), reason `idle timeout` |
171
172
  | retained patch bytes per node | 64 MiB | `new RingChangeBuffer({ maxBytes, maxBytesPerQuery })` | eviction, then a re-snapshot on resume |
172
173
  | array lengths and `input` nesting in a frame | `FRAME_LIMITS` | none — a hard ceiling | `X_PROTOCOL_VERSION` |
173
174
 
@@ -362,6 +363,18 @@ wire twice by a reconnect that raced an ack.
362
363
  revoked grant — every topic on every re-authenticated socket, silently, with the client never told
363
364
  to resubscribe. The initial `subscribe` is deliberately not split that way: there is no
364
365
  subscription to keep, so a guard that raises refuses that subscribe and the client hears about it.
366
+ - **An idle socket is swept, and the sweep is an APPLICATION budget, not Bun's.** Bun's own
367
+ `idleTimeout` is renewed by its ping/pong, so a client whose frame loop is wedged answers pings
368
+ and keeps its grant, its live subscriptions and its topic membership indefinitely. `start()`
369
+ arms one `.unref()`ed pass every `idleTimeoutMs / 4` (floored at a second, derived rather than
370
+ configured) and evicts anything past the budget the same way a close does — through the node's
371
+ `teardown`, never `SocketRegistry.remove`. `SocketRegistry.idle()` is a *query* for that reason:
372
+ the socket table is three of the five things a socket holds, and the other two are its live
373
+ subscriptions and its presence membership on the shared set. `sweepIdle` — which closed and
374
+ removed here, and had no caller at all — is gone. The budget is measured on `Clock.monotonic()`,
375
+ so `SyncSocket.lastSeenMonotonicMs` is a duration's start and not an instant: an NTP step forward
376
+ would otherwise evict every socket that is talking, and a step backward would spare every socket
377
+ that is dead. `openedAt` stays on the wall clock — it is a value a human reads.
365
378
  - **A `sync` node shuts down in two phases.** The `accept` phase calls `stopAccepting()`: `/readyz`
366
379
  answers 503 and a late upgrade is shed with `retry-after-ms`, while **every socket the node holds
367
380
  keeps its patch stream**. The `close` phase is `drain()` then `stop()`. Registered with no phase it
@@ -381,7 +394,10 @@ wire twice by a reconnect that raced an ack.
381
394
  caller.
382
395
  - **A cold subscribe reads once per query id.** Subscribers arriving during a read join it and each
383
396
  runs its own policy pass over the result. A read that resolves behind a change already fanned out
384
- is discarded rather than written back: the window only ever moves forwards.
397
+ is discarded rather than written back: the window only ever moves forwards. Two reads are ordered
398
+ by a monotonic **read generation** and never by lsn — a definition with no lsn provider answers
399
+ `''` for every read, and `'' >= ''` let the older of two concurrent reads land on top of the
400
+ newer one's gap repair, with `stale` already cleared and therefore nothing left to re-read.
385
401
  - **A denial drops a row; a gate that could not decide does not.** A policy answer (`X_FORBIDDEN`,
386
402
  `X_UNAUTHENTICATED`) is a decision and costs the row, counted as `rowsDenied`. Anything else a
387
403
  gate throws — a rule whose lookup timed out, a predicate with a typo — is counted as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,8 +32,8 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "2.0.0",
36
- "@ultimat3/query": "2.0.0",
35
+ "@ultimat3/core": "3.0.0",
36
+ "@ultimat3/query": "3.0.0",
37
37
  "nats": "2.29.3"
38
38
  }
39
39
  }
package/src/channel.ts CHANGED
@@ -6,7 +6,12 @@
6
6
 
7
7
  import { type Actor, logger, renderThrowable } from '@ultimat3/core';
8
8
  import { formatLsn } from './changefeed';
9
- import { isPolicyDenial, SubscriptionLimitError, TopicForbiddenError } from './errors';
9
+ import {
10
+ isPolicyDenial,
11
+ SubscriptionLimitError,
12
+ TopicForbiddenError,
13
+ TransportUnavailableError,
14
+ } from './errors';
10
15
  import { subjectMatches, type Transport, type TransportSubscription } from './fanout';
11
16
  import type { JsonObject } from './json';
12
17
  import type { SocketRegistry, SyncSocket } from './socket';
@@ -152,8 +157,11 @@ export class ChannelHub {
152
157
  await this.#authorize(socket.actor, name);
153
158
  await this.#open(name, bridge);
154
159
  } catch (error) {
155
- // The slot this subscribe took, given back on the one path that will never fill it.
156
- this.#release(name);
160
+ // The slot this subscribe took, given back on the one path that will never fill it — and
161
+ // given back to the bridge this subscribe actually reserved. `close()` clears the table, so
162
+ // a later subscribe may have put a DIFFERENT bridge under this name in the meantime, and
163
+ // decrementing that one's refs releases a topic somebody else is holding.
164
+ this.#release(name, bridge);
157
165
  throw error;
158
166
  } finally {
159
167
  const held = this.#claimed.get(socket) ?? 1;
@@ -164,7 +172,7 @@ export class ChannelHub {
164
172
  // membership this socket's close will give back, so the reference taken above has to go now or
165
173
  // it is a bridge nothing will ever release.
166
174
  if (socket.topics.has(name)) {
167
- this.#release(name);
175
+ this.#release(name, bridge);
168
176
  return;
169
177
  }
170
178
  // Through the registry, never `socket.subscribeTopic` directly: membership and the index the
@@ -297,12 +305,31 @@ export class ChannelHub {
297
305
  if (this.#closed) {
298
306
  unsubscribeWhenOpen(bridge);
299
307
  if (this.#bridges.get(name) === bridge) this.#bridges.delete(name);
308
+ // RAISED, not returned. Returning let `subscribe` fall through to `joinTopic`, so the socket
309
+ // became a member of a topic nothing on this node is bridged to: silent for the life of the
310
+ // connection, with no error on either side and nothing telling the client to redial. The
311
+ // same refusal the transport itself answers when it is gone, because from the client's side
312
+ // that is what happened — this node's bus for that topic is closed.
313
+ throw new TransportUnavailableError({
314
+ transport: 'channel',
315
+ reason: `the hub closed while "${name}" was opening`,
316
+ // The reader is a browser websocket client, which cannot run a CLI — so pure command
317
+ // advice would be worse than prose here. The shape that satisfies axiom 4 anyway is
318
+ // `http/src/error-map.ts`'s: a command that SHIPS (`x errors explain`, unlike the planned
319
+ // `x logs tail`) for whoever is holding a terminal, then the instruction as a comment.
320
+ fix: 'x errors explain X_TRANSPORT_UNAVAILABLE --json # then reconnect and resubscribe: this node is draining',
321
+ });
300
322
  }
301
323
  }
302
324
 
303
- #release(name: Topic): void {
325
+ /**
326
+ * `expected` is the bridge the caller reserved. Without it a release looks the topic up by name,
327
+ * and after a `close()` cleared the table that name may hold a bridge a LATER subscribe opened.
328
+ */
329
+ #release(name: Topic, expected?: Bridge): void {
304
330
  const bridge = this.#bridges.get(name);
305
331
  if (!bridge) return;
332
+ if (expected !== undefined && bridge !== expected) return;
306
333
  bridge.refs -= 1;
307
334
  if (bridge.refs > 0) return;
308
335
  unsubscribeWhenOpen(bridge);
package/src/index.ts CHANGED
@@ -263,6 +263,7 @@ export {
263
263
  createEntry,
264
264
  fillWindow,
265
265
  orgIdOf,
266
+ type PendingRead,
266
267
  type QueryEntry,
267
268
  refillWindowInLane,
268
269
  } from './query-window';
@@ -299,8 +300,10 @@ export {
299
300
  actorIdOf,
300
301
  CLOSE,
301
302
  DEFAULT_FRAME_BURST,
303
+ DEFAULT_IDLE_TIMEOUT_MS,
302
304
  DEFAULT_MAX_BUFFERED_BYTES,
303
305
  DEFAULT_MAX_FRAMES_PER_SECOND,
306
+ idleSweepPeriodMs,
304
307
  SocketRegistry,
305
308
  type SocketRegistryOptions,
306
309
  SyncSocket,
@@ -32,7 +32,24 @@ export interface QueryEntry {
32
32
  /** Serial lane over `rows`/`lsn`. Every fanout and every window assignment takes its turn here. */
33
33
  readonly lock: WindowLock;
34
34
  /** The read in flight, shared by every subscriber that arrives during it. `null` between reads. */
35
- reading: Promise<SnapshotResult> | null;
35
+ reading: PendingRead | null;
36
+ /**
37
+ * Reads issued against this entry, ever. It is the ORDER of two reads, which nothing else here
38
+ * can answer: a definition with no lsn provider returns `''` from every snapshot.
39
+ */
40
+ generation: number;
41
+ /** The generation of the newest read whose rows are in `rows`. `0` before the first one lands. */
42
+ applied: number;
43
+ }
44
+
45
+ /**
46
+ * One read and which read it is. They are one fact — a joiner needs the promise AND the generation
47
+ * it will have to compare against when it lands — and two fields on the entry is two writes a later
48
+ * edit can separate.
49
+ */
50
+ export interface PendingRead {
51
+ readonly generation: number;
52
+ readonly result: Promise<SnapshotResult>;
36
53
  }
37
54
 
38
55
  export function createEntry(
@@ -64,6 +81,8 @@ export function createEntry(
64
81
  stale: false,
65
82
  lock: new WindowLock(),
66
83
  reading: null,
84
+ generation: 0,
85
+ applied: 0,
67
86
  };
68
87
  }
69
88
 
@@ -82,18 +101,21 @@ export async function fillWindow(
82
101
  // Read before `startRead` clears it: a second caller arriving during the read joins it and is
83
102
  // not the one that forced it, which is what keeps one forced read from becoming N.
84
103
  const forced = entry.stale;
85
- const result = await (forced || entry.reading === null ? startRead(entry) : entry.reading);
104
+ const pending = forced || entry.reading === null ? startRead(entry) : entry.reading;
105
+ const result = await pending.result;
86
106
  return await entry.lock.run(async () => {
87
- if (forced) {
88
- // A forced read replaces the window whatever its lsn says: it was issued *because* what is
89
- // under it is wrong, and a definition with no lsn provider answers `''` — which the
90
- // never-backwards rule below would read as older than what we hold and discard, leaving
91
- // every subscriber served from the window the gap already invalidated.
92
- entry.rows = result.rows;
93
- if (result.lsn > entry.lsn) entry.lsn = result.lsn;
94
- } else if (result.lsn >= entry.lsn) {
95
- entry.rows = result.rows;
96
- entry.lsn = result.lsn;
107
+ // Two rules, and neither can stand in for the other. Against another READ it is identity —
108
+ // the same check `startRead` makes on `entry.reading` one function down, and the one
109
+ // `packages/cache/src/single-flight.ts` makes for the same reason because an lsn cannot
110
+ // order two reads at all: a definition with no lsn provider answers `''` for both, and
111
+ // `'' >= ''` let the older one overwrite the gap repair the newer one had just landed, with
112
+ // `stale` already cleared by its issue and therefore nothing left to re-read. Against a
113
+ // CHANGE it is still the lsn, because a fanout moved `entry.lsn` forwards while this read was
114
+ // in flight and rewinding to what the read saw hands that subscriber rows the fanout has
115
+ // moved past — except for a forced read, which was issued *because* what is under it is
116
+ // wrong.
117
+ if (isNewestRead(entry, pending) && (forced || result.lsn >= entry.lsn)) {
118
+ applyRead(entry, pending, result);
97
119
  }
98
120
  return { rows: entry.rows, lsn: entry.lsn };
99
121
  });
@@ -105,22 +127,36 @@ export async function fillWindow(
105
127
  * that repairs a stale window mid-fanout is spelled here rather than deadlocking on the other.
106
128
  */
107
129
  export async function refillWindowInLane(entry: QueryEntry): Promise<void> {
108
- const result = await startRead(entry);
130
+ const pending = startRead(entry);
131
+ const result = await pending.result;
132
+ // Same identity rule as `fillWindow`: a read issued before this one may still be in flight, and
133
+ // whichever was issued LAST is the one the window keeps.
134
+ if (isNewestRead(entry, pending)) applyRead(entry, pending, result);
135
+ }
136
+
137
+ /** Is this the newest read to have landed? An older one's rows are behind the window, not on it. */
138
+ function isNewestRead(entry: QueryEntry, pending: PendingRead): boolean {
139
+ return pending.generation > entry.applied;
140
+ }
141
+
142
+ function applyRead(entry: QueryEntry, pending: PendingRead, result: SnapshotResult): void {
143
+ entry.applied = pending.generation;
109
144
  entry.rows = result.rows;
110
145
  if (result.lsn > entry.lsn) entry.lsn = result.lsn;
111
146
  }
112
147
 
113
148
  /** Publishes the in-flight read, and clears it as it settles — the share is per read, not a cache. */
114
- function startRead(entry: QueryEntry): Promise<SnapshotResult> {
149
+ function startRead(entry: QueryEntry): PendingRead {
115
150
  // Cleared here rather than when the read lands: the read about to be issued is the one that
116
151
  // answers the staleness, so a second caller must join it instead of forcing another.
117
152
  entry.stale = false;
118
- const reading = readSnapshot(entry);
153
+ entry.generation += 1;
154
+ const reading: PendingRead = { generation: entry.generation, result: readSnapshot(entry) };
119
155
  entry.reading = reading;
120
156
  const done = (): void => {
121
157
  if (entry.reading === reading) entry.reading = null;
122
158
  };
123
- void reading.then(done, done);
159
+ void reading.result.then(done, done);
124
160
  return reading;
125
161
  }
126
162
 
package/src/socket.ts CHANGED
@@ -126,7 +126,13 @@ export class SyncSocket {
126
126
  readonly frameBudget: AcceptBudget;
127
127
 
128
128
  actor: Actor | null;
129
- lastSeenAt: number;
129
+ /**
130
+ * MONOTONIC milliseconds, not an instant — the idle sweep measures a duration, and a duration
131
+ * read off the wall clock is decided by whatever NTP last wrote. A step forward evicts sockets
132
+ * that are talking; a step backward makes `idleFor` negative and spares sockets that are dead.
133
+ * Named for the units so nobody hands it to `new Date()`; `openedAt` is the wall-clock one.
134
+ */
135
+ lastSeenMonotonicMs: number;
130
136
  droppedFrames = 0;
131
137
  sentFrames = 0;
132
138
 
@@ -150,8 +156,10 @@ export class SyncSocket {
150
156
  burst: options.frameBurst ?? DEFAULT_FRAME_BURST,
151
157
  clock: this.#clock,
152
158
  });
159
+ // Two clocks on purpose: `openedAt` is an instant a human reads, `lastSeenMonotonicMs` is the
160
+ // start of a duration only this process compares.
153
161
  this.openedAt = this.#clock.now().getTime();
154
- this.lastSeenAt = this.openedAt;
162
+ this.lastSeenMonotonicMs = this.#clock.monotonic();
155
163
  }
156
164
 
157
165
  get actorId(): string | null {
@@ -206,11 +214,12 @@ export class SyncSocket {
206
214
  }
207
215
 
208
216
  touch(): void {
209
- this.lastSeenAt = this.#clock.now().getTime();
217
+ this.lastSeenMonotonicMs = this.#clock.monotonic();
210
218
  }
211
219
 
212
- idleFor(now: number): number {
213
- return now - this.lastSeenAt;
220
+ /** `nowMonotonicMs` comes from the SAME clock — `Clock.monotonic()`, never `now().getTime()`. */
221
+ idleFor(nowMonotonicMs: number): number {
222
+ return nowMonotonicMs - this.lastSeenMonotonicMs;
214
223
  }
215
224
 
216
225
  close(code: number = CLOSE.normal, reason = ''): void {
@@ -220,9 +229,28 @@ export class SyncSocket {
220
229
  }
221
230
  }
222
231
 
232
+ /**
233
+ * How long a socket may route no frame before `sync-node` evicts it. It is an APPLICATION
234
+ * inactivity budget and not Bun's transport one: Bun's `idleTimeout` is renewed by its own
235
+ * ping/pong, so a client whose TCP stack still answers pings while its frame loop is wedged holds
236
+ * its grant, its subscriptions and its topic membership forever. A beating client sends a `hello`
237
+ * every `DEFAULT_HEARTBEAT_MS` (15s), so this is eight missed beats.
238
+ */
239
+ export const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
240
+
241
+ /**
242
+ * How often to ask. A quarter of the budget, floored at a second: a socket is evicted within 25%
243
+ * of its window of going quiet, and a node holding 50,000 of them pays one pass over the table
244
+ * four times per window rather than once a second. Derived rather than configured — a second knob
245
+ * is a second number that can disagree with the one it is a fraction of.
246
+ */
247
+ export function idleSweepPeriodMs(idleTimeoutMs: number): number {
248
+ return Math.max(1_000, Math.floor(idleTimeoutMs / 4));
249
+ }
250
+
223
251
  export interface SocketRegistryOptions {
224
252
  readonly clock?: Clock;
225
- /** Bun also enforces its own `idleTimeout`; this sweep catches half-open connections. */
253
+ /** Bun's own `idleTimeout` is renewed by its ping/pong; this budget counts routed FRAMES. */
226
254
  readonly idleTimeoutMs?: number;
227
255
  }
228
256
 
@@ -244,7 +272,7 @@ export class SocketRegistry {
244
272
 
245
273
  constructor(options: SocketRegistryOptions = {}) {
246
274
  this.#clock = options.clock ?? systemClock;
247
- this.#idleTimeoutMs = options.idleTimeoutMs ?? 120_000;
275
+ this.#idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
248
276
  }
249
277
 
250
278
  /**
@@ -310,19 +338,26 @@ export class SocketRegistry {
310
338
  return this.#sockets.size;
311
339
  }
312
340
 
313
- /** Closes and returns everything past the idle budget. Called on an interval by `sync-node`. */
314
- sweepIdle(): SyncSocket[] {
315
- const now = this.#clock.now().getTime();
316
- const closed: SyncSocket[] = [];
317
- for (const socket of this.#sockets.values()) {
318
- if (socket.idleFor(now) > this.#idleTimeoutMs) {
319
- socket.close(CLOSE.idle, 'idle timeout');
320
- // Through `remove`, not the map: the sweep is exactly the abnormal close a gauge leaks on.
321
- this.remove(socket.id);
322
- closed.push(socket);
323
- }
324
- }
325
- return closed;
341
+ /** The budget `idle()` answers against, so a caller can size its own sweep from one number. */
342
+ get idleTimeoutMs(): number {
343
+ return this.#idleTimeoutMs;
344
+ }
345
+
346
+ /**
347
+ * Everything past the idle budget. A QUERY, and deliberately not an eviction: this table is three
348
+ * of the five things a socket holds, and the other two its live subscriptions and its presence
349
+ * membership on the SHARED set — are only reachable from `sync-node`'s `teardown`. A sweep that
350
+ * closed and `remove`d here left a member every other node renders until its TTL and a
351
+ * `QueryEntry` whose `subscribers` map never empties. `sync-node` is the one caller and it
352
+ * releases each one the way the close callback does.
353
+ */
354
+ idle(): SyncSocket[] {
355
+ // Monotonic, because this is the one comparison an operator's clock could otherwise decide:
356
+ // `sync-node` hands this registry and every socket it builds the same `Clock`.
357
+ const now = this.#clock.monotonic();
358
+ return [...this.#sockets.values()].filter(
359
+ (socket) => socket.idleFor(now) > this.#idleTimeoutMs,
360
+ );
326
361
  }
327
362
 
328
363
  /**
package/src/sync-node.ts CHANGED
@@ -14,6 +14,7 @@ import { CHANGE_SUBJECT_PREFIX, parseEnvelope, SeqGapDetector } from './replicat
14
14
  import {
15
15
  CLOSE,
16
16
  DEFAULT_MAX_BUFFERED_BYTES,
17
+ idleSweepPeriodMs,
17
18
  SocketRegistry,
18
19
  SyncSocket,
19
20
  type WsLike,
@@ -76,6 +77,12 @@ export interface SyncNodeOptions {
76
77
  */
77
78
  readonly maxBufferedBytes?: number;
78
79
  readonly maxDroppedFrames?: number;
80
+ /**
81
+ * How long a socket may route no frame before this node evicts it. Every ceiling on a socket
82
+ * `sync` builds has to be reachable from here, and this one was not: `SocketRegistry`'s default
83
+ * was only settable by constructing the registry yourself, and nothing swept it either way.
84
+ */
85
+ readonly idleTimeoutMs?: number;
79
86
  readonly onMutate?: MutationHandler;
80
87
  /**
81
88
  * Who is dialling. Injected for the same reason `onMutate` is: `sync` owns no business logic and
@@ -131,7 +138,11 @@ export interface SyncNode {
131
138
 
132
139
  export function createSyncNode(options: SyncNodeOptions): SyncNode {
133
140
  const sockets =
134
- options.sockets ?? new SocketRegistry({ ...(options.clock ? { clock: options.clock } : {}) });
141
+ options.sockets ??
142
+ new SocketRegistry({
143
+ ...(options.clock ? { clock: options.clock } : {}),
144
+ ...(options.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: options.idleTimeoutMs }),
145
+ });
135
146
  const clock = options.clock ?? systemClock;
136
147
  const accept = options.accept ?? new AcceptBudget({ perSecond: 500, burst: 2000, clock });
137
148
  const maxConnections = options.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
@@ -143,6 +154,7 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
143
154
  let changes: TransportSubscription | null = null;
144
155
  let sweeping: ReturnType<typeof setInterval> | null = null;
145
156
  let reauthing: ReturnType<typeof setInterval> | null = null;
157
+ let idling: ReturnType<typeof setInterval> | null = null;
146
158
 
147
159
  /**
148
160
  * Work nobody is waiting on — a presence leave from a synchronous close, a sweep on a timer, a
@@ -178,6 +190,8 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
178
190
  sweeping = null;
179
191
  if (reauthing !== null) clearInterval(reauthing);
180
192
  reauthing = null;
193
+ if (idling !== null) clearInterval(idling);
194
+ idling = null;
181
195
  gaps.forget();
182
196
  };
183
197
 
@@ -200,6 +214,17 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
200
214
  }
201
215
  };
202
216
 
217
+ /**
218
+ * The node's one eviction: close, then release everything the socket held. Every path that ends
219
+ * a socket without a `close` callback behind it — the drain, the idle sweep — goes through it,
220
+ * because dropping the socket from the table is three of `teardown`'s five steps and the two it
221
+ * misses are the ones another node can see.
222
+ */
223
+ const evict = (socket: SyncSocket, code: number, reason: string): void => {
224
+ socket.close(code, reason);
225
+ teardown(socket);
226
+ };
227
+
203
228
  /**
204
229
  * One pass over the grants whose window has closed. This is the half R2 was missing: `reauthorize`
205
230
  * and `onActorChange` were both written and neither had a caller, so a socket that was accepted
@@ -277,6 +302,14 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
277
302
  );
278
303
  sweeping.unref();
279
304
  }
305
+ // The half-open connection Bun's own `idleTimeout` renews through its ping/pong: a client
306
+ // whose frame loop is wedged answers pings and keeps its grant, its subscriptions and its
307
+ // topic membership. `sweepIdle` was written for this and never called, so `touch()` and the
308
+ // 120s budget under it decided nothing.
309
+ idling = setInterval(() => {
310
+ for (const socket of sockets.idle()) evict(socket, CLOSE.idle, 'idle timeout');
311
+ }, idleSweepPeriodMs(sockets.idleTimeoutMs));
312
+ idling.unref();
280
313
  if (options.authenticate) {
281
314
  reauthing = setInterval(
282
315
  () => detach(reauthenticate(), 'sync.reauthenticate'),
@@ -419,11 +452,13 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
419
452
  }
420
453
  const graceMs = drainOptions.graceMs ?? 5_000;
421
454
  if (graceMs > 0) await new Promise((resolve) => setTimeout(resolve, graceMs));
422
- for (const socket of [...sockets.all()]) {
423
- socket.close(CLOSE.goingAway, 'drain');
424
- sockets.remove(socket.id);
425
- grants.delete(socket.id);
426
- }
455
+ // Through `evict`, never `sockets.remove` + `grants.delete`: those are three of `teardown`'s
456
+ // five steps, and the two they skip are the ones the rest of the fleet can see. A drained
457
+ // socket that never left its presence set is a member every other node renders for a full
458
+ // TTL — during a rolling restart, beside the same client's reconnection under a new id
459
+ // and its live subscriptions stay in the registry, so `entry.subscribers` never empties and
460
+ // the matcher, the shared window and the retained ring are pinned for the process's life.
461
+ for (const socket of [...sockets.all()]) evict(socket, CLOSE.goingAway, 'drain');
427
462
  // Released once the sockets are gone rather than at the top: a client is entitled to its
428
463
  // patches for the whole grace window, and it is entitled to them *before* the hub the
429
464
  // fanout writes through is closed.