socketduct 0.1.32 → 0.1.34

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.
@@ -32,8 +32,16 @@ import {attachDisconnectAttribution, createDisconnectAttribution, parseDisconnec
32
32
  const DEFAULT_STREAM_BUFFER_BYTES = 4 * 1024 * 1024
33
33
  const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
34
34
  const MANIFEST_PAGE_STREAMS = 64
35
- const DEFAULT_GATEWAY_PROBE_DELAY_MS = 1_000
36
- const DEFAULT_MAX_GATEWAY_PROBE_DELAY_MS = 30_000
35
+ const MAX_TARGET_UNAVAILABLE_ENTRIES = 64
36
+
37
+ /**
38
+ * Builds the per-target availability key for one logical target.
39
+ * @param {{host: string, port: number}} target - Logical target.
40
+ * @returns {string} Stable key.
41
+ */
42
+ function targetKey(target) {
43
+ return `${target.host}:${target.port}`
44
+ }
37
45
 
38
46
  /**
39
47
  * @typedef {object} ReverseGatewayConnectionOptions
@@ -52,8 +60,6 @@ const DEFAULT_MAX_GATEWAY_PROBE_DELAY_MS = 30_000
52
60
  * @property {number} [connectTimeoutMs] - TLS/auth deadline.
53
61
  * @property {number} [reconnectDelayMs] - Initial reconnect delay.
54
62
  * @property {number} [maxReconnectDelayMs] - Maximum reconnect delay.
55
- * @property {number} [gatewayProbeDelayMs] - Initial re-reach delay while the hub reports no selectable gateway.
56
- * @property {number} [maxGatewayProbeDelayMs] - Maximum re-reach delay while the hub reports no selectable gateway.
57
63
  * @property {number} [initialGeneration] - Monotonic seed for controlled replacement; defaults to wall-clock microseconds.
58
64
  * @property {boolean} [continuousReconnect] - Keep the member warm after loss, default true.
59
65
  * @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - zstd safety bounds.
@@ -144,9 +150,6 @@ export class ReverseGatewayConnection extends EventEmitter {
144
150
  /** @type {number} */ #generation
145
151
  /** @type {string | undefined} */ #codec
146
152
  /** @type {boolean} */ #closing = false
147
- /** @type {boolean} */ #draining = false
148
- /** @type {Promise<void> | undefined} */ #closePromise
149
- /** @type {Array<() => void>} */ #drainWaiters = []
150
153
  /** @type {boolean} */ #detached = false
151
154
  /** @type {boolean} */ #preserveSpools = false
152
155
  /** @type {boolean} */ #fatal = false
@@ -175,10 +178,15 @@ export class ReverseGatewayConnection extends EventEmitter {
175
178
  /** @type {number} */ #spoolBytes = 0
176
179
  /** @type {number} */ #spoolCleanupFailures = 0
177
180
  /** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
178
- /** @type {number | undefined} - Wall-clock time the hub first reported this member's route class unselectable; undefined means registered available. */ #gatewayDownSince
179
- /** @type {ReturnType<typeof setTimeout> | undefined} */ #gatewayProbeTimer
180
- /** @type {number} */ #gatewayProbeDelay
181
- /** @type {number} */ #gatewayProbeAttempts = 0
181
+ /** @type {number | undefined} - Wall-clock time of the last hub-reported gateway unavailability. */ #gatewayUnavailableSince
182
+ /**
183
+ * Targets this member most recently answered with "the hub has no gateway for
184
+ * that target". Scoped to the exact target so one unreachable machine cannot
185
+ * take this route class out of rotation for every other target, and cleared by
186
+ * the next OPENED for the same target instead of expiring on a timer.
187
+ * @type {Map<string, number>}
188
+ */
189
+ #targetUnavailableSince = new Map()
182
190
  /** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */ #disconnectAttribution
183
191
 
184
192
  /** @param {ReverseGatewayConnectionOptions} options - Hub route and safety policy. */
@@ -194,7 +202,6 @@ export class ReverseGatewayConnection extends EventEmitter {
194
202
  })
195
203
  this.#generation = options.initialGeneration ?? Date.now() * 1000
196
204
  this.#reconnectDelay = options.reconnectDelayMs ?? 250
197
- this.#gatewayProbeDelay = options.gatewayProbeDelayMs ?? DEFAULT_GATEWAY_PROBE_DELAY_MS
198
205
  this.#sendWindowLimit = this.#initialWindow()
199
206
  this.createConnection = this.createConnection.bind(this)
200
207
  }
