socketduct 0.1.32 → 0.1.34
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/README.md +1 -0
- package/changelog.d/20261026183000-route-class-availability-scope.md +3 -0
- package/changelog.d/20261026192500-target-scoped-availability.md +5 -0
- package/changelog.d/20261026200000-ship-docker-entrypoint.md +1 -0
- package/docker-entrypoint.sh +11 -0
- package/docs/reverse-gateway.md +5 -0
- package/docs/transport-set-availability.md +119 -0
- package/package.json +2 -2
- package/src/client/multiplexed-connection.js +11 -0
- package/src/client/multiplexed-transport-set.js +48 -11
- package/src/reverse/client.js +75 -135
- package/test/integration/multiplexed-transport.test.js +160 -0
- package/test/integration/reverse-route-class.test.js +50 -47
- package/test/unit/client/multiplexed-transport-set.test.js +45 -15
- package/changelog.d/20261025123000-route-class-down-registration.md +0 -1
- package/changelog.d/20261025203000-node-24-lts.md +0 -5
- package/changelog.d/20261026100000-reverse-client-drain.md +0 -1
- package/test/integration/multiplexed-drain.test.js +0 -179
- package/test/integration/reverse-drain.test.js +0 -138
package/src/reverse/client.js
CHANGED
|
@@ -32,8 +32,16 @@ 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
|
|
36
|
-
|
|
35
|
+
const MAX_TARGET_UNAVAILABLE_ENTRIES = 64
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Builds the per-target availability key for one logical target.
|
|
39
|
+
* @param {{host: string, port: number}} target - Logical target.
|
|
40
|
+
* @returns {string} Stable key.
|
|
41
|
+
*/
|
|
42
|
+
function targetKey(target) {
|
|
43
|
+
return `${target.host}:${target.port}`
|
|
44
|
+
}
|
|
37
45
|
|
|
38
46
|
/**
|
|
39
47
|
* @typedef {object} ReverseGatewayConnectionOptions
|
|
@@ -52,8 +60,6 @@ const DEFAULT_MAX_GATEWAY_PROBE_DELAY_MS = 30_000
|
|
|
52
60
|
* @property {number} [connectTimeoutMs] - TLS/auth deadline.
|
|
53
61
|
* @property {number} [reconnectDelayMs] - Initial reconnect delay.
|
|
54
62
|
* @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.
|
|
57
63
|
* @property {number} [initialGeneration] - Monotonic seed for controlled replacement; defaults to wall-clock microseconds.
|
|
58
64
|
* @property {boolean} [continuousReconnect] - Keep the member warm after loss, default true.
|
|
59
65
|
* @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - zstd safety bounds.
|
|
@@ -144,9 +150,6 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
144
150
|
/** @type {number} */ #generation
|
|
145
151
|
/** @type {string | undefined} */ #codec
|
|
146
152
|
/** @type {boolean} */ #closing = false
|
|
147
|
-
/** @type {boolean} */ #draining = false
|
|
148
|
-
/** @type {Promise<void> | undefined} */ #closePromise
|
|
149
|
-
/** @type {Array<() => void>} */ #drainWaiters = []
|
|
150
153
|
/** @type {boolean} */ #detached = false
|
|
151
154
|
/** @type {boolean} */ #preserveSpools = false
|
|
152
155
|
/** @type {boolean} */ #fatal = false
|
|
@@ -175,10 +178,15 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
175
178
|
/** @type {number} */ #spoolBytes = 0
|
|
176
179
|
/** @type {number} */ #spoolCleanupFailures = 0
|
|
177
180
|
/** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
|
|
178
|
-
/** @type {number | undefined} - Wall-clock time the hub
|
|
179
|
-
/**
|
|
180
|
-
|
|
181
|
-
|
|
181
|
+
/** @type {number | undefined} - Wall-clock time of the last hub-reported gateway unavailability. */ #gatewayUnavailableSince
|
|
182
|
+
/**
|
|
183
|
+
* Targets this member most recently answered with "the hub has no gateway for
|
|
184
|
+
* that target". Scoped to the exact target so one unreachable machine cannot
|
|
185
|
+
* take this route class out of rotation for every other target, and cleared by
|
|
186
|
+
* the next OPENED for the same target instead of expiring on a timer.
|
|
187
|
+
* @type {Map<string, number>}
|
|
188
|
+
*/
|
|
189
|
+
#targetUnavailableSince = new Map()
|
|
182
190
|
/** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */ #disconnectAttribution
|
|
183
191
|
|
|
184
192
|
/** @param {ReverseGatewayConnectionOptions} options - Hub route and safety policy. */
|
|
@@ -194,7 +202,6 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
194
202
|
})
|
|
195
203
|
this.#generation = options.initialGeneration ?? Date.now() * 1000
|
|
196
204
|
this.#reconnectDelay = options.reconnectDelayMs ?? 250
|
|
197
|
-
this.#gatewayProbeDelay = options.gatewayProbeDelayMs ?? DEFAULT_GATEWAY_PROBE_DELAY_MS
|
|
198
205
|
this.#sendWindowLimit = this.#initialWindow()
|
|
199
206
|
this.createConnection = this.createConnection.bind(this)
|
|
200
207
|
}
|
|
@@ -250,7 +257,6 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
250
257
|
/** Creates a fresh or synchronously recovered reverse stream. */
|
|
251
258
|
#createConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback, /** @type {boolean} */ recovering) {
|
|
252
259
|
if (this.#closing) throw closedError()
|
|
253
|
-
if (this.#draining) throw Object.assign(new Error("Reverse gateway member is draining"), {code: "SOCKETDUCT_TRANSPORT_SET_DRAINING"})
|
|
254
260
|
if (options?.manualReceiveAck !== undefined && typeof options.manualReceiveAck !== "boolean") throw new TypeError("manualReceiveAck must be a boolean")
|
|
255
261
|
if (!recovering && this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
|
|
256
262
|
if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum reverse virtual stream count exceeded")
|
|
@@ -372,92 +378,58 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
372
378
|
* @returns {void}
|
|
373
379
|
*/
|
|
374
380
|
#markGatewayUnavailable(code) {
|
|
375
|
-
const firstMark = this.#
|
|
376
|
-
this.#
|
|
381
|
+
const firstMark = this.#gatewayUnavailableSince === undefined
|
|
382
|
+
this.#gatewayUnavailableSince = Date.now()
|
|
377
383
|
if (firstMark) {
|
|
378
384
|
logDiagnostic(this.#options.logger, "reverse_client.gateway_unavailable", {
|
|
379
385
|
code,
|
|
380
386
|
generation: this.#generation,
|
|
381
387
|
...this.#routeIdentityFields()
|
|
382
388
|
})
|
|
383
|
-
this.#emitStateChange()
|
|
384
389
|
}
|
|
385
|
-
this.#scheduleGatewayProbe()
|
|
386
390
|
}
|
|
387
391
|
|
|
388
392
|
/**
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
393
|
+
* Whether the hub last reported this member's route class as having no selectable
|
|
394
|
+
* gateway. The report is a property of the live connection, not a short-lived cache:
|
|
395
|
+
* it arrives once per connection at selection time and only changes when the hub says
|
|
396
|
+
* otherwise or an open succeeds. Expiring it on a timer made the transport set pick a
|
|
397
|
+
* route class the hub had already declared unserviceable, and the reset that followed
|
|
398
|
+
* failed the caller's operation — a build lost its first transport open this way.
|
|
399
|
+
* The transport set still falls back to marked members when every member is marked, so
|
|
400
|
+
* a mark can never wedge the set.
|
|
401
|
+
* @returns {boolean} True while the hub reports no selectable gateway for this member.
|
|
392
402
|
*/
|
|
393
|
-
#
|
|
394
|
-
|
|
395
|
-
const downMs = Date.now() - this.#gatewayDownSince
|
|
396
|
-
this.#gatewayDownSince = undefined
|
|
397
|
-
this.#gatewayProbeDelay = this.#options.gatewayProbeDelayMs ?? DEFAULT_GATEWAY_PROBE_DELAY_MS
|
|
398
|
-
this.#gatewayProbeAttempts = 0
|
|
399
|
-
if (this.#gatewayProbeTimer !== undefined) clearTimeout(this.#gatewayProbeTimer)
|
|
400
|
-
this.#gatewayProbeTimer = undefined
|
|
401
|
-
logDiagnostic(this.#options.logger, "reverse_client.gateway_available", {
|
|
402
|
-
downMs,
|
|
403
|
-
generation: this.#generation,
|
|
404
|
-
...this.#routeIdentityFields()
|
|
405
|
-
})
|
|
406
|
-
this.#emitStateChange()
|
|
403
|
+
#gatewayUnavailable() {
|
|
404
|
+
return this.#gatewayUnavailableSince !== undefined
|
|
407
405
|
}
|
|
408
406
|
|
|
409
407
|
/**
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
* @
|
|
408
|
+
* Whether this member most recently reported the exact target unserviceable.
|
|
409
|
+
* Scoped per target: one unreachable machine must not remove this route class
|
|
410
|
+
* from rotation for every other machine.
|
|
411
|
+
* @param {{host: string, port: number}} target - Logical target a caller wants to open.
|
|
412
|
+
* @returns {boolean} True while this member is marked down for that target.
|
|
414
413
|
*/
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const delay = this.#gatewayProbeDelay
|
|
418
|
-
this.#gatewayProbeDelay = Math.min(this.#gatewayProbeDelay * 2, this.#options.maxGatewayProbeDelayMs ?? DEFAULT_MAX_GATEWAY_PROBE_DELAY_MS)
|
|
419
|
-
this.#gatewayProbeTimer = setTimeout(() => {
|
|
420
|
-
this.#gatewayProbeTimer = undefined
|
|
421
|
-
this.#probeGateway()
|
|
422
|
-
}, delay)
|
|
423
|
-
if (!this.#referenced) this.#gatewayProbeTimer.unref?.()
|
|
414
|
+
isTargetUnavailable(target) {
|
|
415
|
+
return this.#targetUnavailableSince.has(targetKey(target))
|
|
424
416
|
}
|
|
425
417
|
|
|
426
|
-
/**
|
|
427
|
-
#
|
|
428
|
-
|
|
429
|
-
this.#
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
})
|
|
436
|
-
const physical = this.#physical
|
|
437
|
-
if (physical !== undefined && !physical.destroyed) {
|
|
438
|
-
physical.destroy()
|
|
439
|
-
return
|
|
440
|
-
}
|
|
441
|
-
if (this.#connectPromise === undefined) {
|
|
442
|
-
this.#connectPromise = new Promise((resolve, reject) => { this.#resolveConnect = resolve; this.#rejectConnect = reject })
|
|
443
|
-
this.#connectPromise.catch(() => {})
|
|
418
|
+
/** Records that this member could not serve a target, bounded so long-lived sets stay small. */
|
|
419
|
+
#markTargetUnavailable(/** @type {{host: string, port: number}} */ target) {
|
|
420
|
+
const key = targetKey(target)
|
|
421
|
+
this.#targetUnavailableSince.delete(key)
|
|
422
|
+
this.#targetUnavailableSince.set(key, Date.now())
|
|
423
|
+
while (this.#targetUnavailableSince.size > MAX_TARGET_UNAVAILABLE_ENTRIES) {
|
|
424
|
+
const oldest = this.#targetUnavailableSince.keys().next()
|
|
425
|
+
if (oldest.done === true) break
|
|
426
|
+
this.#targetUnavailableSince.delete(oldest.value)
|
|
444
427
|
}
|
|
445
|
-
this.#connectPhysical()
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
/** Notifies owners that availability changed without changing the physical state. @returns {void} */
|
|
449
|
-
#emitStateChange() {
|
|
450
|
-
this.emit("stateChange", this.#state, undefined)
|
|
451
428
|
}
|
|
452
429
|
|
|
453
|
-
/**
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
* hub reports a selectable gateway again.
|
|
457
|
-
* @returns {boolean} True while the route class is registered down.
|
|
458
|
-
*/
|
|
459
|
-
#gatewayUnavailable() {
|
|
460
|
-
return this.#gatewayDownSince !== undefined
|
|
430
|
+
/** Clears a target mark; a successful open is the only thing that proves it recovered. */
|
|
431
|
+
#clearTargetUnavailable(/** @type {{host: string, port: number}} */ target) {
|
|
432
|
+
this.#targetUnavailableSince.delete(targetKey(target))
|
|
461
433
|
}
|
|
462
434
|
|
|
463
435
|
status() {
|
|
@@ -467,7 +439,6 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
467
439
|
return {
|
|
468
440
|
physicalState: this.#state,
|
|
469
441
|
gatewayUnavailable: this.#gatewayUnavailable(),
|
|
470
|
-
gatewayProbeAttempts: this.#gatewayProbeAttempts,
|
|
471
442
|
connectionGeneration: this.#generation,
|
|
472
443
|
virtualStreams: this.#streams.size,
|
|
473
444
|
streamStates,
|
|
@@ -491,52 +462,13 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
491
462
|
ref() { this.#referenced = true; this.#physical?.ref(); this.#reconnectTimer?.ref?.(); return this }
|
|
492
463
|
unref() { this.#referenced = false; this.#physical?.unref(); this.#reconnectTimer?.unref?.(); return this }
|
|
493
464
|
|
|
494
|
-
/**
|
|
495
|
-
* Graceful counterpart of `close()`: refuses new virtual streams, lets the open
|
|
496
|
-
* ones finish on their own, then releases the shared transport. Transport sets
|
|
497
|
-
* that are torn down while they carry work must use this instead of `close()`,
|
|
498
|
-
* otherwise live streams are reset at the relay mid-transfer.
|
|
499
|
-
* @returns {Promise<void>}
|
|
500
|
-
*/
|
|
501
|
-
async drain() {
|
|
502
|
-
if (this.#closePromise !== undefined) return this.#closePromise
|
|
503
|
-
this.#draining = true
|
|
504
|
-
this.#closePromise = (async () => {
|
|
505
|
-
logDiagnostic(this.#options.logger, "reverse_client.draining", {virtualStreams: this.#streams.size})
|
|
506
|
-
await this.#waitForStreamsToDrain()
|
|
507
|
-
await this.close()
|
|
508
|
-
})()
|
|
509
|
-
|
|
510
|
-
return this.#closePromise
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/** @returns {Promise<void>} */
|
|
514
|
-
#waitForStreamsToDrain() {
|
|
515
|
-
if (this.#streams.size === 0) return Promise.resolve()
|
|
516
|
-
|
|
517
|
-
return new Promise((resolve) => { this.#drainWaiters.push(resolve) })
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
/** @returns {void} */
|
|
521
|
-
#releaseDrainWaiters() {
|
|
522
|
-
if (this.#drainWaiters.length === 0) return
|
|
523
|
-
const waiters = this.#drainWaiters.splice(0)
|
|
524
|
-
for (const resolve of waiters) resolve()
|
|
525
|
-
}
|
|
526
|
-
|
|
527
465
|
async close() {
|
|
528
466
|
if (this.#closing) return
|
|
529
|
-
if (this.#streams.size > 0 && !this.#draining) {
|
|
530
|
-
logDiagnostic(this.#options.logger, "reverse_client.closed_with_live_streams", {streamsKilled: this.#streams.size})
|
|
531
|
-
}
|
|
532
467
|
this.#closing = true
|
|
533
468
|
if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
|
|
534
469
|
this.#reconnectTimer = undefined
|
|
535
|
-
if (this.#gatewayProbeTimer !== undefined) clearTimeout(this.#gatewayProbeTimer)
|
|
536
|
-
this.#gatewayProbeTimer = undefined
|
|
537
470
|
this.#setState("closed")
|
|
538
471
|
for (const stream of [...this.#streams.values()]) stream.socket.destroy()
|
|
539
|
-
this.#releaseDrainWaiters()
|
|
540
472
|
const physical = this.#physical
|
|
541
473
|
this.#physical = undefined
|
|
542
474
|
if (physical !== undefined && !physical.destroyed) {
|
|
@@ -560,8 +492,6 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
560
492
|
this.#closing = true
|
|
561
493
|
if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
|
|
562
494
|
this.#reconnectTimer = undefined
|
|
563
|
-
if (this.#gatewayProbeTimer !== undefined) clearTimeout(this.#gatewayProbeTimer)
|
|
564
|
-
this.#gatewayProbeTimer = undefined
|
|
565
495
|
this.#setState("closed")
|
|
566
496
|
const physical = this.#physical
|
|
567
497
|
physical?.pause()
|
|
@@ -774,7 +704,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
774
704
|
const selection = this.#routeIdentityFields()
|
|
775
705
|
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")
|
|
776
706
|
if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_SELECTION_FAILED")
|
|
777
|
-
if (detail.selectable === true) this.#
|
|
707
|
+
if (detail.selectable === true) this.#gatewayUnavailableSince = undefined
|
|
778
708
|
else if (detail.selectable === false) this.#markGatewayUnavailable("GATEWAY_SELECT_UNAVAILABLE")
|
|
779
709
|
selected = true
|
|
780
710
|
if (detail.resumed) {
|
|
@@ -901,7 +831,14 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
901
831
|
stream.sendWindow = Math.min(stream.sendWindow, detail.initialWindow)
|
|
902
832
|
stream.state = stream.localEnded ? "half-closed" : "open"
|
|
903
833
|
stream.opened = true
|
|
904
|
-
this.#
|
|
834
|
+
this.#clearTargetUnavailable(stream.target)
|
|
835
|
+
if (this.#gatewayUnavailableSince !== undefined) {
|
|
836
|
+
this.#gatewayUnavailableSince = undefined
|
|
837
|
+
logDiagnostic(this.#options.logger, "reverse_client.gateway_available", {
|
|
838
|
+
generation: this.#generation,
|
|
839
|
+
...this.#routeIdentityFields()
|
|
840
|
+
})
|
|
841
|
+
}
|
|
905
842
|
logDiagnostic(this.#options.logger, "virtual_stream.opened", {
|
|
906
843
|
streamId: stream.id,
|
|
907
844
|
generation: this.#generation,
|
|
@@ -955,9 +892,21 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
955
892
|
const detail = decodeV3Control(frame.payload)
|
|
956
893
|
const code = typeof detail.code === "string" ? detail.code : "STREAM_RESET"
|
|
957
894
|
stream.state = "reset"
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
895
|
+
// A stream reset describes this stream's target, not the health of the
|
|
896
|
+
// member's whole route class. The hub resolves gateways per (route class,
|
|
897
|
+
// target) and the transport set retries a failed open across members, so a
|
|
898
|
+
// single unreachable target would otherwise mark every route class down.
|
|
899
|
+
// Such a mark never expires on its own, which deadlocks the set: nothing is
|
|
900
|
+
// selected, so no open can succeed, so no mark can ever be cleared.
|
|
901
|
+
// Route-class availability is owned by the hub's GATEWAY_SELECTED response,
|
|
902
|
+
// which is class-scoped and re-read on every probe.
|
|
903
|
+
//
|
|
904
|
+
// What the reset does own is this target: the transport set must stop
|
|
905
|
+
// handing this member opens for a target it just reported unserviceable,
|
|
906
|
+
// while every other target keeps using it. The mark is keyed by target and
|
|
907
|
+
// cleared by the next OPENED for that target, so it never expires on a
|
|
908
|
+
// timer and can never wedge the set.
|
|
909
|
+
if (code === "GATEWAY_UNAVAILABLE" || code === "GATEWAY_DISCONNECTED") this.#markTargetUnavailable(stream.target)
|
|
961
910
|
const error = attachDisconnectAttribution(new SocketductStreamError("Reverse gateway stream was reset", code), validHubAttribution(detail) ?? (code === "GATEWAY_DISCONNECTED" ? this.#disconnectAttribution : undefined))
|
|
962
911
|
stream.socket._receiveError(error)
|
|
963
912
|
return
|
|
@@ -1244,7 +1193,6 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
1244
1193
|
while (stream.pendingWrites.length > 0) this.#finishWrite(stream, pendingWriteError)
|
|
1245
1194
|
if (!this.#preserveSpools) this.#trackSpoolCleanup(stream)
|
|
1246
1195
|
this.#progressPendingWrites()
|
|
1247
|
-
if (this.#streams.size === 0) this.#releaseDrainWaiters()
|
|
1248
1196
|
}
|
|
1249
1197
|
|
|
1250
1198
|
/** @param {ReverseClientStream} stream - Stream. @param {number} sequence - ACK. */
|
|
@@ -1330,11 +1278,6 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
1330
1278
|
this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, this.#options.maxReconnectDelayMs ?? 30_000)
|
|
1331
1279
|
this.#reconnectTimer = setTimeout(() => {
|
|
1332
1280
|
this.#reconnectTimer = undefined
|
|
1333
|
-
// clearTimeout() cannot stop a callback that is already queued, so a
|
|
1334
|
-
// detach()/close() that lands in that window used to reconnect anyway:
|
|
1335
|
-
// it reopened a physical transport after the caller had deliberately
|
|
1336
|
-
// detached (observed as ENOENT reading a deleted recovery CA file).
|
|
1337
|
-
if (this.#closing || this.#fatal) return
|
|
1338
1281
|
if (this.#connectPromise === undefined) {
|
|
1339
1282
|
this.#connectPromise = new Promise((resolve, reject) => { this.#resolveConnect = resolve; this.#rejectConnect = reject })
|
|
1340
1283
|
this.#connectPromise.catch(() => {})
|
|
@@ -1452,9 +1395,6 @@ function validateOptions(options) {
|
|
|
1452
1395
|
positiveInteger(options.maxConnectionBufferBytes, "maxConnectionBufferBytes", DEFAULT_CONNECTION_BUFFER_BYTES)
|
|
1453
1396
|
if (options.spoolDirectory !== undefined && (typeof options.spoolDirectory !== "string" || options.spoolDirectory.length === 0)) throw new TypeError("spoolDirectory must be a non-empty string")
|
|
1454
1397
|
positiveInteger(options.maxAgeMs, "maxAgeMs", 24 * 60 * 60 * 1000)
|
|
1455
|
-
const gatewayProbeDelay = positiveInteger(options.gatewayProbeDelayMs, "gatewayProbeDelayMs", DEFAULT_GATEWAY_PROBE_DELAY_MS)
|
|
1456
|
-
const maxGatewayProbeDelay = positiveInteger(options.maxGatewayProbeDelayMs, "maxGatewayProbeDelayMs", DEFAULT_MAX_GATEWAY_PROBE_DELAY_MS)
|
|
1457
|
-
if (maxGatewayProbeDelay < gatewayProbeDelay) throw new RangeError("maxGatewayProbeDelayMs must not be smaller than gatewayProbeDelayMs")
|
|
1458
1398
|
if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
|
|
1459
1399
|
}
|
|
1460
1400
|
|
|
@@ -207,3 +207,163 @@ describe("multiplexed transport", () => {
|
|
|
207
207
|
sockets.forEach((socket) => socket.destroy())
|
|
208
208
|
}))
|
|
209
209
|
})
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* `close()` terminates live virtual streams, which the relay answers with a
|
|
213
|
+
* stream reset — that is how in-flight work on the other side of a transport
|
|
214
|
+
* set died when the owning process shut down. `drain()` is the graceful verb:
|
|
215
|
+
* it refuses new streams, waits for the open ones, and only then releases the
|
|
216
|
+
* transport.
|
|
217
|
+
*/
|
|
218
|
+
|
|
219
|
+
/** @returns {Promise<void>} */
|
|
220
|
+
async function settleTurns() {
|
|
221
|
+
for (let index = 0; index < 10; index += 1) await new Promise((resolve) => setImmediate(resolve))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @param {Array<import('../../src/client/virtual-socket.js').VirtualSocket>} streams - Open streams.
|
|
226
|
+
* @param {number} index - Index.
|
|
227
|
+
* @returns {import('../../src/client/virtual-socket.js').VirtualSocket} - The opened stream.
|
|
228
|
+
*/
|
|
229
|
+
function streamAt(streams, index) {
|
|
230
|
+
const stream = streams[index]
|
|
231
|
+
|
|
232
|
+
if (stream === undefined) throw new Error(`Expected stream ${index} to be open`)
|
|
233
|
+
|
|
234
|
+
return stream
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @param {(callback: () => Promise<void> | void) => void} after - Cleanup registrar.
|
|
239
|
+
* @param {number} streamCount - Streams to open.
|
|
240
|
+
*/
|
|
241
|
+
async function openDrainStreams(after, streamCount) {
|
|
242
|
+
/** @type {Set<import('node:net').Socket>} */
|
|
243
|
+
const targetSockets = new Set()
|
|
244
|
+
const target = createServer((socket) => {
|
|
245
|
+
targetSockets.add(socket)
|
|
246
|
+
socket.on("close", () => targetSockets.delete(socket))
|
|
247
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
248
|
+
})
|
|
249
|
+
const targetPort = await listen(target)
|
|
250
|
+
|
|
251
|
+
after(async () => {
|
|
252
|
+
for (const socket of targetSockets) socket.destroy()
|
|
253
|
+
await new Promise((resolve) => target.close(() => resolve(undefined)))
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
const relay = await startMultiplexedTcpRelay({
|
|
257
|
+
tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
|
|
258
|
+
authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === targetPort
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
after(() => relay.close())
|
|
262
|
+
|
|
263
|
+
const connection = new MultiplexedSocketductConnection({
|
|
264
|
+
relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
after(() => connection.close())
|
|
268
|
+
/** @type {Array<import('../../src/client/virtual-socket.js').VirtualSocket>} */
|
|
269
|
+
const streams = []
|
|
270
|
+
|
|
271
|
+
for (let index = 0; index < streamCount; index += 1) {
|
|
272
|
+
streams.push(connection.createConnection({host: "127.0.0.1", port: targetPort, sessionName: `drain-${index}`}))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
await Promise.all(streams.map((stream) => onceEvent(stream, "connect")))
|
|
276
|
+
expect(connection.status().virtualStreams).toBe(streamCount)
|
|
277
|
+
|
|
278
|
+
return {connection, relay, streams, targetPort}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
describe("multiplexed transport drain", () => {
|
|
282
|
+
it("drain() keeps in-flight virtual streams alive until they finish", async () => withTestCleanup(async (after) => {
|
|
283
|
+
const {connection, relay, streams} = await openDrainStreams(after, 2)
|
|
284
|
+
|
|
285
|
+
const draining = connection.drain()
|
|
286
|
+
|
|
287
|
+
await settleTurns()
|
|
288
|
+
|
|
289
|
+
// drain() must not tear down streams that are still open.
|
|
290
|
+
expect(connection.status().virtualStreams).toBe(2)
|
|
291
|
+
expect(streamAt(streams, 0).destroyed).toBe(false)
|
|
292
|
+
expect(streamAt(streams, 1).destroyed).toBe(false)
|
|
293
|
+
// The physical transport must not be dropped while streams are live.
|
|
294
|
+
expect(relay.status().authenticatedConnections).toBe(1)
|
|
295
|
+
|
|
296
|
+
for (const stream of streams) stream.destroy()
|
|
297
|
+
await draining
|
|
298
|
+
|
|
299
|
+
expect(connection.status().virtualStreams).toBe(0)
|
|
300
|
+
// The physical transport is released once the last stream ends.
|
|
301
|
+
expect(relay.status().authenticatedConnections).toBe(0)
|
|
302
|
+
}))
|
|
303
|
+
|
|
304
|
+
it("drain() keeps working streams usable instead of resetting them", async () => withTestCleanup(async (after) => {
|
|
305
|
+
const {connection, streams} = await openDrainStreams(after, 1)
|
|
306
|
+
const draining = connection.drain()
|
|
307
|
+
|
|
308
|
+
await settleTurns()
|
|
309
|
+
|
|
310
|
+
streamAt(streams, 0).write("still-works")
|
|
311
|
+
/** @type {string} */
|
|
312
|
+
let echoed = ""
|
|
313
|
+
|
|
314
|
+
await new Promise((resolve, reject) => {
|
|
315
|
+
/** @param {Buffer} chunk - Echoed data. */
|
|
316
|
+
const onData = (chunk) => {
|
|
317
|
+
echoed += chunk.toString()
|
|
318
|
+
if (echoed.length < 11) return
|
|
319
|
+
streamAt(streams, 0).off("error", reject)
|
|
320
|
+
resolve(undefined)
|
|
321
|
+
}
|
|
322
|
+
streamAt(streams, 0).on("error", reject)
|
|
323
|
+
streamAt(streams, 0).on("data", onData)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// Data written during the drain must still reach the target and come back.
|
|
327
|
+
expect(echoed).toBe("still-works")
|
|
328
|
+
|
|
329
|
+
streamAt(streams, 0).destroy()
|
|
330
|
+
await draining
|
|
331
|
+
}))
|
|
332
|
+
|
|
333
|
+
it("drain() rejects new virtual streams while draining", async () => withTestCleanup(async (after) => {
|
|
334
|
+
const {connection, streams, targetPort} = await openDrainStreams(after, 1)
|
|
335
|
+
const draining = connection.drain()
|
|
336
|
+
|
|
337
|
+
await settleTurns()
|
|
338
|
+
|
|
339
|
+
// A draining connection must refuse new virtual streams instead of opening
|
|
340
|
+
// them on a doomed transport.
|
|
341
|
+
/** @type {unknown} */
|
|
342
|
+
let caughtError
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
connection.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "late"})
|
|
346
|
+
} catch (error) {
|
|
347
|
+
caughtError = error
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
expect(caughtError).toBeInstanceOf(Error)
|
|
351
|
+
expect(String(/** @type {Error} */ (caughtError).message)).toMatch(/connection is closed/i)
|
|
352
|
+
// The stream that was already open must survive.
|
|
353
|
+
expect(connection.status().virtualStreams).toBe(1)
|
|
354
|
+
|
|
355
|
+
streamAt(streams, 0).destroy()
|
|
356
|
+
await draining
|
|
357
|
+
}))
|
|
358
|
+
|
|
359
|
+
it("close() still terminates in-flight virtual streams immediately", async () => withTestCleanup(async (after) => {
|
|
360
|
+
const {connection, relay, streams} = await openDrainStreams(after, 2)
|
|
361
|
+
|
|
362
|
+
await connection.close()
|
|
363
|
+
|
|
364
|
+
expect(connection.status().virtualStreams).toBe(0)
|
|
365
|
+
expect(streamAt(streams, 0).destroyed).toBe(true)
|
|
366
|
+
expect(streamAt(streams, 1).destroyed).toBe(true)
|
|
367
|
+
expect(relay.status().authenticatedConnections).toBe(0)
|
|
368
|
+
}))
|
|
369
|
+
})
|
|
@@ -56,15 +56,6 @@ 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
|
-
|
|
68
59
|
/** @param {string} prefix - Echo response prefix. */
|
|
69
60
|
async function createEchoTarget(prefix) {
|
|
70
61
|
const sockets = new Set()
|
|
@@ -480,58 +471,42 @@ describe("reverse gateway route-class", () => {
|
|
|
480
471
|
stream.destroy()
|
|
481
472
|
}))
|
|
482
473
|
|
|
483
|
-
it("a
|
|
484
|
-
const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-
|
|
474
|
+
it("a reset for one target without a gateway does not mark the whole route class unavailable", async () => withTestCleanup(async (after) => {
|
|
475
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-target-scope-"))
|
|
485
476
|
after(() => rm(root, {recursive: true, force: true}))
|
|
486
477
|
const certificates = generateTestCertificates(root)
|
|
487
478
|
const targetA = await createEchoTarget("a:")
|
|
488
479
|
after(() => targetA.close())
|
|
480
|
+
const targetB = await createEchoTarget("b:")
|
|
481
|
+
after(() => targetB.close())
|
|
489
482
|
|
|
483
|
+
// The route class is serviceable, but only target A has a configured gateway.
|
|
484
|
+
// Target B has none, so the hub resets opens for it with GATEWAY_UNAVAILABLE.
|
|
485
|
+
// That statement is about target B, not about the route class as a whole.
|
|
490
486
|
const hub = await startReverseGatewayHub({
|
|
491
487
|
tokens: new StaticTokenStore([
|
|
492
488
|
{username: "home-a", token: "gateway-a-token"},
|
|
493
489
|
{username: "tensorbuzz", token: "client-token"}
|
|
494
490
|
]),
|
|
495
491
|
tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
|
|
496
|
-
knownGatewayIds: ["server-a-starlink"],
|
|
492
|
+
knownGatewayIds: ["server-a-starlink", "server-b-starlink"],
|
|
497
493
|
knownRouteClasses: ["starlink"],
|
|
498
|
-
authorizeGateway: ({principal, gatewayId}) => principal.username === "home-a" && gatewayId === "server-a-starlink",
|
|
494
|
+
authorizeGateway: ({principal, gatewayId}) => principal.username === "home-a" && (gatewayId === "server-a-starlink" || gatewayId === "server-b-starlink"),
|
|
499
495
|
authorizeClientGateway: () => false,
|
|
500
496
|
authorizeClientRouteClass: ({principal, routeClass}) => principal.username === "tensorbuzz" && routeClass === "starlink",
|
|
501
|
-
resolveRouteClassGateways: () => ["server-a-starlink"],
|
|
502
|
-
resolveOpenGateways: ({routeClass, target}) =>
|
|
503
|
-
|
|
497
|
+
resolveRouteClassGateways: () => ["server-a-starlink", "server-b-starlink"],
|
|
498
|
+
resolveOpenGateways: ({routeClass, target}) => {
|
|
499
|
+
if (routeClass !== "starlink") return []
|
|
500
|
+
return target.port === targetA.port ? ["server-a-starlink"] : ["server-b-starlink"]
|
|
501
|
+
},
|
|
502
|
+
authorizeOpen: ({gatewayId, target}) => (gatewayId === "server-a-starlink" && target.port === targetA.port) || (gatewayId === "server-b-starlink" && target.port === targetB.port)
|
|
504
503
|
})
|
|
505
504
|
after(() => hub.close())
|
|
506
505
|
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
507
506
|
|
|
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
507
|
const gatewayA = new OutboundReverseGateway({
|
|
533
508
|
gatewayId: "server-a-starlink",
|
|
534
|
-
connectionId: "
|
|
509
|
+
connectionId: "target_scope_gateway_a",
|
|
535
510
|
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
|
|
536
511
|
tls,
|
|
537
512
|
authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
|
|
@@ -542,11 +517,39 @@ describe("reverse gateway route-class", () => {
|
|
|
542
517
|
await gatewayA.start()
|
|
543
518
|
await waitUntil(() => hub.status().healthyGateways === 1, "gateway A registration")
|
|
544
519
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
520
|
+
const client = new ReverseGatewayConnection({
|
|
521
|
+
routeClass: "starlink",
|
|
522
|
+
connectionId: "target_scope_client",
|
|
523
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
|
|
524
|
+
tls
|
|
525
|
+
})
|
|
526
|
+
after(() => client.close())
|
|
527
|
+
await client.connect()
|
|
528
|
+
expect(client.status().gatewayUnavailable).toBe(false)
|
|
529
|
+
|
|
530
|
+
// Target B's gateway is configured in the route table but is not registered
|
|
531
|
+
// (the maintenance/offline case), so the hub resets its opens as unavailable.
|
|
532
|
+
const unavailableStream = client.createConnection({host: targetB.host, port: targetB.port})
|
|
533
|
+
const reset = await nextError(unavailableStream)
|
|
534
|
+
expect(reset.code).toBe("GATEWAY_UNAVAILABLE")
|
|
535
|
+
unavailableStream.destroy()
|
|
536
|
+
|
|
537
|
+
// The route class is healthy, so the reset must not take it out of service.
|
|
538
|
+
// A member marked unavailable this way is never selected again, which would
|
|
539
|
+
// stall every later open in the set until the caller's own deadline.
|
|
540
|
+
expect({value: client.status().gatewayUnavailable, context: "a target-scoped reset must not mark the route class unavailable"}).toMatchObject({value: false})
|
|
541
|
+
|
|
542
|
+
// The member must still be skipped for the target the hub could not serve,
|
|
543
|
+
// otherwise every later open for it keeps paying a full round trip, while
|
|
544
|
+
// every other target on this member stays in rotation.
|
|
545
|
+
expect({value: client.isTargetUnavailable({host: targetB.host, port: targetB.port}), context: "the unserviceable target is marked on this member"}).toMatchObject({value: true})
|
|
546
|
+
expect({value: client.isTargetUnavailable({host: targetA.host, port: targetA.port}), context: "a different target stays available on the same member"}).toMatchObject({value: false})
|
|
547
|
+
|
|
548
|
+
// A healthy target on the same route class still opens and exchanges data.
|
|
549
|
+
const healthyStream = client.createConnection({host: targetA.host, port: targetA.port})
|
|
550
|
+
await onceEvent(healthyStream, "connect")
|
|
551
|
+
expect(await exchange(healthyStream, "probe")).toBe("a:probe")
|
|
552
|
+
healthyStream.destroy()
|
|
551
553
|
}))
|
|
554
|
+
|
|
552
555
|
})
|