socketduct 0.1.27 → 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.
@@ -0,0 +1 @@
1
+ A hub route-class unavailability report now lasts for the life of the connection instead of five seconds, so a transport set stops re-selecting a route class the hub already declared unserviceable and no longer fails the caller's first open on it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socketduct",
3
- "version": "0.1.27",
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",
@@ -44,7 +44,7 @@ const GATEWAY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/
44
44
  * @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured logger shared by the set and its members.
45
45
  * @typedef {object} MemberConnectionStatus
46
46
  * @property {string} physicalState - Member lifecycle.
47
- * @property {boolean} [gatewayUnavailable] - Hub recently reported no selectable gateway for this member's route class.
47
+ * @property {boolean} [gatewayUnavailable] - Hub reported no selectable gateway for this member's route class on the current connection.
48
48
  * @property {number} connectionGeneration - Current authenticated generation.
49
49
  * @property {number} physicalConnects - Authenticated physical generations.
50
50
  * @property {number} reconnectAttempts - Physical reconnect attempts.
@@ -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,7 +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 GATEWAY_UNAVAILABLE_TTL_MS = 5_000
35
+ const DEFAULT_GATEWAY_PROBE_DELAY_MS = 1_000
36
+ const DEFAULT_MAX_GATEWAY_PROBE_DELAY_MS = 30_000
36
37
 
37
38
  /**
38
39
  * @typedef {object} ReverseGatewayConnectionOptions
@@ -51,6 +52,8 @@ const GATEWAY_UNAVAILABLE_TTL_MS = 5_000
51
52
  * @property {number} [connectTimeoutMs] - TLS/auth deadline.
52
53
  * @property {number} [reconnectDelayMs] - Initial reconnect delay.
53
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.
54
57
  * @property {number} [initialGeneration] - Monotonic seed for controlled replacement; defaults to wall-clock microseconds.
55
58
  * @property {boolean} [continuousReconnect] - Keep the member warm after loss, default true.
56
59
  * @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - zstd safety bounds.
@@ -169,7 +172,10 @@ export class ReverseGatewayConnection extends EventEmitter {
169
172
  /** @type {number} */ #spoolBytes = 0
170
173
  /** @type {number} */ #spoolCleanupFailures = 0
171
174
  /** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
172
- /** @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
173
179
  /** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */ #disconnectAttribution
174
180
 
175
181
  /** @param {ReverseGatewayConnectionOptions} options - Hub route and safety policy. */
@@ -185,6 +191,7 @@ export class ReverseGatewayConnection extends EventEmitter {
185
191
  })
186
192
  this.#generation = options.initialGeneration ?? Date.now() * 1000
187
193
  this.#reconnectDelay = options.reconnectDelayMs ?? 250
194
+ this.#gatewayProbeDelay = options.gatewayProbeDelayMs ?? DEFAULT_GATEWAY_PROBE_DELAY_MS
188
195
  this.#sendWindowLimit = this.#initialWindow()
189
196
  this.createConnection = this.createConnection.bind(this)
190
197
  }
@@ -361,25 +368,92 @@ export class ReverseGatewayConnection extends EventEmitter {
361
368
  * @returns {void}
362
369
  */
363
370
  #markGatewayUnavailable(code) {
364
- const firstMark = this.#gatewayUnavailableSince === undefined
365
- this.#gatewayUnavailableSince = Date.now()
371
+ const firstMark = this.#gatewayDownSince === undefined
372
+ this.#gatewayDownSince ??= Date.now()
366
373
  if (firstMark) {
367
374
  logDiagnostic(this.#options.logger, "reverse_client.gateway_unavailable", {
368
375
  code,
369
376
  generation: this.#generation,
370
377
  ...this.#routeIdentityFields()
371
378
  })
379
+ this.#emitStateChange()
372
380
  }
381
+ this.#scheduleGatewayProbe()
373
382
  }
374
383
 
375
384
  /**
376
- * Whether the last hub-reported gateway state for this member was unavailable
377
- * and the report is still fresh. A stale report never pins the member out.
378
- * @returns {boolean} True while a fresh hub unavailability report is active.
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.
379
454
  */
380
455
  #gatewayUnavailable() {
381
- return this.#gatewayUnavailableSince !== undefined
382
- && Date.now() - this.#gatewayUnavailableSince < GATEWAY_UNAVAILABLE_TTL_MS
456
+ return this.#gatewayDownSince !== undefined
383
457
  }
384
458
 
