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.
- package/changelog.d/20261025123000-route-class-down-registration.md +1 -0
- package/changelog.d/20261025162000-gateway-unavailability-report-is-not-a-timer.md +1 -0
- package/package.json +1 -1
- package/src/client/multiplexed-transport-set.js +9 -16
- package/src/reverse/client.js +93 -17
- package/test/integration/reverse-route-class.test.js +85 -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.
|
|
@@ -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
|
@@ -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
|
|
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
|
|
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,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
|
|
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
|
|
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.#
|
|
365
|
-
this.#
|
|
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
|
-
*
|
|
377
|
-
*
|
|
378
|
-
* @returns {
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
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
|
|
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
|
})
|