socketduct 0.1.28 → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.d/20261025123000-route-class-down-registration.md +1 -0
- package/changelog.d/20261025193000-drain-transport-without-resetting-streams.md +1 -0
- package/package.json +1 -1
- package/src/client/multiplexed-connection.js +52 -3
- package/src/client/multiplexed-transport-set.js +15 -16
- package/src/reverse/client.js +98 -21
- package/test/integration/multiplexed-drain.test.js +179 -0
- package/test/integration/reverse-route-class.test.js +79 -0
- package/test/unit/client/multiplexed-transport-set.test.js +21 -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
|
+
Multiplexed connections gain `drain()`, which refuses new virtual streams, waits for the open ones to finish, and only then releases the physical transport, so a shutdown no longer resets work that is still in flight; `close()` keeps its immediate-termination behaviour and now logs how many live streams it terminated.
|
package/package.json
CHANGED
|
@@ -138,6 +138,11 @@ export class MultiplexedSocketductConnection extends EventEmitter {
|
|
|
138
138
|
|
|
139
139
|
/** @type {boolean} */
|
|
140
140
|
#closing = false
|
|
141
|
+
#draining = false
|
|
142
|
+
/** @type {Promise<void> | undefined} */
|
|
143
|
+
#closePromise = undefined
|
|
144
|
+
/** @type {Array<() => void>} */
|
|
145
|
+
#drainWaiters = []
|
|
141
146
|
|
|
142
147
|
/** @type {boolean} */
|
|
143
148
|
#warm = false
|
|
@@ -247,7 +252,7 @@ export class MultiplexedSocketductConnection extends EventEmitter {
|
|
|
247
252
|
* @returns {Promise<void>} Resolves when the member becomes ready.
|
|
248
253
|
*/
|
|
249
254
|
connect() {
|
|
250
|
-
if (this.#closing) return Promise.reject(Object.assign(new Error("Multiplexed Socketduct connection is closed"), {code: "SOCKETDUCT_CONNECTION_CLOSED"}))
|
|
255
|
+
if (this.#closing || this.#draining) return Promise.reject(Object.assign(new Error("Multiplexed Socketduct connection is closed"), {code: "SOCKETDUCT_CONNECTION_CLOSED"}))
|
|
251
256
|
if (this.#warmPromise !== undefined) return this.#warmPromise
|
|
252
257
|
this.#warm = true
|
|
253
258
|
this.#warmPromise = Promise.all([...this.#streams.values()].map((stream) => stream.spoolReady)).then(() => this.#ensureConnected())
|
|
@@ -297,7 +302,7 @@ export class MultiplexedSocketductConnection extends EventEmitter {
|
|
|
297
302
|
* @returns {VirtualSocket} Socket-shaped Duplex.
|
|
298
303
|
*/
|
|
299
304
|
#createConnection(options, callback, connectOnReady) {
|
|
300
|
-
if (this.#closing) throw new Error("Multiplexed Socketduct connection is closed")
|
|
305
|
+
if (this.#closing || this.#draining) throw new Error("Multiplexed Socketduct connection is closed")
|
|
301
306
|
if (options.manualReceiveAck !== undefined) throw new TypeError("manualReceiveAck is supported only by reverse gateway connections")
|
|
302
307
|
if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum virtual stream count exceeded")
|
|
303
308
|
const target = normalizeTarget(options)
|
|
@@ -446,10 +451,52 @@ export class MultiplexedSocketductConnection extends EventEmitter {
|
|
|
446
451
|
return this
|
|
447
452
|
}
|
|
448
453
|
|
|
449
|
-
/**
|
|
454
|
+
/**
|
|
455
|
+
* Terminates every virtual stream and releases the shared transport. Streams
|
|
456
|
+
* still in flight are reset at the relay by design, so the number of them is
|
|
457
|
+
* logged (`multiplexed_connection.shutdown_with_live_streams`) instead of
|
|
458
|
+
* disappearing. Use `drain()` when in-flight work must survive.
|
|
459
|
+
* @returns {Promise<void>}
|
|
460
|
+
*/
|
|
450
461
|
async close() {
|
|
462
|
+
if (this.#closePromise !== undefined) return this.#closePromise
|
|
463
|
+
this.#closePromise = this.#shutdownTransport()
|
|
464
|
+
|
|
465
|
+
return this.#closePromise
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Graceful counterpart of `close()`: refuses new virtual streams, lets the
|
|
470
|
+
* open ones finish, and only then releases the shared transport. Use this
|
|
471
|
+
* wherever a transport must not reset work that is still in flight.
|
|
472
|
+
* @returns {Promise<void>}
|
|
473
|
+
*/
|
|
474
|
+
async drain() {
|
|
475
|
+
if (this.#closePromise !== undefined) return this.#closePromise
|
|
476
|
+
this.#draining = true
|
|
477
|
+
this.#closePromise = (async () => {
|
|
478
|
+
await this.#waitForStreamsToDrain()
|
|
479
|
+
await this.#shutdownTransport()
|
|
480
|
+
})()
|
|
481
|
+
|
|
482
|
+
return this.#closePromise
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** @returns {Promise<void>} */
|
|
486
|
+
#waitForStreamsToDrain() {
|
|
487
|
+
if (this.#streams.size === 0) return Promise.resolve()
|
|
488
|
+
|
|
489
|
+
return new Promise((resolve) => { this.#drainWaiters.push(resolve) })
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** @returns {Promise<void>} */
|
|
493
|
+
async #shutdownTransport() {
|
|
451
494
|
if (this.#closing) return
|
|
452
495
|
this.#closing = true
|
|
496
|
+
|
|
497
|
+
if (this.#streams.size > 0) {
|
|
498
|
+
logDiagnostic(this.#options.logger, "multiplexed_connection.shutdown_with_live_streams", {streamsKilled: this.#streams.size})
|
|
499
|
+
}
|
|
453
500
|
this.#setPhysicalState("closed")
|
|
454
501
|
if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
|
|
455
502
|
this.#reconnectTimer = undefined
|
|
@@ -618,6 +665,8 @@ export class MultiplexedSocketductConnection extends EventEmitter {
|
|
|
618
665
|
this.#trackSpoolCleanup(stream)
|
|
619
666
|
}
|
|
620
667
|
if (this.#streams.size === 0 && !this.#authenticated && this.#physical !== undefined) this.#physical.destroy()
|
|
668
|
+
|
|
669
|
+
if (this.#streams.size === 0) for (const resolve of this.#drainWaiters.splice(0)) resolve()
|
|
621
670
|
}
|
|
622
671
|
|
|
623
672
|
/** @param {number} streamId - Stream. @returns {void} */
|
|
@@ -61,6 +61,7 @@ const GATEWAY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/
|
|
|
61
61
|
* @property {() => MemberConnectionStatus} status - Privacy-safe status.
|
|
62
62
|
* @property {() => Promise<void>} close - Close the member.
|
|
63
63
|
* @property {() => Promise<void>} detach - Preserve durable streams for process-replacement recovery.
|
|
64
|
+
* @property {() => Promise<void>} drain - Let live streams finish before releasing the member.
|
|
64
65
|
* @property {(eventName: string, listener: (...args: unknown[]) => void) => unknown} on - Subscribe to lifecycle changes.
|
|
65
66
|
* @property {() => unknown} [ref] - Reference resources.
|
|
66
67
|
* @property {() => unknown} [unref] - Unreference resources.
|
|
@@ -493,13 +494,14 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
493
494
|
return this
|
|
494
495
|
}
|
|
495
496
|
|
|
496
|
-
/** @returns {{state: string, configuredMembers: number, connectingMembers: number, readyMembers: number, degradedMembers: number, closedMembers: number, minimumReadyMembers: number, pendingOpens: number, maxPendingOpens: number, virtualOpens: number, physicalConnects: number, reconnectAttempts: number, budgets: {dataBytes: number, spoolBytes: number, queuedBytes: number, maxDataBytes: number, maxSpoolBytes: number, maxQueuedBytes: number}, members: Array<{gatewayId?: string, routeClass?: string, state: string, assignments: number, physicalConnects: number, reconnectAttempts: number, reconnectSuccesses: number, virtualStreams: number, queuedBytes: number, spoolBytes: number, spoolCleanupFailures: number, terminalErrorCode: string | null}>}} Privacy-safe aggregate and per-member status. */
|
|
497
|
+
/** @returns {{state: string, configuredMembers: number, connectingMembers: number, readyMembers: number, degradedMembers: number, closedMembers: number, minimumReadyMembers: number, pendingOpens: number, maxPendingOpens: number, virtualOpens: number, physicalConnects: number, reconnectAttempts: number, budgets: {dataBytes: number, spoolBytes: number, queuedBytes: number, maxDataBytes: number, maxSpoolBytes: number, maxQueuedBytes: number}, members: Array<{gatewayId?: string, routeClass?: string, state: string, gatewayUnavailable: boolean, assignments: number, physicalConnects: number, reconnectAttempts: number, reconnectSuccesses: number, virtualStreams: number, queuedBytes: number, spoolBytes: number, spoolCleanupFailures: number, terminalErrorCode: string | null}>}} Privacy-safe aggregate and per-member status. */
|
|
497
498
|
status() {
|
|
498
499
|
const members = this.#members.map((member) => {
|
|
499
500
|
const status = member.connection.status()
|
|
500
501
|
return {
|
|
501
502
|
...memberAllocation(member),
|
|
502
503
|
state: status.physicalState,
|
|
504
|
+
gatewayUnavailable: status.gatewayUnavailable === true,
|
|
503
505
|
assignments: member.assignments,
|
|
504
506
|
physicalConnects: status.physicalConnects,
|
|
505
507
|
reconnectAttempts: status.reconnectAttempts,
|
|
@@ -549,7 +551,12 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
549
551
|
return this.#shutdown("detach")
|
|
550
552
|
}
|
|
551
553
|
|
|
552
|
-
/**
|
|
554
|
+
/** Drains live virtual streams on every member before releasing them once. @returns {Promise<void>} */
|
|
555
|
+
drain() {
|
|
556
|
+
return this.#shutdown("drain")
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** @param {"close" | "detach" | "drain"} method - Member shutdown operation. @returns {Promise<void>} */
|
|
553
560
|
#shutdown(method) {
|
|
554
561
|
if (this.#closePromise !== undefined) return this.#closePromise
|
|
555
562
|
this.#closed = true
|
|
@@ -571,33 +578,25 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
571
578
|
}
|
|
572
579
|
|
|
573
580
|
/**
|
|
574
|
-
* Selects the next ready member
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
581
|
+
* Selects the next ready member whose route class the hub has registered up.
|
|
582
|
+
* A member the hub registered down is never selected: nothing is sent to a
|
|
583
|
+
* route class without a reachable gateway. When every member is registered
|
|
584
|
+
* down the open stays pending here until a member reports availability again
|
|
585
|
+
* (the member keeps probing the hub, and its state change re-drains this set).
|
|
578
586
|
* @returns {{member: TransportMember, previousCursor: number} | undefined}
|
|
579
587
|
*/
|
|
580
588
|
#selectReadyMember() {
|
|
581
589
|
if (this.#closed) throw closedError()
|
|
582
590
|
const previousCursor = this.#cursor
|
|
583
|
-
/** @type {{member: TransportMember, index: number} | undefined} */
|
|
584
|
-
let fallback
|
|
585
591
|
for (let offset = 0; offset < this.#members.length; offset += 1) {
|
|
586
592
|
const index = (this.#cursor + offset) % this.#members.length
|
|
587
593
|
const member = /** @type {TransportMember} */ (this.#members[index])
|
|
588
594
|
const status = member.connection.status()
|
|
589
595
|
if (status.physicalState !== "ready") continue
|
|
590
|
-
if (status.gatewayUnavailable === true)
|
|
591
|
-
fallback ??= {member, index}
|
|
592
|
-
continue
|
|
593
|
-
}
|
|
596
|
+
if (status.gatewayUnavailable === true) continue
|
|
594
597
|
this.#cursor = (index + 1) % this.#members.length
|
|
595
598
|
return {member, previousCursor}
|
|
596
599
|
}
|
|
597
|
-
if (fallback !== undefined) {
|
|
598
|
-
this.#cursor = (fallback.index + 1) % this.#members.length
|
|
599
|
-
return {member: fallback.member, previousCursor}
|
|
600
|
-
}
|
|
601
600
|
return undefined
|
|
602
601
|
}
|
|
603
602
|
|
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,
|
|
@@ -1219,6 +1288,11 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
1219
1288
|
this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, this.#options.maxReconnectDelayMs ?? 30_000)
|
|
1220
1289
|
this.#reconnectTimer = setTimeout(() => {
|
|
1221
1290
|
this.#reconnectTimer = undefined
|
|
1291
|
+
// clearTimeout() cannot stop a callback that is already queued, so a
|
|
1292
|
+
// detach()/close() that lands in that window used to reconnect anyway:
|
|
1293
|
+
// it reopened a physical transport after the caller had deliberately
|
|
1294
|
+
// detached (observed as ENOENT reading a deleted recovery CA file).
|
|
1295
|
+
if (this.#closing || this.#fatal) return
|
|
1222
1296
|
if (this.#connectPromise === undefined) {
|
|
1223
1297
|
this.#connectPromise = new Promise((resolve, reject) => { this.#resolveConnect = resolve; this.#rejectConnect = reject })
|
|
1224
1298
|
this.#connectPromise.catch(() => {})
|
|
@@ -1336,6 +1410,9 @@ function validateOptions(options) {
|
|
|
1336
1410
|
positiveInteger(options.maxConnectionBufferBytes, "maxConnectionBufferBytes", DEFAULT_CONNECTION_BUFFER_BYTES)
|
|
1337
1411
|
if (options.spoolDirectory !== undefined && (typeof options.spoolDirectory !== "string" || options.spoolDirectory.length === 0)) throw new TypeError("spoolDirectory must be a non-empty string")
|
|
1338
1412
|
positiveInteger(options.maxAgeMs, "maxAgeMs", 24 * 60 * 60 * 1000)
|
|
1413
|
+
const gatewayProbeDelay = positiveInteger(options.gatewayProbeDelayMs, "gatewayProbeDelayMs", DEFAULT_GATEWAY_PROBE_DELAY_MS)
|
|
1414
|
+
const maxGatewayProbeDelay = positiveInteger(options.maxGatewayProbeDelayMs, "maxGatewayProbeDelayMs", DEFAULT_MAX_GATEWAY_PROBE_DELAY_MS)
|
|
1415
|
+
if (maxGatewayProbeDelay < gatewayProbeDelay) throw new RangeError("maxGatewayProbeDelayMs must not be smaller than gatewayProbeDelayMs")
|
|
1339
1416
|
if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
|
|
1340
1417
|
}
|
|
1341
1418
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-returns */
|
|
2
|
+
import {describe, expect, it, withTestCleanup} from "@velocious/testing"
|
|
3
|
+
import {createServer} from "node:net"
|
|
4
|
+
|
|
5
|
+
import {StaticTokenStore} from "../../src/auth/static-token-store.js"
|
|
6
|
+
import {MultiplexedSocketductConnection} from "../../src/client/multiplexed-connection.js"
|
|
7
|
+
import {startMultiplexedTcpRelay} from "../../src/proxy/multiplexed-relay.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `close()` terminates live virtual streams, which the relay answers with a
|
|
11
|
+
* stream reset — that is how in-flight work on the other side of a transport
|
|
12
|
+
* set died when the owning process shut down. `drain()` is the graceful verb:
|
|
13
|
+
* it refuses new streams, waits for the open ones, then releases the transport.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** @param {import('node:net').Server} server - Server. @returns {Promise<number>} */
|
|
17
|
+
async function listen(server) {
|
|
18
|
+
await new Promise((resolve, reject) => {
|
|
19
|
+
server.once("error", reject)
|
|
20
|
+
server.listen(0, "127.0.0.1", () => {
|
|
21
|
+
server.off("error", reject)
|
|
22
|
+
resolve(undefined)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
const address = server.address()
|
|
26
|
+
|
|
27
|
+
if (address === null || typeof address === "string") throw new Error("Expected a TCP address")
|
|
28
|
+
|
|
29
|
+
return address.port
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {import('node:events').EventEmitter} emitter - Emitter.
|
|
34
|
+
* @param {string} event - Event.
|
|
35
|
+
* @returns {Promise<void>}
|
|
36
|
+
*/
|
|
37
|
+
async function onceEvent(emitter, event) {
|
|
38
|
+
await new Promise((resolve, reject) => {
|
|
39
|
+
emitter.once("error", reject)
|
|
40
|
+
emitter.once(event, () => {
|
|
41
|
+
emitter.off("error", reject)
|
|
42
|
+
resolve(undefined)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {Array<import("../../src/client/virtual-socket.js").VirtualSocket>} streams - Open streams.
|
|
49
|
+
* @param {number} index - Index.
|
|
50
|
+
* @returns {import("../../src/client/virtual-socket.js").VirtualSocket} - The stream.
|
|
51
|
+
*/
|
|
52
|
+
function streamAt(streams, index) {
|
|
53
|
+
const stream = streams[index]
|
|
54
|
+
|
|
55
|
+
if (stream === undefined) throw new Error(`Expected stream ${index} to be open`)
|
|
56
|
+
|
|
57
|
+
return stream
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @returns {Promise<void>} */
|
|
61
|
+
async function settleTurns() {
|
|
62
|
+
for (let index = 0; index < 10; index += 1) await new Promise((resolve) => setImmediate(resolve))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Relay plus one client connection with `streamCount` echoed streams open.
|
|
67
|
+
* @param {(cleanup: () => void | Promise<void>) => void} after - Cleanup registrar.
|
|
68
|
+
* @param {number} streamCount - Streams to open.
|
|
69
|
+
*/
|
|
70
|
+
async function openStreams(after, streamCount) {
|
|
71
|
+
/** @type {Set<import('node:net').Socket>} */
|
|
72
|
+
const targetSockets = new Set()
|
|
73
|
+
const target = createServer((socket) => {
|
|
74
|
+
targetSockets.add(socket)
|
|
75
|
+
socket.on("close", () => targetSockets.delete(socket))
|
|
76
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
77
|
+
})
|
|
78
|
+
const targetPort = await listen(target)
|
|
79
|
+
|
|
80
|
+
after(() => {
|
|
81
|
+
for (const socket of targetSockets) socket.destroy()
|
|
82
|
+
|
|
83
|
+
return new Promise((resolve) => target.close(() => resolve(undefined)))
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const relay = await startMultiplexedTcpRelay({
|
|
87
|
+
tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
|
|
88
|
+
authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === targetPort
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
after(() => relay.close())
|
|
92
|
+
|
|
93
|
+
const connection = new MultiplexedSocketductConnection({
|
|
94
|
+
relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
after(() => connection.close())
|
|
98
|
+
|
|
99
|
+
/** @type {Array<import("../../src/client/virtual-socket.js").VirtualSocket>} */
|
|
100
|
+
const streams = []
|
|
101
|
+
|
|
102
|
+
for (let index = 0; index < streamCount; index += 1) {
|
|
103
|
+
streams.push(connection.createConnection({host: "127.0.0.1", port: targetPort, sessionName: `drain-${index}`}))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await Promise.all(streams.map((stream) => onceEvent(stream, "connect")))
|
|
107
|
+
expect(connection.status().virtualStreams).toBe(streamCount)
|
|
108
|
+
|
|
109
|
+
return {connection, relay, streams, targetPort}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe("multiplexed connection drain", () => {
|
|
113
|
+
it("drain() keeps in-flight virtual streams alive until they finish", async () => withTestCleanup(async (after) => {
|
|
114
|
+
const {connection, relay, streams} = await openStreams(after, 2)
|
|
115
|
+
const draining = connection.drain()
|
|
116
|
+
|
|
117
|
+
await settleTurns()
|
|
118
|
+
|
|
119
|
+
expect(connection.status().virtualStreams).toBe(2)
|
|
120
|
+
expect(streamAt(streams, 0).destroyed).toBeFalse()
|
|
121
|
+
expect(streamAt(streams, 1).destroyed).toBeFalse()
|
|
122
|
+
expect(relay.status().authenticatedConnections).toBe(1)
|
|
123
|
+
|
|
124
|
+
for (const stream of streams) stream.destroy()
|
|
125
|
+
await draining
|
|
126
|
+
|
|
127
|
+
expect(connection.status().virtualStreams).toBe(0)
|
|
128
|
+
expect(relay.status().authenticatedConnections).toBe(0)
|
|
129
|
+
}))
|
|
130
|
+
|
|
131
|
+
it("drain() keeps working streams usable instead of resetting them", async () => withTestCleanup(async (after) => {
|
|
132
|
+
const {connection, streams} = await openStreams(after, 1)
|
|
133
|
+
const draining = connection.drain()
|
|
134
|
+
|
|
135
|
+
await settleTurns()
|
|
136
|
+
|
|
137
|
+
streamAt(streams, 0).write("still-works")
|
|
138
|
+
|
|
139
|
+
/** @type {string} */
|
|
140
|
+
let echoed = ""
|
|
141
|
+
|
|
142
|
+
await new Promise((resolve, reject) => {
|
|
143
|
+
streamAt(streams, 0).once("error", reject)
|
|
144
|
+
streamAt(streams, 0).on("data", (chunk) => {
|
|
145
|
+
echoed += chunk.toString()
|
|
146
|
+
if (echoed.length >= 11) resolve(undefined)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
expect(echoed).toBe("still-works")
|
|
151
|
+
|
|
152
|
+
streamAt(streams, 0).destroy()
|
|
153
|
+
await draining
|
|
154
|
+
}))
|
|
155
|
+
|
|
156
|
+
it("drain() rejects new virtual streams while draining", async () => withTestCleanup(async (after) => {
|
|
157
|
+
const {connection, streams, targetPort} = await openStreams(after, 1)
|
|
158
|
+
const draining = connection.drain()
|
|
159
|
+
|
|
160
|
+
await settleTurns()
|
|
161
|
+
|
|
162
|
+
expect(() => connection.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "late"})).toThrow(/connection is closed/i)
|
|
163
|
+
expect(connection.status().virtualStreams).toBe(1)
|
|
164
|
+
|
|
165
|
+
streamAt(streams, 0).destroy()
|
|
166
|
+
await draining
|
|
167
|
+
}))
|
|
168
|
+
|
|
169
|
+
it("close() still terminates in-flight virtual streams immediately", async () => withTestCleanup(async (after) => {
|
|
170
|
+
const {connection, relay, streams} = await openStreams(after, 2)
|
|
171
|
+
|
|
172
|
+
await connection.close()
|
|
173
|
+
|
|
174
|
+
expect(connection.status().virtualStreams).toBe(0)
|
|
175
|
+
expect(streamAt(streams, 0).destroyed).toBeTrue()
|
|
176
|
+
expect(streamAt(streams, 1).destroyed).toBeTrue()
|
|
177
|
+
expect(relay.status().authenticatedConnections).toBe(0)
|
|
178
|
+
}))
|
|
179
|
+
})
|
|
@@ -56,6 +56,15 @@ async function waitUntil(predicate, label) {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** @param {() => boolean} predicate - Completion predicate. @param {string} label - Failure label. @param {number} timeoutMs - Deadline. */
|
|
60
|
+
async function waitUntilWithin(predicate, label, timeoutMs) {
|
|
61
|
+
const deadline = Date.now() + timeoutMs
|
|
62
|
+
while (!predicate()) {
|
|
63
|
+
if (Date.now() >= deadline) throw new Error(`Timed out: ${label}`)
|
|
64
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
/** @param {string} prefix - Echo response prefix. */
|
|
60
69
|
async function createEchoTarget(prefix) {
|
|
61
70
|
const sockets = new Set()
|
|
@@ -470,4 +479,74 @@ describe("reverse gateway route-class", () => {
|
|
|
470
479
|
expect(await exchange(stream, "probe")).toBe("a:probe")
|
|
471
480
|
stream.destroy()
|
|
472
481
|
}))
|
|
482
|
+
|
|
483
|
+
it("a route class registered down stays down, keeps being reached, and rejoins automatically", async () => withTestCleanup(async (after) => {
|
|
484
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-down-"))
|
|
485
|
+
after(() => rm(root, {recursive: true, force: true}))
|
|
486
|
+
const certificates = generateTestCertificates(root)
|
|
487
|
+
const targetA = await createEchoTarget("a:")
|
|
488
|
+
after(() => targetA.close())
|
|
489
|
+
|
|
490
|
+
const hub = await startReverseGatewayHub({
|
|
491
|
+
tokens: new StaticTokenStore([
|
|
492
|
+
{username: "home-a", token: "gateway-a-token"},
|
|
493
|
+
{username: "tensorbuzz", token: "client-token"}
|
|
494
|
+
]),
|
|
495
|
+
tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
|
|
496
|
+
knownGatewayIds: ["server-a-starlink"],
|
|
497
|
+
knownRouteClasses: ["starlink"],
|
|
498
|
+
authorizeGateway: ({principal, gatewayId}) => principal.username === "home-a" && gatewayId === "server-a-starlink",
|
|
499
|
+
authorizeClientGateway: () => false,
|
|
500
|
+
authorizeClientRouteClass: ({principal, routeClass}) => principal.username === "tensorbuzz" && routeClass === "starlink",
|
|
501
|
+
resolveRouteClassGateways: () => ["server-a-starlink"],
|
|
502
|
+
resolveOpenGateways: ({routeClass, target}) => (routeClass === "starlink" && target.host === targetA.host ? ["server-a-starlink"] : []),
|
|
503
|
+
authorizeOpen: ({gatewayId, target}) => gatewayId === "server-a-starlink" && target.port === targetA.port
|
|
504
|
+
})
|
|
505
|
+
after(() => hub.close())
|
|
506
|
+
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
507
|
+
|
|
508
|
+
const client = new ReverseGatewayConnection({
|
|
509
|
+
routeClass: "starlink",
|
|
510
|
+
connectionId: "route_class_down_client",
|
|
511
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
|
|
512
|
+
tls,
|
|
513
|
+
gatewayProbeDelayMs: 20,
|
|
514
|
+
maxGatewayProbeDelayMs: 40,
|
|
515
|
+
reconnectDelayMs: 10,
|
|
516
|
+
maxReconnectDelayMs: 20
|
|
517
|
+
})
|
|
518
|
+
after(() => client.close())
|
|
519
|
+
|
|
520
|
+
await client.connect()
|
|
521
|
+
expect(client.status().gatewayUnavailable).toBe(true)
|
|
522
|
+
|
|
523
|
+
// The member keeps reaching the hub while its route class is down.
|
|
524
|
+
await waitUntilWithin(() => client.status().gatewayProbeAttempts >= 3, "gateway re-reaching", 4_000)
|
|
525
|
+
|
|
526
|
+
// A down registration never expires on its own: the previous five-second
|
|
527
|
+
// mark would have silently returned this member to service with no gateway.
|
|
528
|
+
await new Promise((resolve) => setTimeout(resolve, 5_100))
|
|
529
|
+
expect(client.status().gatewayUnavailable).toBe(true)
|
|
530
|
+
expect(client.status().gatewayProbeAttempts >= 3).toBe(true)
|
|
531
|
+
|
|
532
|
+
const gatewayA = new OutboundReverseGateway({
|
|
533
|
+
gatewayId: "server-a-starlink",
|
|
534
|
+
connectionId: "down_gateway_a",
|
|
535
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
|
|
536
|
+
tls,
|
|
537
|
+
authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
|
|
538
|
+
reconnectDelayMs: 10,
|
|
539
|
+
maxReconnectDelayMs: 20
|
|
540
|
+
})
|
|
541
|
+
after(() => gatewayA.close())
|
|
542
|
+
await gatewayA.start()
|
|
543
|
+
await waitUntil(() => hub.status().healthyGateways === 1, "gateway A registration")
|
|
544
|
+
|
|
545
|
+
// The same member registers the class up again and is taken back into use.
|
|
546
|
+
await waitUntilWithin(() => client.status().gatewayUnavailable === false, "automatic re-registration", 4_000)
|
|
547
|
+
const stream = client.createConnection({host: targetA.host, port: targetA.port})
|
|
548
|
+
await onceEvent(stream, "connect")
|
|
549
|
+
expect(await exchange(stream, "probe")).toBe("a:probe")
|
|
550
|
+
stream.destroy()
|
|
551
|
+
}))
|
|
473
552
|
})
|
|
@@ -58,6 +58,7 @@ class FakeMember extends EventEmitter {
|
|
|
58
58
|
this.closeError = closeError
|
|
59
59
|
this.connectCalls = 0
|
|
60
60
|
this.closeCalls = 0
|
|
61
|
+
this.drainCalls = 0
|
|
61
62
|
this.opens = 0
|
|
62
63
|
/** @type {FakeSocket[]} */
|
|
63
64
|
this.sockets = []
|
|
@@ -161,6 +162,11 @@ class FakeMember extends EventEmitter {
|
|
|
161
162
|
this.setState("closed")
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
async drain() {
|
|
166
|
+
this.drainCalls += 1
|
|
167
|
+
this.setState("closed")
|
|
168
|
+
}
|
|
169
|
+
|
|
164
170
|
/** @param {string} state - Physical state. @param {Error} [error] - Terminal error. */
|
|
165
171
|
setState(state, error) {
|
|
166
172
|
this.state = state
|
|
@@ -552,16 +558,30 @@ describe("multiplexed transport set", () => {
|
|
|
552
558
|
await set.close()
|
|
553
559
|
})
|
|
554
560
|
|
|
555
|
-
it("a
|
|
561
|
+
it("a member the hub registered down is never selected, even when every member is down", async () => {
|
|
556
562
|
const {set, members} = fixture()
|
|
557
563
|
const started = set.start()
|
|
558
564
|
members.forEach((member) => member.ready())
|
|
559
565
|
await started
|
|
560
566
|
members.forEach((member) => { member.gatewayUnavailable = true })
|
|
567
|
+
let callbacks = 0
|
|
561
568
|
const socket = set.createConnection({host: "target.internal", port: 443}, (error) => {
|
|
562
569
|
expect(error).toBe(null)
|
|
570
|
+
callbacks += 1
|
|
563
571
|
})
|
|
572
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
573
|
+
expect(callbacks).toBe(0)
|
|
574
|
+
expect(socket.gatewayId).toBe(undefined)
|
|
575
|
+
expect(set.status().pendingOpens).toBe(1)
|
|
576
|
+
expect(members.map((member) => member.opens)).toEqual([0, 0, 0])
|
|
577
|
+
expect(set.status().members.map((member) => member.gatewayUnavailable)).toEqual([true, true, true])
|
|
578
|
+
|
|
579
|
+
at(members, 2).gatewayUnavailable = false
|
|
580
|
+
at(members, 2).setState("ready")
|
|
581
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
582
|
+
expect(callbacks).toBe(1)
|
|
564
583
|
expect(socket.gatewayId !== undefined).toBe(true)
|
|
584
|
+
expect(members.map((member) => member.opens)).toEqual([0, 0, 1])
|
|
565
585
|
expect(set.status().pendingOpens).toBe(0)
|
|
566
586
|
await set.close()
|
|
567
587
|
})
|