@@ -250,7 +257,6 @@ export class ReverseGatewayConnection extends EventEmitter {
250
257
  /** Creates a fresh or synchronously recovered reverse stream. */
251
258
  #createConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback, /** @type {boolean} */ recovering) {
252
259
  if (this.#closing) throw closedError()
253
- if (this.#draining) throw Object.assign(new Error("Reverse gateway member is draining"), {code: "SOCKETDUCT_TRANSPORT_SET_DRAINING"})
254
260
  if (options?.manualReceiveAck !== undefined && typeof options.manualReceiveAck !== "boolean") throw new TypeError("manualReceiveAck must be a boolean")
255
261
  if (!recovering && this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
256
262
  if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum reverse virtual stream count exceeded")
@@ -372,92 +378,58 @@ export class ReverseGatewayConnection extends EventEmitter {
372
378
  * @returns {void}
373
379
  */
374
380
  #markGatewayUnavailable(code) {
375
- const firstMark = this.#gatewayDownSince === undefined
376
- this.#gatewayDownSince ??= Date.now()
381
+ const firstMark = this.#gatewayUnavailableSince === undefined
382
+ this.#gatewayUnavailableSince = Date.now()
377
383
  if (firstMark) {
378
384
  logDiagnostic(this.#options.logger, "reverse_client.gateway_unavailable", {
379
385
  code,
380
386
  generation: this.#generation,
381
387
  ...this.#routeIdentityFields()
382
388
  })
383
- this.#emitStateChange()
384
389
  }
385
- this.#scheduleGatewayProbe()
386
390
  }
387
391
 
388
392
  /**
389
- * Registers this member's route class as available again, which is the only
390
- * transition back into service.
391
- * @returns {void}
393
+ * Whether the hub last reported this member's route class as having no selectable
394
+ * gateway. The report is a property of the live connection, not a short-lived cache:
395
+ * it arrives once per connection at selection time and only changes when the hub says
396
+ * otherwise or an open succeeds. Expiring it on a timer made the transport set pick a
397
+ * route class the hub had already declared unserviceable, and the reset that followed
398
+ * failed the caller's operation — a build lost its first transport open this way.
399
+ * The transport set still falls back to marked members when every member is marked, so
400
+ * a mark can never wedge the set.
401
+ * @returns {boolean} True while the hub reports no selectable gateway for this member.
392
402
  */
393
- #markGatewayAvailable() {
394
- if (this.#gatewayDownSince === undefined) return
395
- const downMs = Date.now() - this.#gatewayDownSince
396
- this.#gatewayDownSince = undefined
397
- this.#gatewayProbeDelay = this.#options.gatewayProbeDelayMs ?? DEFAULT_GATEWAY_PROBE_DELAY_MS
398
- this.#gatewayProbeAttempts = 0
399
- if (this.#gatewayProbeTimer !== undefined) clearTimeout(this.#gatewayProbeTimer)
400
- this.#gatewayProbeTimer = undefined
401
- logDiagnostic(this.#options.logger, "reverse_client.gateway_available", {
402
- downMs,
403
- generation: this.#generation,
404
- ...this.#routeIdentityFields()
405
- })
406
- this.#emitStateChange()
403
+ #gatewayUnavailable() {
404
+ return this.#gatewayUnavailableSince !== undefined
407
405
  }
408
406
 
409
407
  /**
410
- * Keeps reaching the hub while this member is down, so a returning gateway is
411
- * detected and taken into service again without operator action. Only a fresh
412
- * hub selection can report a route class available, so the probe re-selects.
413
- * @returns {void}
408
+ * Whether this member most recently reported the exact target unserviceable.
409
+ * Scoped per target: one unreachable machine must not remove this route class
410
+ * from rotation for every other machine.
411
+ * @param {{host: string, port: number}} target - Logical target a caller wants to open.
412
+ * @returns {boolean} True while this member is marked down for that target.
414
413
  */
415
- #scheduleGatewayProbe() {
416
- if (this.#closing || this.#fatal || this.#gatewayDownSince === undefined || this.#gatewayProbeTimer !== undefined) return
417
- const delay = this.#gatewayProbeDelay
418
- this.#gatewayProbeDelay = Math.min(this.#gatewayProbeDelay * 2, this.#options.maxGatewayProbeDelayMs ?? DEFAULT_MAX_GATEWAY_PROBE_DELAY_MS)
419
- this.#gatewayProbeTimer = setTimeout(() => {
420
- this.#gatewayProbeTimer = undefined
421
- this.#probeGateway()
422
- }, delay)
423
- if (!this.#referenced) this.#gatewayProbeTimer.unref?.()
414
+ isTargetUnavailable(target) {
415
+ return this.#targetUnavailableSince.has(targetKey(target))
424
416
  }
425
417
 
426
- /** Re-runs hub selection for a down route class and re-reads its availability. @returns {void} */
427
- #probeGateway() {
428
- if (this.#closing || this.#fatal || this.#gatewayDownSince === undefined) return
429
- this.#gatewayProbeAttempts += 1
430
- logDiagnostic(this.#options.logger, "reverse_client.gateway_probe", {
431
- attempt: this.#gatewayProbeAttempts,
432
- generation: this.#generation,
433
- physicalState: this.#state,
434
- ...this.#routeIdentityFields()
435
- })
436
- const physical = this.#physical
437
- if (physical !== undefined && !physical.destroyed) {
438
- physical.destroy()
439
- return
440
- }
441
- if (this.#connectPromise === undefined) {
442
- this.#connectPromise = new Promise((resolve, reject) => { this.#resolveConnect = resolve; this.#rejectConnect = reject })
443
- this.#connectPromise.catch(() => {})
418
+ /** Records that this member could not serve a target, bounded so long-lived sets stay small. */
419
+ #markTargetUnavailable(/** @type {{host: string, port: number}} */ target) {
420
+ const key = targetKey(target)
421
+ this.#targetUnavailableSince.delete(key)
422
+ this.#targetUnavailableSince.set(key, Date.now())
423
+ while (this.#targetUnavailableSince.size > MAX_TARGET_UNAVAILABLE_ENTRIES) {
424
+ const oldest = this.#targetUnavailableSince.keys().next()
425
+ if (oldest.done === true) break
426
+ this.#targetUnavailableSince.delete(oldest.value)
444
427
  }
445
- this.#connectPhysical()
446
- }
447
-
448
- /** Notifies owners that availability changed without changing the physical state. @returns {void} */
449
- #emitStateChange() {
450
- this.emit("stateChange", this.#state, undefined)
451
428
  }
452
429
 
453
- /**
454
- * Whether the hub currently has this member's route class registered down. The
455
- * state never expires on its own: a down member stays out of service until the
456
- * hub reports a selectable gateway again.
457
- * @returns {boolean} True while the route class is registered down.
458
- */
459
- #gatewayUnavailable() {
460
- return this.#gatewayDownSince !== undefined
430
+ /** Clears a target mark; a successful open is the only thing that proves it recovered. */
431
+ #clearTargetUnavailable(/** @type {{host: string, port: number}} */ target) {
432
+ this.#targetUnavailableSince.delete(targetKey(target))
461
433
  }
462
434
 
463
435
  status() {
@@ -467,7 +439,6 @@ export class ReverseGatewayConnection extends EventEmitter {
467
439
  return {
468
440
  physicalState: this.#state,
469
441
  gatewayUnavailable: this.#gatewayUnavailable(),
470
- gatewayProbeAttempts: this.#gatewayProbeAttempts,
471
442
  connectionGeneration: this.#generation,
472
443
  virtualStreams: this.#streams.size,
473
444
  streamStates,
@@ -491,52 +462,13 @@ export class ReverseGatewayConnection extends EventEmitter {
491
462
  ref() { this.#referenced = true; this.#physical?.ref(); this.#reconnectTimer?.ref?.(); return this }
492
463
  unref() { this.#referenced = false; this.#physical?.unref(); this.#reconnectTimer?.unref?.(); return this }
493
464
 
494
- /**
495
- * Graceful counterpart of `close()`: refuses new virtual streams, lets the open
496
- * ones finish on their own, then releases the shared transport. Transport sets
497
- * that are torn down while they carry work must use this instead of `close()`,
498
- * otherwise live streams are reset at the relay mid-transfer.
499
- * @returns {Promise<void>}
500
- */
501
- async drain() {
502
- if (this.#closePromise !== undefined) return this.#closePromise
503
- this.#draining = true
504
- this.#closePromise = (async () => {
505
- logDiagnostic(this.#options.logger, "reverse_client.draining", {virtualStreams: this.#streams.size})
506
- await this.#waitForStreamsToDrain()
507
- await this.close()
508
- })()
509
-
510
- return this.#closePromise
511
- }
512
-
513
- /** @returns {Promise<void>} */
514
- #waitForStreamsToDrain() {
515
- if (this.#streams.size === 0) return Promise.resolve()
516
-
517
- return new Promise((resolve) => { this.#drainWaiters.push(resolve) })
518
- }
519
-
520
- /** @returns {void} */
521
- #releaseDrainWaiters() {
522
- if (this.#drainWaiters.length === 0) return
523
- const waiters = this.#drainWaiters.splice(0)
524
- for (const resolve of waiters) resolve()
525
- }
526
-
527
465
  async close() {
528
466
  if (this.#closing) return
529
- if (this.#streams.size > 0 && !this.#draining) {
530
- logDiagnostic(this.#options.logger, "reverse_client.closed_with_live_streams", {streamsKilled: this.#streams.size})
531
- }
532
467
  this.#closing = true
533
468
  if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
534
469
  this.#reconnectTimer = undefined
535
- if (this.#gatewayProbeTimer !== undefined) clearTimeout(this.#gatewayProbeTimer)
536
- this.#gatewayProbeTimer = undefined
537
470
  this.#setState("closed")
538
471
  for (const stream of [...this.#streams.values()]) stream.socket.destroy()
539
- this.#releaseDrainWaiters()
540
472
  const physical = this.#physical
541
473
  this.#physical = undefined
542
474
  if (physical !== undefined && !physical.destroyed) {
@@ -560,8 +492,6 @@ export class ReverseGatewayConnection extends EventEmitter {
560
492
  this.#closing = true
561
493
  if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
562
494
  this.#reconnectTimer = undefined
563
- if (this.#gatewayProbeTimer !== undefined) clearTimeout(this.#gatewayProbeTimer)
564
- this.#gatewayProbeTimer = undefined
565
495
  this.#setState("closed")
566
496
  const physical = this.#physical
567
497
  physical?.pause()
@@ -774,7 +704,7 @@ export class ReverseGatewayConnection extends EventEmitter {
774
704
  const selection = this.#routeIdentityFields()
775
705
  if (detail.generation !== generation || ("gatewayId" in selection ? detail.gatewayId !== selection.gatewayId || Object.hasOwn(detail, "routeClass") : detail.routeClass !== selection.routeClass || Object.hasOwn(detail, "gatewayId"))) throw fatalError("GATEWAY_SELECTION_FAILED")
776
706
  if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_SELECTION_FAILED")
777
- if (detail.selectable === true) this.#markGatewayAvailable()
707
+ if (detail.selectable === true) this.#gatewayUnavailableSince = undefined
778
708
  else if (detail.selectable === false) this.#markGatewayUnavailable("GATEWAY_SELECT_UNAVAILABLE")
779
709
  selected = true
780
710
  if (detail.resumed) {
@@ -901,7 +831,14 @@ export class ReverseGatewayConnection extends EventEmitter {
901
831
  stream.sendWindow = Math.min(stream.sendWindow, detail.initialWindow)
902
832
  stream.state = stream.localEnded ? "half-closed" : "open"
903
833
  stream.opened = true
904
- this.#markGatewayAvailable()
834
+ this.#clearTargetUnavailable(stream.target)
835
+ if (this.#gatewayUnavailableSince !== undefined) {
836
+ this.#gatewayUnavailableSince = undefined
837
+ logDiagnostic(this.#options.logger, "reverse_client.gateway_available", {
838
+ generation: this.#generation,
839
+ ...this.#routeIdentityFields()
840
+ })
841
+ }
905
842
  logDiagnostic(this.#options.logger, "virtual_stream.opened", {
906
843
  streamId: stream.id,
907
844
  generation: this.#generation,
@@ -955,9 +892,21 @@ export class ReverseGatewayConnection extends EventEmitter {
955
892
  const detail = decodeV3Control(frame.payload)
956
893
  const code = typeof detail.code === "string" ? detail.code : "STREAM_RESET"
957
894
  stream.state = "reset"
958
- if (code === "GATEWAY_UNAVAILABLE" || code === "GATEWAY_DISCONNECTED" || code === "GATEWAY_REPLACED") {
959
- this.#markGatewayUnavailable(code)
960
- }
895
+ // A stream reset describes this stream's target, not the health of the
896
+ // member's whole route class. The hub resolves gateways per (route class,
897
+ // target) and the transport set retries a failed open across members, so a
898
+ // single unreachable target would otherwise mark every route class down.
899
+ // Such a mark never expires on its own, which deadlocks the set: nothing is
900
+ // selected, so no open can succeed, so no mark can ever be cleared.
901
+ // Route-class availability is owned by the hub's GATEWAY_SELECTED response,
902
+ // which is class-scoped and re-read on every probe.
903
+ //
904
+ // What the reset does own is this target: the transport set must stop
905
+ // handing this member opens for a target it just reported unserviceable,
906
+ // while every other target keeps using it. The mark is keyed by target and
907
+ // cleared by the next OPENED for that target, so it never expires on a
908
+ // timer and can never wedge the set.
909
+ if (code === "GATEWAY_UNAVAILABLE" || code === "GATEWAY_DISCONNECTED") this.#markTargetUnavailable(stream.target)
961
910
  const error = attachDisconnectAttribution(new SocketductStreamError("Reverse gateway stream was reset", code), validHubAttribution(detail) ?? (code === "GATEWAY_DISCONNECTED" ? this.#disconnectAttribution : undefined))
962
911
  stream.socket._receiveError(error)
963
912
  return
@@ -1244,7 +1193,6 @@ export class ReverseGatewayConnection extends EventEmitter {
1244
1193
  while (stream.pendingWrites.length > 0) this.#finishWrite(stream, pendingWriteError)
1245
1194
  if (!this.#preserveSpools) this.#trackSpoolCleanup(stream)
1246
1195
  this.#progressPendingWrites()
1247
- if (this.#streams.size === 0) this.#releaseDrainWaiters()
1248
1196
  }
1249
1197
 
1250
1198
  /** @param {ReverseClientStream} stream - Stream. @param {number} sequence - ACK. */
@@ -1330,11 +1278,6 @@ export class ReverseGatewayConnection extends EventEmitter {
1330
1278
  this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, this.#options.maxReconnectDelayMs ?? 30_000)
1331
1279
  this.#reconnectTimer = setTimeout(() => {
1332
1280
  this.#reconnectTimer = undefined
1333
- // clearTimeout() cannot stop a callback that is already queued, so a
1334
- // detach()/close() that lands in that window used to reconnect anyway:
1335
- // it reopened a physical transport after the caller had deliberately
1336
- // detached (observed as ENOENT reading a deleted recovery CA file).
1337
- if (this.#closing || this.#fatal) return
1338
1281
  if (this.#connectPromise === undefined) {
1339
1282
  this.#connectPromise = new Promise((resolve, reject) => { this.#resolveConnect = resolve; this.#rejectConnect = reject })
1340
1283
  this.#connectPromise.catch(() => {})
@@ -1452,9 +1395,6 @@ function validateOptions(options) {
1452
1395
  positiveInteger(options.maxConnectionBufferBytes, "maxConnectionBufferBytes", DEFAULT_CONNECTION_BUFFER_BYTES)
1453
1396
  if (options.spoolDirectory !== undefined && (typeof options.spoolDirectory !== "string" || options.spoolDirectory.length === 0)) throw new TypeError("spoolDirectory must be a non-empty string")
1454
1397
  positiveInteger(options.maxAgeMs, "maxAgeMs", 24 * 60 * 60 * 1000)
1455
- const gatewayProbeDelay = positiveInteger(options.gatewayProbeDelayMs, "gatewayProbeDelayMs", DEFAULT_GATEWAY_PROBE_DELAY_MS)
1456
- const maxGatewayProbeDelay = positiveInteger(options.maxGatewayProbeDelayMs, "maxGatewayProbeDelayMs", DEFAULT_MAX_GATEWAY_PROBE_DELAY_MS)
1457
- if (maxGatewayProbeDelay < gatewayProbeDelay) throw new RangeError("maxGatewayProbeDelayMs must not be smaller than gatewayProbeDelayMs")
1458
1398
  if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
1459
1399
  }
1460
1400
 
@@ -207,3 +207,163 @@ describe("multiplexed transport", () => {
207
207
  sockets.forEach((socket) => socket.destroy())
208
208
  }))
209
209
  })
210
+
211
+ /**
212
+ * `close()` terminates live virtual streams, which the relay answers with a
213
+ * stream reset — that is how in-flight work on the other side of a transport
214
+ * set died when the owning process shut down. `drain()` is the graceful verb:
215
+ * it refuses new streams, waits for the open ones, and only then releases the
216
+ * transport.
217
+ */
218
+
219
+ /** @returns {Promise<void>} */
220
+ async function settleTurns() {
221
+ for (let index = 0; index < 10; index += 1) await new Promise((resolve) => setImmediate(resolve))
222
+ }
223
+
224
+ /**
225
+ * @param {Array<import('../../src/client/virtual-socket.js').VirtualSocket>} streams - Open streams.
226
+ * @param {number} index - Index.
227
+ * @returns {import('../../src/client/virtual-socket.js').VirtualSocket} - The opened stream.
228
+ */
229
+ function streamAt(streams, index) {
230
+ const stream = streams[index]
231
+
232
+ if (stream === undefined) throw new Error(`Expected stream ${index} to be open`)
233
+
234
+ return stream
235
+ }
236
+
237
+ /**
238
+ * @param {(callback: () => Promise<void> | void) => void} after - Cleanup registrar.
239
+ * @param {number} streamCount - Streams to open.
240
+ */
241
+ async function openDrainStreams(after, streamCount) {
242
+ /** @type {Set<import('node:net').Socket>} */
243
+ const targetSockets = new Set()
244
+ const target = createServer((socket) => {
245
+ targetSockets.add(socket)
246
+ socket.on("close", () => targetSockets.delete(socket))
247
+ socket.on("data", (chunk) => socket.write(chunk))
248
+ })
249
+ const targetPort = await listen(target)
250
+
251
+ after(async () => {
252
+ for (const socket of targetSockets) socket.destroy()
253
+ await new Promise((resolve) => target.close(() => resolve(undefined)))
254
+ })
255
+
256
+ const relay = await startMultiplexedTcpRelay({
257
+ tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
258
+ authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === targetPort
259
+ })
260
+
261
+ after(() => relay.close())
262
+
263
+ const connection = new MultiplexedSocketductConnection({
264
+ relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}
265
+ })
266
+
267
+ after(() => connection.close())
268
+ /** @type {Array<import('../../src/client/virtual-socket.js').VirtualSocket>} */
269
+ const streams = []
270
+
271
+ for (let index = 0; index < streamCount; index += 1) {
272
+ streams.push(connection.createConnection({host: "127.0.0.1", port: targetPort, sessionName: `drain-${index}`}))
273
+ }
274
+
275
+ await Promise.all(streams.map((stream) => onceEvent(stream, "connect")))
276
+ expect(connection.status().virtualStreams).toBe(streamCount)
277
+
278
+ return {connection, relay, streams, targetPort}
279
+ }
280
+
281
+ describe("multiplexed transport drain", () => {
282
+ it("drain() keeps in-flight virtual streams alive until they finish", async () => withTestCleanup(async (after) => {
283
+ const {connection, relay, streams} = await openDrainStreams(after, 2)
284
+
285
+ const draining = connection.drain()
286
+
287
+ await settleTurns()
288
+
289
+ // drain() must not tear down streams that are still open.
290
+ expect(connection.status().virtualStreams).toBe(2)
291
+ expect(streamAt(streams, 0).destroyed).toBe(false)
292
+ expect(streamAt(streams, 1).destroyed).toBe(false)
293
+ // The physical transport must not be dropped while streams are live.
294
+ expect(relay.status().authenticatedConnections).toBe(1)
295
+
296
+ for (const stream of streams) stream.destroy()
297
+ await draining
298
+
299
+ expect(connection.status().virtualStreams).toBe(0)
300
+ // The physical transport is released once the last stream ends.
301
+ expect(relay.status().authenticatedConnections).toBe(0)
302
+ }))
303
+
304
+ it("drain() keeps working streams usable instead of resetting them", async () => withTestCleanup(async (after) => {
305
+ const {connection, streams} = await openDrainStreams(after, 1)
306
+ const draining = connection.drain()
307
+
308
+ await settleTurns()
309
+
310
+ streamAt(streams, 0).write("still-works")
311
+ /** @type {string} */
312
+ let echoed = ""
313
+
314
+ await new Promise((resolve, reject) => {
315
+ /** @param {Buffer} chunk - Echoed data. */
316
+ const onData = (chunk) => {
317
+ echoed += chunk.toString()
318
+ if (echoed.length < 11) return
319
+ streamAt(streams, 0).off("error", reject)
320
+ resolve(undefined)
321
+ }
322
+ streamAt(streams, 0).on("error", reject)
323
+ streamAt(streams, 0).on("data", onData)
324
+ })
325
+
326
+ // Data written during the drain must still reach the target and come back.
327
+ expect(echoed).toBe("still-works")
328
+
329
+ streamAt(streams, 0).destroy()
330
+ await draining
331
+ }))
332
+
333
+ it("drain() rejects new virtual streams while draining", async () => withTestCleanup(async (after) => {
334
+ const {connection, streams, targetPort} = await openDrainStreams(after, 1)
335
+ const draining = connection.drain()
336
+
337
+ await settleTurns()
338
+
339
+ // A draining connection must refuse new virtual streams instead of opening
340
+ // them on a doomed transport.
341
+ /** @type {unknown} */
342
+ let caughtError
343
+
344
+ try {
345
+ connection.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "late"})
346
+ } catch (error) {
347
+ caughtError = error
348
+ }
349
+
350
+ expect(caughtError).toBeInstanceOf(Error)
351
+ expect(String(/** @type {Error} */ (caughtError).message)).toMatch(/connection is closed/i)
352
+ // The stream that was already open must survive.
353
+ expect(connection.status().virtualStreams).toBe(1)
354
+
355
+ streamAt(streams, 0).destroy()
356
+ await draining
357
+ }))
358
+
359
+ it("close() still terminates in-flight virtual streams immediately", async () => withTestCleanup(async (after) => {
360
+ const {connection, relay, streams} = await openDrainStreams(after, 2)
361
+
362
+ await connection.close()
363
+
364
+ expect(connection.status().virtualStreams).toBe(0)
365
+ expect(streamAt(streams, 0).destroyed).toBe(true)
366
+ expect(streamAt(streams, 1).destroyed).toBe(true)
367
+ expect(relay.status().authenticatedConnections).toBe(0)
368
+ }))
369
+ })
@@ -56,15 +56,6 @@ async function waitUntil(predicate, label) {
56
56
  }
57
57
  }
58
58
 
59
- /** @param {() => boolean} predicate - Completion predicate. @param {string} label - Failure label. @param {number} timeoutMs - Deadline. */
60
- async function waitUntilWithin(predicate, label, timeoutMs) {
61
- const deadline = Date.now() + timeoutMs
62
- while (!predicate()) {
63
- if (Date.now() >= deadline) throw new Error(`Timed out: ${label}`)
64
- await new Promise((resolve) => setTimeout(resolve, 10))
65
- }
66
- }
67
-
68
59
  /** @param {string} prefix - Echo response prefix. */
69
60
  async function createEchoTarget(prefix) {
70
61
  const sockets = new Set()
@@ -480,58 +471,42 @@ describe("reverse gateway route-class", () => {
480
471
  stream.destroy()
481
472
  }))
482
473
 
483
- it("a route class registered down stays down, keeps being reached, and rejoins automatically", async () => withTestCleanup(async (after) => {
484
- const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-down-"))
474
+ it("a reset for one target without a gateway does not mark the whole route class unavailable", async () => withTestCleanup(async (after) => {
475
+ const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-target-scope-"))
485
476
  after(() => rm(root, {recursive: true, force: true}))
486
477
  const certificates = generateTestCertificates(root)
487
478
  const targetA = await createEchoTarget("a:")
488
479
  after(() => targetA.close())
480
+ const targetB = await createEchoTarget("b:")
481
+ after(() => targetB.close())
489
482
 
483
+ // The route class is serviceable, but only target A has a configured gateway.
484
+ // Target B has none, so the hub resets opens for it with GATEWAY_UNAVAILABLE.
485
+ // That statement is about target B, not about the route class as a whole.
490
486
  const hub = await startReverseGatewayHub({
491
487
  tokens: new StaticTokenStore([
492
488
  {username: "home-a", token: "gateway-a-token"},
493
489
  {username: "tensorbuzz", token: "client-token"}
494
490
  ]),
495
491
  tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
496
- knownGatewayIds: ["server-a-starlink"],
492
+ knownGatewayIds: ["server-a-starlink", "server-b-starlink"],
497
493
  knownRouteClasses: ["starlink"],
498
- authorizeGateway: ({principal, gatewayId}) => principal.username === "home-a" && gatewayId === "server-a-starlink",
494
+ authorizeGateway: ({principal, gatewayId}) => principal.username === "home-a" && (gatewayId === "server-a-starlink" || gatewayId === "server-b-starlink"),
499
495
  authorizeClientGateway: () => false,
500
496
  authorizeClientRouteClass: ({principal, routeClass}) => principal.username === "tensorbuzz" && routeClass === "starlink",
501
- resolveRouteClassGateways: () => ["server-a-starlink"],
502
- resolveOpenGateways: ({routeClass, target}) => (routeClass === "starlink" && target.host === targetA.host ? ["server-a-starlink"] : []),
503
- authorizeOpen: ({gatewayId, target}) => gatewayId === "server-a-starlink" && target.port === targetA.port
497
+ resolveRouteClassGateways: () => ["server-a-starlink", "server-b-starlink"],
498
+ resolveOpenGateways: ({routeClass, target}) => {
499
+ if (routeClass !== "starlink") return []
500
+ return target.port === targetA.port ? ["server-a-starlink"] : ["server-b-starlink"]
501
+ },
502
+ authorizeOpen: ({gatewayId, target}) => (gatewayId === "server-a-starlink" && target.port === targetA.port) || (gatewayId === "server-b-starlink" && target.port === targetB.port)
504
503
  })
505
504
  after(() => hub.close())
506
505
  const tls = {caFile: certificates.caCertFile, servername: "localhost"}
507
506
 
508
- const client = new ReverseGatewayConnection({
509
- routeClass: "starlink",
510
- connectionId: "route_class_down_client",
511
- hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
512
- tls,
513
- gatewayProbeDelayMs: 20,
514
- maxGatewayProbeDelayMs: 40,
515
- reconnectDelayMs: 10,
516
- maxReconnectDelayMs: 20
517
- })
518
- after(() => client.close())
519
-
520
- await client.connect()
521
- expect(client.status().gatewayUnavailable).toBe(true)
522
-
523
- // The member keeps reaching the hub while its route class is down.
524
- await waitUntilWithin(() => client.status().gatewayProbeAttempts >= 3, "gateway re-reaching", 4_000)
525
-
526
- // A down registration never expires on its own: the previous five-second
527
- // mark would have silently returned this member to service with no gateway.
528
- await new Promise((resolve) => setTimeout(resolve, 5_100))
529
- expect(client.status().gatewayUnavailable).toBe(true)
530
- expect(client.status().gatewayProbeAttempts >= 3).toBe(true)
531
-
532
507
  const gatewayA = new OutboundReverseGateway({
533
508
  gatewayId: "server-a-starlink",
534
- connectionId: "down_gateway_a",
509
+ connectionId: "target_scope_gateway_a",
535
510
  hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
536
511
  tls,
537
512
  authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
@@ -542,11 +517,39 @@ describe("reverse gateway route-class", () => {
542
517
  await gatewayA.start()
543
518
  await waitUntil(() => hub.status().healthyGateways === 1, "gateway A registration")
544
519
 
545
- // The same member registers the class up again and is taken back into use.
546
- await waitUntilWithin(() => client.status().gatewayUnavailable === false, "automatic re-registration", 4_000)
547
- const stream = client.createConnection({host: targetA.host, port: targetA.port})
548
- await onceEvent(stream, "connect")
549
- expect(await exchange(stream, "probe")).toBe("a:probe")
550
- stream.destroy()
520
+ const client = new ReverseGatewayConnection({
521
+ routeClass: "starlink",
522
+ connectionId: "target_scope_client",
523
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
524
+ tls
525
+ })
526
+ after(() => client.close())
527
+ await client.connect()
528
+ expect(client.status().gatewayUnavailable).toBe(false)
529
+
530
+ // Target B's gateway is configured in the route table but is not registered
531
+ // (the maintenance/offline case), so the hub resets its opens as unavailable.
532
+ const unavailableStream = client.createConnection({host: targetB.host, port: targetB.port})
533
+ const reset = await nextError(unavailableStream)
534
+ expect(reset.code).toBe("GATEWAY_UNAVAILABLE")
535
+ unavailableStream.destroy()
536
+
537
+ // The route class is healthy, so the reset must not take it out of service.
538
+ // A member marked unavailable this way is never selected again, which would
539
+ // stall every later open in the set until the caller's own deadline.
540
+ expect({value: client.status().gatewayUnavailable, context: "a target-scoped reset must not mark the route class unavailable"}).toMatchObject({value: false})
541
+
542
+ // The member must still be skipped for the target the hub could not serve,
543
+ // otherwise every later open for it keeps paying a full round trip, while
544
+ // every other target on this member stays in rotation.
545
+ expect({value: client.isTargetUnavailable({host: targetB.host, port: targetB.port}), context: "the unserviceable target is marked on this member"}).toMatchObject({value: true})
546
+ expect({value: client.isTargetUnavailable({host: targetA.host, port: targetA.port}), context: "a different target stays available on the same member"}).toMatchObject({value: false})
547
+
548
+ // A healthy target on the same route class still opens and exchanges data.
549
+ const healthyStream = client.createConnection({host: targetA.host, port: targetA.port})
550
+ await onceEvent(healthyStream, "connect")
551
+ expect(await exchange(healthyStream, "probe")).toBe("a:probe")
552
+ healthyStream.destroy()
551
553
  }))
554
+
552
555
  })