385
459
  status() {
@@ -389,6 +463,7 @@ export class ReverseGatewayConnection extends EventEmitter {
389
463
  return {
390
464
  physicalState: this.#state,
391
465
  gatewayUnavailable: this.#gatewayUnavailable(),
466
+ gatewayProbeAttempts: this.#gatewayProbeAttempts,
392
467
  connectionGeneration: this.#generation,
393
468
  virtualStreams: this.#streams.size,
394
469
  streamStates,
@@ -417,6 +492,8 @@ export class ReverseGatewayConnection extends EventEmitter {
417
492
  this.#closing = true
418
493
  if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
419
494
  this.#reconnectTimer = undefined
495
+ if (this.#gatewayProbeTimer !== undefined) clearTimeout(this.#gatewayProbeTimer)
496
+ this.#gatewayProbeTimer = undefined
420
497
  this.#setState("closed")
421
498
  for (const stream of [...this.#streams.values()]) stream.socket.destroy()
422
499
  const physical = this.#physical
@@ -442,6 +519,8 @@ export class ReverseGatewayConnection extends EventEmitter {
442
519
  this.#closing = true
443
520
  if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
444
521
  this.#reconnectTimer = undefined
522
+ if (this.#gatewayProbeTimer !== undefined) clearTimeout(this.#gatewayProbeTimer)
523
+ this.#gatewayProbeTimer = undefined
445
524
  this.#setState("closed")
446
525
  const physical = this.#physical
447
526
  physical?.pause()
@@ -654,7 +733,7 @@ export class ReverseGatewayConnection extends EventEmitter {
654
733
  const selection = this.#routeIdentityFields()
655
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")
656
735
  if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_SELECTION_FAILED")
657
- if (detail.selectable === true) this.#gatewayUnavailableSince = undefined
736
+ if (detail.selectable === true) this.#markGatewayAvailable()
658
737
  else if (detail.selectable === false) this.#markGatewayUnavailable("GATEWAY_SELECT_UNAVAILABLE")
659
738
  selected = true
660
739
  if (detail.resumed) {
@@ -781,13 +860,7 @@ export class ReverseGatewayConnection extends EventEmitter {
781
860
  stream.sendWindow = Math.min(stream.sendWindow, detail.initialWindow)
782
861
  stream.state = stream.localEnded ? "half-closed" : "open"
783
862
  stream.opened = true
784
- if (this.#gatewayUnavailableSince !== undefined) {
785
- this.#gatewayUnavailableSince = undefined
786
- logDiagnostic(this.#options.logger, "reverse_client.gateway_available", {
787
- generation: this.#generation,
788
- ...this.#routeIdentityFields()
789
- })
790
- }
863
+ this.#markGatewayAvailable()
791
864
  logDiagnostic(this.#options.logger, "virtual_stream.opened", {
792
865
  streamId: stream.id,
793
866
  generation: this.#generation,
@@ -1332,6 +1405,9 @@ function validateOptions(options) {
1332
1405
  positiveInteger(options.maxConnectionBufferBytes, "maxConnectionBufferBytes", DEFAULT_CONNECTION_BUFFER_BYTES)
1333
1406
  if (options.spoolDirectory !== undefined && (typeof options.spoolDirectory !== "string" || options.spoolDirectory.length === 0)) throw new TypeError("spoolDirectory must be a non-empty string")
1334
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")
1335
1411
  if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
1336
1412
  }
1337
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()
@@ -436,6 +445,12 @@ describe("reverse gateway route-class", () => {
436
445
  expect(client.status().physicalState).toBe("ready")
437
446
  expect(client.status().gatewayUnavailable).toBe(true)
438
447
 
448
+ // The report belongs to the live connection: it must survive any short window a caller
449
+ // might wait between the announce and its first open. A timer-bound mark made the transport
450
+ // set pick this route class again and the resulting reset failed the caller's operation.
451
+ await new Promise((resolve) => setTimeout(resolve, 5200))
452
+ expect({value: client.status().gatewayUnavailable, context: "a hub unavailability report must not expire on a timer"}).toMatchObject({value: true})
453
+
439
454
  // Once the gateway registers, a replacement selection announces selectable=true and clears the mark.
440
455
  const gatewayA = new OutboundReverseGateway({
441
456
  gatewayId: "server-a-starlink",
@@ -464,4 +479,74 @@ describe("reverse gateway route-class", () => {
464
479
  expect(await exchange(stream, "probe")).toBe("a:probe")
465
480
  stream.destroy()
466
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
+ }))
467
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
  })