socketduct 0.1.28 → 0.1.30

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.
@@ -0,0 +1 @@
1
+ A route class the hub reports down is never selected while it stays down: the member keeps re-reaching the hub on a bounded backoff and returns to service automatically once a gateway registers again, and `status()` now exposes the down state and the probe attempts.
@@ -0,0 +1 @@
1
+ Multiplexed connections gain `drain()`, which refuses new virtual streams, waits for the open ones to finish, and only then releases the physical transport, so a shutdown no longer resets work that is still in flight; `close()` keeps its immediate-termination behaviour and now logs how many live streams it terminated.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socketduct",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "Resilient authenticated tunnel/proxy for keeping socket streams alive across flaky links.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -138,6 +138,11 @@ export class MultiplexedSocketductConnection extends EventEmitter {
138
138
 
139
139
  /** @type {boolean} */
140
140
  #closing = false
141
+ #draining = false
142
+ /** @type {Promise<void> | undefined} */
143
+ #closePromise = undefined
144
+ /** @type {Array<() => void>} */
145
+ #drainWaiters = []
141
146
 
142
147
  /** @type {boolean} */
143
148
  #warm = false
@@ -247,7 +252,7 @@ export class MultiplexedSocketductConnection extends EventEmitter {
247
252
  * @returns {Promise<void>} Resolves when the member becomes ready.
248
253
  */
249
254
  connect() {
250
- if (this.#closing) return Promise.reject(Object.assign(new Error("Multiplexed Socketduct connection is closed"), {code: "SOCKETDUCT_CONNECTION_CLOSED"}))
255
+ if (this.#closing || this.#draining) return Promise.reject(Object.assign(new Error("Multiplexed Socketduct connection is closed"), {code: "SOCKETDUCT_CONNECTION_CLOSED"}))
251
256
  if (this.#warmPromise !== undefined) return this.#warmPromise
252
257
  this.#warm = true
253
258
  this.#warmPromise = Promise.all([...this.#streams.values()].map((stream) => stream.spoolReady)).then(() => this.#ensureConnected())
@@ -297,7 +302,7 @@ export class MultiplexedSocketductConnection extends EventEmitter {
297
302
  * @returns {VirtualSocket} Socket-shaped Duplex.
298
303
  */
299
304
  #createConnection(options, callback, connectOnReady) {
300
- if (this.#closing) throw new Error("Multiplexed Socketduct connection is closed")
305
+ if (this.#closing || this.#draining) throw new Error("Multiplexed Socketduct connection is closed")
301
306
  if (options.manualReceiveAck !== undefined) throw new TypeError("manualReceiveAck is supported only by reverse gateway connections")
302
307
  if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum virtual stream count exceeded")
303
308
  const target = normalizeTarget(options)
@@ -446,10 +451,52 @@ export class MultiplexedSocketductConnection extends EventEmitter {
446
451
  return this
447
452
  }
448
453
 
449
- /** Gracefully closes all virtual streams and the shared transport. @returns {Promise<void>} */
454
+ /**
455
+ * Terminates every virtual stream and releases the shared transport. Streams
456
+ * still in flight are reset at the relay by design, so the number of them is
457
+ * logged (`multiplexed_connection.shutdown_with_live_streams`) instead of
458
+ * disappearing. Use `drain()` when in-flight work must survive.
459
+ * @returns {Promise<void>}
460
+ */
450
461
  async close() {
462
+ if (this.#closePromise !== undefined) return this.#closePromise
463
+ this.#closePromise = this.#shutdownTransport()
464
+
465
+ return this.#closePromise
466
+ }
467
+
468
+ /**
469
+ * Graceful counterpart of `close()`: refuses new virtual streams, lets the
470
+ * open ones finish, and only then releases the shared transport. Use this
471
+ * wherever a transport must not reset work that is still in flight.
472
+ * @returns {Promise<void>}
473
+ */
474
+ async drain() {
475
+ if (this.#closePromise !== undefined) return this.#closePromise
476
+ this.#draining = true
477
+ this.#closePromise = (async () => {
478
+ await this.#waitForStreamsToDrain()
479
+ await this.#shutdownTransport()
480
+ })()
481
+
482
+ return this.#closePromise
483
+ }
484
+
485
+ /** @returns {Promise<void>} */
486
+ #waitForStreamsToDrain() {
487
+ if (this.#streams.size === 0) return Promise.resolve()
488
+
489
+ return new Promise((resolve) => { this.#drainWaiters.push(resolve) })
490
+ }
491
+
492
+ /** @returns {Promise<void>} */
493
+ async #shutdownTransport() {
451
494
  if (this.#closing) return
452
495
  this.#closing = true
496
+
497
+ if (this.#streams.size > 0) {
498
+ logDiagnostic(this.#options.logger, "multiplexed_connection.shutdown_with_live_streams", {streamsKilled: this.#streams.size})
499
+ }
453
500
  this.#setPhysicalState("closed")
454
501
  if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
455
502
  this.#reconnectTimer = undefined
@@ -618,6 +665,8 @@ export class MultiplexedSocketductConnection extends EventEmitter {
618
665
  this.#trackSpoolCleanup(stream)
619
666
  }
620
667
  if (this.#streams.size === 0 && !this.#authenticated && this.#physical !== undefined) this.#physical.destroy()
668
+
669
+ if (this.#streams.size === 0) for (const resolve of this.#drainWaiters.splice(0)) resolve()
621
670
  }
622
671
 
623
672
  /** @param {number} streamId - Stream. @returns {void} */
@@ -61,6 +61,7 @@ const GATEWAY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/
61
61
  * @property {() => MemberConnectionStatus} status - Privacy-safe status.
62
62
  * @property {() => Promise<void>} close - Close the member.
63
63
  * @property {() => Promise<void>} detach - Preserve durable streams for process-replacement recovery.
64
+ * @property {() => Promise<void>} drain - Let live streams finish before releasing the member.
64
65
  * @property {(eventName: string, listener: (...args: unknown[]) => void) => unknown} on - Subscribe to lifecycle changes.
65
66
  * @property {() => unknown} [ref] - Reference resources.
66
67
  * @property {() => unknown} [unref] - Unreference resources.
@@ -493,13 +494,14 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
493
494
  return this
494
495
  }
495
496
 
496
- /** @returns {{state: string, configuredMembers: number, connectingMembers: number, readyMembers: number, degradedMembers: number, closedMembers: number, minimumReadyMembers: number, pendingOpens: number, maxPendingOpens: number, virtualOpens: number, physicalConnects: number, reconnectAttempts: number, budgets: {dataBytes: number, spoolBytes: number, queuedBytes: number, maxDataBytes: number, maxSpoolBytes: number, maxQueuedBytes: number}, members: Array<{gatewayId?: string, routeClass?: string, state: string, assignments: number, physicalConnects: number, reconnectAttempts: number, reconnectSuccesses: number, virtualStreams: number, queuedBytes: number, spoolBytes: number, spoolCleanupFailures: number, terminalErrorCode: string | null}>}} Privacy-safe aggregate and per-member status. */
497
+ /** @returns {{state: string, configuredMembers: number, connectingMembers: number, readyMembers: number, degradedMembers: number, closedMembers: number, minimumReadyMembers: number, pendingOpens: number, maxPendingOpens: number, virtualOpens: number, physicalConnects: number, reconnectAttempts: number, budgets: {dataBytes: number, spoolBytes: number, queuedBytes: number, maxDataBytes: number, maxSpoolBytes: number, maxQueuedBytes: number}, members: Array<{gatewayId?: string, routeClass?: string, state: string, gatewayUnavailable: boolean, assignments: number, physicalConnects: number, reconnectAttempts: number, reconnectSuccesses: number, virtualStreams: number, queuedBytes: number, spoolBytes: number, spoolCleanupFailures: number, terminalErrorCode: string | null}>}} Privacy-safe aggregate and per-member status. */
497
498
  status() {
498
499
  const members = this.#members.map((member) => {
499
500
  const status = member.connection.status()
500
501
  return {
501
502
  ...memberAllocation(member),
502
503
  state: status.physicalState,
504
+ gatewayUnavailable: status.gatewayUnavailable === true,
503
505
  assignments: member.assignments,
504
506
  physicalConnects: status.physicalConnects,
505
507
  reconnectAttempts: status.reconnectAttempts,
@@ -549,7 +551,12 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
549
551
  return this.#shutdown("detach")
550
552
  }
551
553
 
552
- /** @param {"close" | "detach"} method - Member shutdown operation. @returns {Promise<void>} */
554
+ /** Drains live virtual streams on every member before releasing them once. @returns {Promise<void>} */
555
+ drain() {
556
+ return this.#shutdown("drain")
557
+ }
558
+
559
+ /** @param {"close" | "detach" | "drain"} method - Member shutdown operation. @returns {Promise<void>} */
553
560
  #shutdown(method) {
554
561
  if (this.#closePromise !== undefined) return this.#closePromise
555
562
  this.#closed = true
@@ -571,33 +578,25 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
571
578
  }
572
579
 
573
580
  /**
574
- * Selects the next ready member, preferring members whose gateway the hub has
575
- * not recently reported unavailable. When every ready member carries a
576
- * gateway-unavailable mark, any ready member may still be selected so a stale
577
- * mark can never wedge the set.
581
+ * Selects the next ready member whose route class the hub has registered up.
582
+ * A member the hub registered down is never selected: nothing is sent to a
583
+ * route class without a reachable gateway. When every member is registered
584
+ * down the open stays pending here until a member reports availability again
585
+ * (the member keeps probing the hub, and its state change re-drains this set).
578
586
  * @returns {{member: TransportMember, previousCursor: number} | undefined}
579
587
  */
580
588
  #selectReadyMember() {
581
589
  if (this.#closed) throw closedError()
582
590
  const previousCursor = this.#cursor
583
- /** @type {{member: TransportMember, index: number} | undefined} */
584
- let fallback
585
591
  for (let offset = 0; offset < this.#members.length; offset += 1) {
586
592
  const index = (this.#cursor + offset) % this.#members.length
587
593
  const member = /** @type {TransportMember} */ (this.#members[index])
588
594
  const status = member.connection.status()
589
595
  if (status.physicalState !== "ready") continue
590
- if (status.gatewayUnavailable === true) {
591
- fallback ??= {member, index}
592
- continue
593
- }
596
+ if (status.gatewayUnavailable === true) continue
594
597
  this.#cursor = (index + 1) % this.#members.length
595
598
  return {member, previousCursor}
596
599
  }
597
- if (fallback !== undefined) {
598
- this.#cursor = (fallback.index + 1) % this.#members.length
599
- return {member: fallback.member, previousCursor}
600
- }
601
600
  return undefined
602
601
  }
603
602
 
@@ -32,6 +32,8 @@ 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
37
 
36
38
  /**
37
39
  * @typedef {object} ReverseGatewayConnectionOptions
@@ -50,6 +52,8 @@ const MANIFEST_PAGE_STREAMS = 64
50
52
  * @property {number} [connectTimeoutMs] - TLS/auth deadline.
51
53
  * @property {number} [reconnectDelayMs] - Initial reconnect delay.
52
54
  * @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.
53
57
  * @property {number} [initialGeneration] - Monotonic seed for controlled replacement; defaults to wall-clock microseconds.
54
58
  * @property {boolean} [continuousReconnect] - Keep the member warm after loss, default true.
55
59
  * @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - zstd safety bounds.
@@ -168,7 +172,10 @@ export class ReverseGatewayConnection extends EventEmitter {
168
172
  /** @type {number} */ #spoolBytes = 0
169
173
  /** @type {number} */ #spoolCleanupFailures = 0
170
174
  /** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
171
- /** @type {number | undefined} - Wall-clock time of the last hub-reported gateway unavailability. */ #gatewayUnavailableSince
175
+ /** @type {number | undefined} - Wall-clock time the hub first reported this member's route class unselectable; undefined means registered available. */ #gatewayDownSince
176
+ /** @type {ReturnType<typeof setTimeout> | undefined} */ #gatewayProbeTimer
177
+ /** @type {number} */ #gatewayProbeDelay
178
+ /** @type {number} */ #gatewayProbeAttempts = 0
172
179
  /** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */ #disconnectAttribution
173
180
 
174
181
  /** @param {ReverseGatewayConnectionOptions} options - Hub route and safety policy. */
@@ -184,6 +191,7 @@ export class ReverseGatewayConnection extends EventEmitter {
184
191
  })
185
192
  this.#generation = options.initialGeneration ?? Date.now() * 1000
186
193
  this.#reconnectDelay = options.reconnectDelayMs ?? 250
194
+ this.#gatewayProbeDelay = options.gatewayProbeDelayMs ?? DEFAULT_GATEWAY_PROBE_DELAY_MS
187
195
  this.#sendWindowLimit = this.#initialWindow()
188
196
  this.createConnection = this.createConnection.bind(this)
189
197
  }
@@ -360,30 +368,92 @@ export class ReverseGatewayConnection extends EventEmitter {
360
368
  * @returns {void}
361
369
  */
362
370
  #markGatewayUnavailable(code) {
363
- const firstMark = this.#gatewayUnavailableSince === undefined
364
- this.#gatewayUnavailableSince = Date.now()
371
+ const firstMark = this.#gatewayDownSince === undefined
372
+ this.#gatewayDownSince ??= Date.now()
365
373
  if (firstMark) {
366
374
  logDiagnostic(this.#options.logger, "reverse_client.gateway_unavailable", {
367
375
  code,
368
376
  generation: this.#generation,
369
377
  ...this.#routeIdentityFields()
370
378
  })
379
+ this.#emitStateChange()
371
380
  }
381
+ this.#scheduleGatewayProbe()
372
382
  }
373
383
 
374
384
  /**
375
- * Whether the hub last reported this member's route class as having no selectable
376
- * gateway. The report is a property of the live connection, not a short-lived cache:
377
- * it arrives once per connection at selection time and only changes when the hub says
378
- * otherwise or an open succeeds. Expiring it on a timer made the transport set pick a
379
- * route class the hub had already declared unserviceable, and the reset that followed
380
- * failed the caller's operation — a build lost its first transport open this way.
381
- * The transport set still falls back to marked members when every member is marked, so
382
- * a mark can never wedge the set.
383
- * @returns {boolean} True while the hub reports no selectable gateway for this member.
385
+ * Registers this member's route class as available again, which is the only
386
+ * transition back into service.
387
+ * @returns {void}
388
+ */
389
+ #markGatewayAvailable() {
390
+ if (this.#gatewayDownSince === undefined) return
391
+ const downMs = Date.now() - this.#gatewayDownSince
392
+ this.#gatewayDownSince = undefined
393
+ this.#gatewayProbeDelay = this.#options.gatewayProbeDelayMs ?? DEFAULT_GATEWAY_PROBE_DELAY_MS
394
+ this.#gatewayProbeAttempts = 0
395
+ if (this.#gatewayProbeTimer !== undefined) clearTimeout(this.#gatewayProbeTimer)
396
+ this.#gatewayProbeTimer = undefined
397
+ logDiagnostic(this.#options.logger, "reverse_client.gateway_available", {
398
+ downMs,
399
+ generation: this.#generation,
400
+ ...this.#routeIdentityFields()
401
+ })
402
+ this.#emitStateChange()
403
+ }
404
+
405
+ /**
406
+ * Keeps reaching the hub while this member is down, so a returning gateway is
407
+ * detected and taken into service again without operator action. Only a fresh
408
+ * hub selection can report a route class available, so the probe re-selects.
409
+ * @returns {void}
410
+ */
411
+ #scheduleGatewayProbe() {
412
+ if (this.#closing || this.#fatal || this.#gatewayDownSince === undefined || this.#gatewayProbeTimer !== undefined) return
413
+ const delay = this.#gatewayProbeDelay
414
+ this.#gatewayProbeDelay = Math.min(this.#gatewayProbeDelay * 2, this.#options.maxGatewayProbeDelayMs ?? DEFAULT_MAX_GATEWAY_PROBE_DELAY_MS)
415
+ this.#gatewayProbeTimer = setTimeout(() => {
416
+ this.#gatewayProbeTimer = undefined
417
+ this.#probeGateway()
418
+ }, delay)
419
+ if (!this.#referenced) this.#gatewayProbeTimer.unref?.()
420
+ }
421
+
422
+ /** Re-runs hub selection for a down route class and re-reads its availability. @returns {void} */
423
+ #probeGateway() {
424
+ if (this.#closing || this.#fatal || this.#gatewayDownSince === undefined) return
425
+ this.#gatewayProbeAttempts += 1
426
+ logDiagnostic(this.#options.logger, "reverse_client.gateway_probe", {
427
+ attempt: this.#gatewayProbeAttempts,
428
+ generation: this.#generation,
429
+ physicalState: this.#state,
430
+ ...this.#routeIdentityFields()
431
+ })
432
+ const physical = this.#physical
433
+ if (physical !== undefined && !physical.destroyed) {
434
+ physical.destroy()
435
+ return
436
+ }
437
+ if (this.#connectPromise === undefined) {
438
+ this.#connectPromise = new Promise((resolve, reject) => { this.#resolveConnect = resolve; this.#rejectConnect = reject })
439
+ this.#connectPromise.catch(() => {})
440
+ }
441
+ this.#connectPhysical()
442
+ }
443
+
444
+ /** Notifies owners that availability changed without changing the physical state. @returns {void} */
445
+ #emitStateChange() {
446
+ this.emit("stateChange", this.#state, undefined)
447
+ }
448
+
449
+ /**
450
+ * Whether the hub currently has this member's route class registered down. The
451
+ * state never expires on its own: a down member stays out of service until the
452
+ * hub reports a selectable gateway again.
453
+ * @returns {boolean} True while the route class is registered down.
384
454
  */
385
455
  #gatewayUnavailable() {
386
- return this.#gatewayUnavailableSince !== undefined
456
+ return this.#gatewayDownSince !== undefined
387
457
  }
388
458
 
389
459
  status() {
@@ -393,6 +463,7 @@ export class ReverseGatewayConnection extends EventEmitter {
393
463
  return {
394
464
  physicalState: this.#state,
395
465
  gatewayUnavailable: this.#gatewayUnavailable(),
466
+ gatewayProbeAttempts: this.#gatewayProbeAttempts,
396
467
  connectionGeneration: this.#generation,
397
468
  virtualStreams: this.#streams.size,
398
469
  streamStates,
@@ -421,6 +492,8 @@ export class ReverseGatewayConnection extends EventEmitter {
421
492
  this.#closing = true
422
493
  if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
423
494
  this.#reconnectTimer = undefined
495
+ if (this.#gatewayProbeTimer !== undefined) clearTimeout(this.#gatewayProbeTimer)
496
+ this.#gatewayProbeTimer = undefined
424
497
  this.#setState("closed")
425
498
  for (const stream of [...this.#streams.values()]) stream.socket.destroy()
426
499
  const physical = this.#physical
@@ -446,6 +519,8 @@ export class ReverseGatewayConnection extends EventEmitter {
446
519
  this.#closing = true
447
520
  if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
448
521
  this.#reconnectTimer = undefined
522
+ if (this.#gatewayProbeTimer !== undefined) clearTimeout(this.#gatewayProbeTimer)
523
+ this.#gatewayProbeTimer = undefined
449
524
  this.#setState("closed")
450
525
  const physical = this.#physical
451
526
  physical?.pause()
@@ -658,7 +733,7 @@ export class ReverseGatewayConnection extends EventEmitter {
658
733
  const selection = this.#routeIdentityFields()
659
734
  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")
660
735
  if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_SELECTION_FAILED")
661
- if (detail.selectable === true) this.#gatewayUnavailableSince = undefined
736
+ if (detail.selectable === true) this.#markGatewayAvailable()
662
737
  else if (detail.selectable === false) this.#markGatewayUnavailable("GATEWAY_SELECT_UNAVAILABLE")
663
738
  selected = true
664
739
  if (detail.resumed) {
@@ -785,13 +860,7 @@ export class ReverseGatewayConnection extends EventEmitter {
785
860
  stream.sendWindow = Math.min(stream.sendWindow, detail.initialWindow)
786
861
  stream.state = stream.localEnded ? "half-closed" : "open"
787
862
  stream.opened = true
788
- if (this.#gatewayUnavailableSince !== undefined) {
789
- this.#gatewayUnavailableSince = undefined
790
- logDiagnostic(this.#options.logger, "reverse_client.gateway_available", {
791
- generation: this.#generation,
792
- ...this.#routeIdentityFields()
793
- })
794
- }
863
+ this.#markGatewayAvailable()
795
864
  logDiagnostic(this.#options.logger, "virtual_stream.opened", {
796
865
  streamId: stream.id,
797
866
  generation: this.#generation,
@@ -1219,6 +1288,11 @@ export class ReverseGatewayConnection extends EventEmitter {
1219
1288
  this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, this.#options.maxReconnectDelayMs ?? 30_000)
1220
1289
  this.#reconnectTimer = setTimeout(() => {
1221
1290
  this.#reconnectTimer = undefined
1291
+ // clearTimeout() cannot stop a callback that is already queued, so a
1292
+ // detach()/close() that lands in that window used to reconnect anyway:
1293
+ // it reopened a physical transport after the caller had deliberately
1294
+ // detached (observed as ENOENT reading a deleted recovery CA file).
1295
+ if (this.#closing || this.#fatal) return
1222
1296
  if (this.#connectPromise === undefined) {
1223
1297
  this.#connectPromise = new Promise((resolve, reject) => { this.#resolveConnect = resolve; this.#rejectConnect = reject })
1224
1298
  this.#connectPromise.catch(() => {})
@@ -1336,6 +1410,9 @@ function validateOptions(options) {
1336
1410
  positiveInteger(options.maxConnectionBufferBytes, "maxConnectionBufferBytes", DEFAULT_CONNECTION_BUFFER_BYTES)
1337
1411
  if (options.spoolDirectory !== undefined && (typeof options.spoolDirectory !== "string" || options.spoolDirectory.length === 0)) throw new TypeError("spoolDirectory must be a non-empty string")
1338
1412
  positiveInteger(options.maxAgeMs, "maxAgeMs", 24 * 60 * 60 * 1000)
1413
+ const gatewayProbeDelay = positiveInteger(options.gatewayProbeDelayMs, "gatewayProbeDelayMs", DEFAULT_GATEWAY_PROBE_DELAY_MS)
1414
+ const maxGatewayProbeDelay = positiveInteger(options.maxGatewayProbeDelayMs, "maxGatewayProbeDelayMs", DEFAULT_MAX_GATEWAY_PROBE_DELAY_MS)
1415
+ if (maxGatewayProbeDelay < gatewayProbeDelay) throw new RangeError("maxGatewayProbeDelayMs must not be smaller than gatewayProbeDelayMs")
1339
1416
  if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
1340
1417
  }
1341
1418
 
@@ -0,0 +1,179 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-returns */
2
+ import {describe, expect, it, withTestCleanup} from "@velocious/testing"
3
+ import {createServer} from "node:net"
4
+
5
+ import {StaticTokenStore} from "../../src/auth/static-token-store.js"
6
+ import {MultiplexedSocketductConnection} from "../../src/client/multiplexed-connection.js"
7
+ import {startMultiplexedTcpRelay} from "../../src/proxy/multiplexed-relay.js"
8
+
9
+ /**
10
+ * `close()` terminates live virtual streams, which the relay answers with a
11
+ * stream reset — that is how in-flight work on the other side of a transport
12
+ * set died when the owning process shut down. `drain()` is the graceful verb:
13
+ * it refuses new streams, waits for the open ones, then releases the transport.
14
+ */
15
+
16
+ /** @param {import('node:net').Server} server - Server. @returns {Promise<number>} */
17
+ async function listen(server) {
18
+ await new Promise((resolve, reject) => {
19
+ server.once("error", reject)
20
+ server.listen(0, "127.0.0.1", () => {
21
+ server.off("error", reject)
22
+ resolve(undefined)
23
+ })
24
+ })
25
+ const address = server.address()
26
+
27
+ if (address === null || typeof address === "string") throw new Error("Expected a TCP address")
28
+
29
+ return address.port
30
+ }
31
+
32
+ /**
33
+ * @param {import('node:events').EventEmitter} emitter - Emitter.
34
+ * @param {string} event - Event.
35
+ * @returns {Promise<void>}
36
+ */
37
+ async function onceEvent(emitter, event) {
38
+ await new Promise((resolve, reject) => {
39
+ emitter.once("error", reject)
40
+ emitter.once(event, () => {
41
+ emitter.off("error", reject)
42
+ resolve(undefined)
43
+ })
44
+ })
45
+ }
46
+
47
+ /**
48
+ * @param {Array<import("../../src/client/virtual-socket.js").VirtualSocket>} streams - Open streams.
49
+ * @param {number} index - Index.
50
+ * @returns {import("../../src/client/virtual-socket.js").VirtualSocket} - The stream.
51
+ */
52
+ function streamAt(streams, index) {
53
+ const stream = streams[index]
54
+
55
+ if (stream === undefined) throw new Error(`Expected stream ${index} to be open`)
56
+
57
+ return stream
58
+ }
59
+
60
+ /** @returns {Promise<void>} */
61
+ async function settleTurns() {
62
+ for (let index = 0; index < 10; index += 1) await new Promise((resolve) => setImmediate(resolve))
63
+ }
64
+
65
+ /**
66
+ * Relay plus one client connection with `streamCount` echoed streams open.
67
+ * @param {(cleanup: () => void | Promise<void>) => void} after - Cleanup registrar.
68
+ * @param {number} streamCount - Streams to open.
69
+ */
70
+ async function openStreams(after, streamCount) {
71
+ /** @type {Set<import('node:net').Socket>} */
72
+ const targetSockets = new Set()
73
+ const target = createServer((socket) => {
74
+ targetSockets.add(socket)
75
+ socket.on("close", () => targetSockets.delete(socket))
76
+ socket.on("data", (chunk) => socket.write(chunk))
77
+ })
78
+ const targetPort = await listen(target)
79
+
80
+ after(() => {
81
+ for (const socket of targetSockets) socket.destroy()
82
+
83
+ return new Promise((resolve) => target.close(() => resolve(undefined)))
84
+ })
85
+
86
+ const relay = await startMultiplexedTcpRelay({
87
+ tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
88
+ authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === targetPort
89
+ })
90
+
91
+ after(() => relay.close())
92
+
93
+ const connection = new MultiplexedSocketductConnection({
94
+ relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}
95
+ })
96
+
97
+ after(() => connection.close())
98
+
99
+ /** @type {Array<import("../../src/client/virtual-socket.js").VirtualSocket>} */
100
+ const streams = []
101
+
102
+ for (let index = 0; index < streamCount; index += 1) {
103
+ streams.push(connection.createConnection({host: "127.0.0.1", port: targetPort, sessionName: `drain-${index}`}))
104
+ }
105
+
106
+ await Promise.all(streams.map((stream) => onceEvent(stream, "connect")))
107
+ expect(connection.status().virtualStreams).toBe(streamCount)
108
+
109
+ return {connection, relay, streams, targetPort}
110
+ }
111
+
112
+ describe("multiplexed connection drain", () => {
113
+ it("drain() keeps in-flight virtual streams alive until they finish", async () => withTestCleanup(async (after) => {
114
+ const {connection, relay, streams} = await openStreams(after, 2)
115
+ const draining = connection.drain()
116
+
117
+ await settleTurns()
118
+
119
+ expect(connection.status().virtualStreams).toBe(2)
120
+ expect(streamAt(streams, 0).destroyed).toBeFalse()
121
+ expect(streamAt(streams, 1).destroyed).toBeFalse()
122
+ expect(relay.status().authenticatedConnections).toBe(1)
123
+
124
+ for (const stream of streams) stream.destroy()
125
+ await draining
126
+
127
+ expect(connection.status().virtualStreams).toBe(0)
128
+ expect(relay.status().authenticatedConnections).toBe(0)
129
+ }))
130
+
131
+ it("drain() keeps working streams usable instead of resetting them", async () => withTestCleanup(async (after) => {
132
+ const {connection, streams} = await openStreams(after, 1)
133
+ const draining = connection.drain()
134
+
135
+ await settleTurns()
136
+
137
+ streamAt(streams, 0).write("still-works")
138
+
139
+ /** @type {string} */
140
+ let echoed = ""
141
+
142
+ await new Promise((resolve, reject) => {
143
+ streamAt(streams, 0).once("error", reject)
144
+ streamAt(streams, 0).on("data", (chunk) => {
145
+ echoed += chunk.toString()
146
+ if (echoed.length >= 11) resolve(undefined)
147
+ })
148
+ })
149
+
150
+ expect(echoed).toBe("still-works")
151
+
152
+ streamAt(streams, 0).destroy()
153
+ await draining
154
+ }))
155
+
156
+ it("drain() rejects new virtual streams while draining", async () => withTestCleanup(async (after) => {
157
+ const {connection, streams, targetPort} = await openStreams(after, 1)
158
+ const draining = connection.drain()
159
+
160
+ await settleTurns()
161
+
162
+ expect(() => connection.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "late"})).toThrow(/connection is closed/i)
163
+ expect(connection.status().virtualStreams).toBe(1)
164
+
165
+ streamAt(streams, 0).destroy()
166
+ await draining
167
+ }))
168
+
169
+ it("close() still terminates in-flight virtual streams immediately", async () => withTestCleanup(async (after) => {
170
+ const {connection, relay, streams} = await openStreams(after, 2)
171
+
172
+ await connection.close()
173
+
174
+ expect(connection.status().virtualStreams).toBe(0)
175
+ expect(streamAt(streams, 0).destroyed).toBeTrue()
176
+ expect(streamAt(streams, 1).destroyed).toBeTrue()
177
+ expect(relay.status().authenticatedConnections).toBe(0)
178
+ }))
179
+ })
@@ -56,6 +56,15 @@ 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
+
59
68
  /** @param {string} prefix - Echo response prefix. */
60
69
  async function createEchoTarget(prefix) {
61
70
  const sockets = new Set()
@@ -470,4 +479,74 @@ describe("reverse gateway route-class", () => {
470
479
  expect(await exchange(stream, "probe")).toBe("a:probe")
471
480
  stream.destroy()
472
481
  }))
482
+
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-"))
485
+ after(() => rm(root, {recursive: true, force: true}))
486
+ const certificates = generateTestCertificates(root)
487
+ const targetA = await createEchoTarget("a:")
488
+ after(() => targetA.close())
489
+
490
+ const hub = await startReverseGatewayHub({
491
+ tokens: new StaticTokenStore([
492
+ {username: "home-a", token: "gateway-a-token"},
493
+ {username: "tensorbuzz", token: "client-token"}
494
+ ]),
495
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
496
+ knownGatewayIds: ["server-a-starlink"],
497
+ knownRouteClasses: ["starlink"],
498
+ authorizeGateway: ({principal, gatewayId}) => principal.username === "home-a" && gatewayId === "server-a-starlink",
499
+ authorizeClientGateway: () => false,
500
+ 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
504
+ })
505
+ after(() => hub.close())
506
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
507
+
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
+ const gatewayA = new OutboundReverseGateway({
533
+ gatewayId: "server-a-starlink",
534
+ connectionId: "down_gateway_a",
535
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
536
+ tls,
537
+ authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
538
+ reconnectDelayMs: 10,
539
+ maxReconnectDelayMs: 20
540
+ })
541
+ after(() => gatewayA.close())
542
+ await gatewayA.start()
543
+ await waitUntil(() => hub.status().healthyGateways === 1, "gateway A registration")
544
+
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()
551
+ }))
473
552
  })
@@ -58,6 +58,7 @@ class FakeMember extends EventEmitter {
58
58
  this.closeError = closeError
59
59
  this.connectCalls = 0
60
60
  this.closeCalls = 0
61
+ this.drainCalls = 0
61
62
  this.opens = 0
62
63
  /** @type {FakeSocket[]} */
63
64
  this.sockets = []
@@ -161,6 +162,11 @@ class FakeMember extends EventEmitter {
161
162
  this.setState("closed")
162
163
  }
163
164
 
165
+ async drain() {
166
+ this.drainCalls += 1
167
+ this.setState("closed")
168
+ }
169
+
164
170
  /** @param {string} state - Physical state. @param {Error} [error] - Terminal error. */
165
171
  setState(state, error) {
166
172
  this.state = state
@@ -552,16 +558,30 @@ describe("multiplexed transport set", () => {
552
558
  await set.close()
553
559
  })
554
560
 
555
- it("a stale gateway-unavailable mark cannot wedge the set when every ready member is marked", async () => {
561
+ it("a member the hub registered down is never selected, even when every member is down", async () => {
556
562
  const {set, members} = fixture()
557
563
  const started = set.start()
558
564
  members.forEach((member) => member.ready())
559
565
  await started
560
566
  members.forEach((member) => { member.gatewayUnavailable = true })
567
+ let callbacks = 0
561
568
  const socket = set.createConnection({host: "target.internal", port: 443}, (error) => {
562
569
  expect(error).toBe(null)
570
+ callbacks += 1
563
571
  })
572
+ await new Promise((resolve) => setImmediate(resolve))
573
+ expect(callbacks).toBe(0)
574
+ expect(socket.gatewayId).toBe(undefined)
575
+ expect(set.status().pendingOpens).toBe(1)
576
+ expect(members.map((member) => member.opens)).toEqual([0, 0, 0])
577
+ expect(set.status().members.map((member) => member.gatewayUnavailable)).toEqual([true, true, true])
578
+
579
+ at(members, 2).gatewayUnavailable = false
580
+ at(members, 2).setState("ready")
581
+ await new Promise((resolve) => setImmediate(resolve))
582
+ expect(callbacks).toBe(1)
564
583
  expect(socket.gatewayId !== undefined).toBe(true)
584
+ expect(members.map((member) => member.opens)).toEqual([0, 0, 1])
565
585
  expect(set.status().pendingOpens).toBe(0)
566
586
  await set.close()
567
587
  })