socketduct 0.1.28 → 0.1.29

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socketduct",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
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",
@@ -493,13 +493,14 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
493
493
  return this
494
494
  }
495
495
 
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. */
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, 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
497
  status() {
498
498
  const members = this.#members.map((member) => {
499
499
  const status = member.connection.status()
500
500
  return {
501
501
  ...memberAllocation(member),
502
502
  state: status.physicalState,
503
+ gatewayUnavailable: status.gatewayUnavailable === true,
503
504
  assignments: member.assignments,
504
505
  physicalConnects: status.physicalConnects,
505
506
  reconnectAttempts: status.reconnectAttempts,
@@ -571,33 +572,25 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
571
572
  }
572
573
 
573
574
  /**
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.
575
+ * Selects the next ready member whose route class the hub has registered up.
576
+ * A member the hub registered down is never selected: nothing is sent to a
577
+ * route class without a reachable gateway. When every member is registered
578
+ * down the open stays pending here until a member reports availability again
579
+ * (the member keeps probing the hub, and its state change re-drains this set).
578
580
  * @returns {{member: TransportMember, previousCursor: number} | undefined}
579
581
  */
580
582
  #selectReadyMember() {
581
583
  if (this.#closed) throw closedError()
582
584
  const previousCursor = this.#cursor
583
- /** @type {{member: TransportMember, index: number} | undefined} */
584
- let fallback
585
585
  for (let offset = 0; offset < this.#members.length; offset += 1) {
586
586
  const index = (this.#cursor + offset) % this.#members.length
587
587
  const member = /** @type {TransportMember} */ (this.#members[index])
588
588
  const status = member.connection.status()
589
589
  if (status.physicalState !== "ready") continue
590
- if (status.gatewayUnavailable === true) {
591
- fallback ??= {member, index}
592
- continue
593
- }
590
+ if (status.gatewayUnavailable === true) continue
594
591
  this.#cursor = (index + 1) % this.#members.length
595
592
  return {member, previousCursor}
596
593
  }
597
- if (fallback !== undefined) {
598
- this.#cursor = (fallback.index + 1) % this.#members.length
599
- return {member: fallback.member, previousCursor}
600
- }
601
594
  return undefined
602
595
  }
603
596
 
@@ -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,
@@ -1336,6 +1405,9 @@ function validateOptions(options) {
1336
1405
  positiveInteger(options.maxConnectionBufferBytes, "maxConnectionBufferBytes", DEFAULT_CONNECTION_BUFFER_BYTES)
1337
1406
  if (options.spoolDirectory !== undefined && (typeof options.spoolDirectory !== "string" || options.spoolDirectory.length === 0)) throw new TypeError("spoolDirectory must be a non-empty string")
1338
1407
  positiveInteger(options.maxAgeMs, "maxAgeMs", 24 * 60 * 60 * 1000)
1408
+ const gatewayProbeDelay = positiveInteger(options.gatewayProbeDelayMs, "gatewayProbeDelayMs", DEFAULT_GATEWAY_PROBE_DELAY_MS)
1409
+ const maxGatewayProbeDelay = positiveInteger(options.maxGatewayProbeDelayMs, "maxGatewayProbeDelayMs", DEFAULT_MAX_GATEWAY_PROBE_DELAY_MS)
1410
+ if (maxGatewayProbeDelay < gatewayProbeDelay) throw new RangeError("maxGatewayProbeDelayMs must not be smaller than gatewayProbeDelayMs")
1339
1411
  if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
1340
1412
  }
1341
1413
 
@@ -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
  })
@@ -552,16 +552,30 @@ describe("multiplexed transport set", () => {
552
552
  await set.close()
553
553
  })
554
554
 
555
- it("a stale gateway-unavailable mark cannot wedge the set when every ready member is marked", async () => {
555
+ it("a member the hub registered down is never selected, even when every member is down", async () => {
556
556
  const {set, members} = fixture()
557
557
  const started = set.start()
558
558
  members.forEach((member) => member.ready())
559
559
  await started
560
560
  members.forEach((member) => { member.gatewayUnavailable = true })
561
+ let callbacks = 0
561
562
  const socket = set.createConnection({host: "target.internal", port: 443}, (error) => {
562
563
  expect(error).toBe(null)
564
+ callbacks += 1
563
565
  })
566
+ await new Promise((resolve) => setImmediate(resolve))
567
+ expect(callbacks).toBe(0)
568
+ expect(socket.gatewayId).toBe(undefined)
569
+ expect(set.status().pendingOpens).toBe(1)
570
+ expect(members.map((member) => member.opens)).toEqual([0, 0, 0])
571
+ expect(set.status().members.map((member) => member.gatewayUnavailable)).toEqual([true, true, true])
572
+
573
+ at(members, 2).gatewayUnavailable = false
574
+ at(members, 2).setState("ready")
575
+ await new Promise((resolve) => setImmediate(resolve))
576
+ expect(callbacks).toBe(1)
564
577
  expect(socket.gatewayId !== undefined).toBe(true)
578
+ expect(members.map((member) => member.opens)).toEqual([0, 0, 1])
565
579
  expect(set.status().pendingOpens).toBe(0)
566
580
  await set.close()
567
581
  })