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.
- package/changelog.d/20261025123000-route-class-down-registration.md +1 -0
- package/package.json +1 -1
- package/src/client/multiplexed-transport-set.js +8 -15
- package/src/reverse/client.js +93 -21
- package/test/integration/reverse-route-class.test.js +79 -0
- package/test/unit/client/multiplexed-transport-set.test.js +15 -1
|
@@ -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
|
@@ -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
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
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
|
|
package/src/reverse/client.js
CHANGED
|
@@ -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
|
|
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.#
|
|
364
|
-
this.#
|
|
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
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
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
|
|
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
|
})
|