socketduct 0.1.33 → 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 +2 -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 +1 -1
- package/src/client/multiplexed-connection.js +11 -0
- package/src/client/multiplexed-transport-set.js +34 -4
- package/src/reverse/client.js +54 -0
- package/test/integration/reverse-route-class.test.js +6 -0
- package/test/unit/client/multiplexed-transport-set.test.js +21 -0
package/README.md
CHANGED
|
@@ -111,6 +111,7 @@ hub address because `gatewayId` selects the outbound member. Docker remains
|
|
|
111
111
|
- [Running Socketduct](docs/running.md)
|
|
112
112
|
- [Docker gateway setup](docs/docker.md)
|
|
113
113
|
- [Docker topology](docs/docker-topology.md)
|
|
114
|
+
- [Transport-set availability](docs/transport-set-availability.md)
|
|
114
115
|
- [Node-compatible socket API design](docs/socket-api.md)
|
|
115
116
|
- [Native reverse gateway](docs/reverse-gateway.md)
|
|
116
117
|
- [Durable compressed diagnostic logs](docs/logging.md)
|
|
@@ -1 +1,3 @@
|
|
|
1
1
|
A reset for one stream no longer marks the member's whole route class unavailable, so a single unreachable target cannot disable every route class and stall all later opens in a transport set.
|
|
2
|
+
|
|
3
|
+
Documents the requirement behind it in `docs/transport-set-availability.md`: per-member availability owned by the hub, failover instead of stalling, automatic recovery without operator action, and no polling watchdog.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
### Fixed
|
|
2
|
+
|
|
3
|
+
- Route-class availability failures are now scoped to the exact target that failed. A `GATEWAY_UNAVAILABLE` reset for one target marks only that target on that member, so the transport set stops handing it opens while every other target keeps using the member. The mark is cleared by the next successful open for that same target, so nothing expires on a timer and a member can never be dropped from rotation for machines it can still reach.
|
|
4
|
+
|
|
5
|
+
Releasing 0.1.33 without this scoping removed the only signal the set had for failover: with several route classes configured, opens kept landing on members whose route class could not serve the target and the reset surfaced straight to the caller. Measured on the TensorBuzz fleet, `GATEWAY_UNAVAILABLE` build failures went from 0/hour to about 100/hour within minutes of 0.1.33 reaching the running workers.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Publishes `docker-entrypoint.sh` in the package again, so the committed Dockerfile can build an image from a released tarball.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
# Run command with node if the first argument contains a "-" or is not a system command. The last
|
|
5
|
+
# part inside the "{}" is a workaround for the following bug in ash/dash:
|
|
6
|
+
# https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=874264
|
|
7
|
+
if [ "${1#-}" != "${1}" ] || [ -z "$(command -v "${1}")" ] || { [ -f "${1}" ] && ! [ -x "${1}" ]; }; then
|
|
8
|
+
set -- node "$@"
|
|
9
|
+
fi
|
|
10
|
+
|
|
11
|
+
exec "$@"
|
package/docs/reverse-gateway.md
CHANGED
|
@@ -347,6 +347,11 @@ const socket = set.createConnection({host: "docker-a", port: 2375})
|
|
|
347
347
|
await starting
|
|
348
348
|
```
|
|
349
349
|
|
|
350
|
+
Availability of a member is decided by the hub's `GATEWAY_SELECTED`
|
|
351
|
+
announcement and cleared by a successful open; a stream reset never changes
|
|
352
|
+
it. Requirements, the selection model and the failure this prevents are in
|
|
353
|
+
[transport-set availability](transport-set-availability.md).
|
|
354
|
+
|
|
350
355
|
Route-class sockets expose immutable `routeClass` allocation metadata and an
|
|
351
356
|
undefined `gatewayId`. Exact-gateway sets retain immutable `gatewayId` metadata.
|
|
352
357
|
Callers cannot override either selection per open. Reverse streams remain
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Transport-set availability
|
|
2
|
+
|
|
3
|
+
How `MultiplexedSocketductTransportSet` decides which configured member to use,
|
|
4
|
+
what "down" means for a member, and which component is allowed to say so.
|
|
5
|
+
|
|
6
|
+
This page states the requirements the model exists to satisfy, then describes
|
|
7
|
+
the mechanism. Requirements come from production failures where a runner fleet
|
|
8
|
+
was reported unreachable although every machine answered.
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
1. **Never use a member the hub reports down.** If the hub says a route class has
|
|
13
|
+
no selectable gateway, opens must not be routed to that member while a ready
|
|
14
|
+
alternative exists.
|
|
15
|
+
2. **A failure scoped to one target must not disable siblings.** Availability is
|
|
16
|
+
per route class (member). A failure to reach one *target* through one member
|
|
17
|
+
says nothing about the other targets or the other members.
|
|
18
|
+
3. **Fail over instead of stalling.** If the preferred member is unavailable, an
|
|
19
|
+
open must go to another ready member rather than waiting for the first one.
|
|
20
|
+
4. **A member that stays down must not block callers indefinitely.** Callers keep
|
|
21
|
+
their own deadlines; the set must not sleep through them while it waits for a
|
|
22
|
+
single member to come back.
|
|
23
|
+
5. **Recovery is automatic.** When a member becomes selectable again, later opens
|
|
24
|
+
use it again with no operator action — no restart, no container removal, no
|
|
25
|
+
config change.
|
|
26
|
+
6. **Availability is event-driven, not polled.** The state changes on hub
|
|
27
|
+
announcements and on successful opens. There is no timer that sweeps members
|
|
28
|
+
and no background watchdog that decides health on its own.
|
|
29
|
+
7. **Marks are attributable and bounded.** A mark is only set by evidence that
|
|
30
|
+
names the member's route class, and it is cleared by later evidence for that
|
|
31
|
+
same member. A mark cannot outlive its evidence.
|
|
32
|
+
8. **Status is inspectable.** Every member reports `physicalState`,
|
|
33
|
+
`gatewayUnavailable`, and its connection counters through
|
|
34
|
+
`connection.status()`, so a caller can explain why an open was slow.
|
|
35
|
+
|
|
36
|
+
## What it solves
|
|
37
|
+
|
|
38
|
+
A liveness sweep is one process that probes many targets in sequence. Without
|
|
39
|
+
requirement 2, a single unreachable target poisons every target probed after it
|
|
40
|
+
in that process:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
Trinity OK 399 ms
|
|
44
|
+
Lenovo DEAD 10004 ms <- genuinely unreachable
|
|
45
|
+
Morpheus DEAD 10008 ms <- healthy, blocked by the poisoning
|
|
46
|
+
Minisforum DEAD 10011 ms <- healthy, blocked by the poisoning
|
|
47
|
+
Apoc DEAD 10010 ms <- healthy, blocked by the poisoning
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Every "dead" row above answers in 63-485 ms when probed in a fresh process. The
|
|
51
|
+
mark also survived for as long as the process lived, so once all members were
|
|
52
|
+
marked the set had nothing to select: no open was attempted, therefore no
|
|
53
|
+
successful open could clear a mark, therefore the marks stayed. A sweep reported
|
|
54
|
+
a healthy fleet as unreachable and withheld its capacity.
|
|
55
|
+
|
|
56
|
+
## Model
|
|
57
|
+
|
|
58
|
+
### Who owns availability
|
|
59
|
+
|
|
60
|
+
Only the hub can mark a member unavailable, and only per route class:
|
|
61
|
+
|
|
62
|
+
- The client sends an open. The hub answers with `GATEWAY_SELECTED` carrying
|
|
63
|
+
`selectable`.
|
|
64
|
+
- `selectable: false` marks that member's route class unavailable
|
|
65
|
+
(`GATEWAY_SELECT_UNAVAILABLE`).
|
|
66
|
+
- `selectable: true` clears the mark.
|
|
67
|
+
- A successfully opened stream clears the mark as well
|
|
68
|
+
(`reverse_client.gateway_available`), because a working open is direct
|
|
69
|
+
evidence that the route class works.
|
|
70
|
+
|
|
71
|
+
A stream `RESET` does **not** change availability. A reset describes one stream's
|
|
72
|
+
target; the hub resolves gateways per `(route class, target)`, so a reset is
|
|
73
|
+
scoped to that target and never to the member's whole route class. This scoping
|
|
74
|
+
is the fix for the poisoning above, and it is covered by a regression test in
|
|
75
|
+
`test/integration/reverse-route-class.test.js`.
|
|
76
|
+
|
|
77
|
+
### Selection
|
|
78
|
+
|
|
79
|
+
`#selectReadyMember` walks the configured members in cursor order and picks the
|
|
80
|
+
first one that is both `physicalState === "ready"` and not marked
|
|
81
|
+
`gatewayUnavailable`. Members skipped for unavailability are remembered as a
|
|
82
|
+
fallback: if **every** ready member is marked down, the set still tries one of
|
|
83
|
+
them instead of refusing. That keeps a stale mark from turning into a hard
|
|
84
|
+
outage, and it means a recovered member is discovered by the next open even
|
|
85
|
+
before the hub has re-announced it.
|
|
86
|
+
|
|
87
|
+
Streams stay sticky to their selected gateway and target; a transient physical
|
|
88
|
+
transport replacement is resumed transparently. Availability decides *new*
|
|
89
|
+
selection, it does not migrate live streams.
|
|
90
|
+
|
|
91
|
+
### Boundaries
|
|
92
|
+
|
|
93
|
+
- The set does not probe gateways, does not schedule background health checks,
|
|
94
|
+
and does not expire marks on a timer. Health comes from the hub.
|
|
95
|
+
- The set does not synthesize availability from its own errors. A failure that
|
|
96
|
+
is not reported by the hub as unavailability is a plain error for that
|
|
97
|
+
operation.
|
|
98
|
+
- Per-call deadlines belong to the caller. Socketduct does not extend them or
|
|
99
|
+
retry past them.
|
|
100
|
+
|
|
101
|
+
## Operating it
|
|
102
|
+
|
|
103
|
+
- Inspect `connection.status()` per member: `physicalState`, `gatewayUnavailable`,
|
|
104
|
+
`physicalConnects`, `reconnectAttempts`, `virtualOpens`.
|
|
105
|
+
- Expect `reverse_client.gateway_available` in diagnostics when a member returns
|
|
106
|
+
to service; `reverse_client.gateway_unavailable` marks the opposite.
|
|
107
|
+
- A member that reports `gatewayUnavailable: true` while its machine answers
|
|
108
|
+
locally is a hub-side registration problem (missing or rejected gateway for
|
|
109
|
+
that route class), not a machine fault.
|
|
110
|
+
|
|
111
|
+
## Verification
|
|
112
|
+
|
|
113
|
+
- `test/integration/reverse-route-class.test.js` - a reset for one target must not
|
|
114
|
+
mark the route class unavailable, and a following open on a healthy target must
|
|
115
|
+
succeed.
|
|
116
|
+
- `test/unit/client/multiplexed-transport-set.test.js` - a member the hub
|
|
117
|
+
registered down is never *selected* while a ready alternative exists.
|
|
118
|
+
- `test/integration/multiplexed-transport-set.test.js` - drain and lifecycle
|
|
119
|
+
behaviour across members.
|
package/package.json
CHANGED
|
@@ -403,6 +403,17 @@ export class MultiplexedSocketductConnection extends EventEmitter {
|
|
|
403
403
|
return socket
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Exact-gateway members are bound to one configured gateway for one configured
|
|
408
|
+
* target, so there is no per-target rotation to scope a failure to: an
|
|
409
|
+
* unavailable gateway takes this member out of service entirely and the set's
|
|
410
|
+
* member-level fallback owns it.
|
|
411
|
+
* @returns {boolean} Always false for exact-gateway members.
|
|
412
|
+
*/
|
|
413
|
+
isTargetUnavailable() {
|
|
414
|
+
return false
|
|
415
|
+
}
|
|
416
|
+
|
|
406
417
|
/** @returns {{physicalState: string, connectionGeneration: number, virtualStreams: number, streamStates: Record<string, number>, queuedBytes: number, dataQueuedBytes: number, controlQueuedBytes: number, spoolBytes: number, spoolCleanupFailures: number, physicalConnects: number, reconnectAttempts: number, reconnectSuccesses: number, resumeFailures: number}} Privacy-safe status. */
|
|
407
418
|
status() {
|
|
408
419
|
/** @type {Record<string, number>} */
|
|
@@ -59,6 +59,7 @@ const GATEWAY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/
|
|
|
59
59
|
* @property {(options: Record<string, unknown>, callback?: (error: Error | null, socket: import('./virtual-socket.js').VirtualSocket) => void) => import('./virtual-socket.js').VirtualSocket} registerRecoveredConnection - Cold-register durable recovery.
|
|
60
60
|
* @property {(identifier: string) => {identifier: string, target: {host: string, port: number}, sessionName: string, manualReceiveAck: boolean} | undefined} [_inspectRecoveredConnection] - Read-only stable-identifier recovery lookup.
|
|
61
61
|
* @property {() => MemberConnectionStatus} status - Privacy-safe status.
|
|
62
|
+
* @property {(target: {host: string, port: number}) => boolean} isTargetUnavailable - Whether this member last reported that exact target unserviceable.
|
|
62
63
|
* @property {() => Promise<void>} close - Close the member.
|
|
63
64
|
* @property {() => Promise<void>} detach - Preserve durable streams for process-replacement recovery.
|
|
64
65
|
* @property {() => Promise<void>} drain - Let live streams finish before releasing the member.
|
|
@@ -82,6 +83,19 @@ const GATEWAY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/
|
|
|
82
83
|
* @typedef {import('node:tls').TLSSocket & {readonly gatewayId: string | undefined, readonly routeClass: string | undefined}} GatewayTlsSocket
|
|
83
84
|
*/
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Resolves the logical target from either caller option shape.
|
|
88
|
+
* @param {{host?: string, port?: number, target?: {host: string, port: number} | null} | undefined} options - Caller open options.
|
|
89
|
+
* @returns {{host: string, port: number} | undefined} Exact target, when the caller named one.
|
|
90
|
+
*/
|
|
91
|
+
function resolveOpenTarget(options) {
|
|
92
|
+
if (options === undefined) return undefined
|
|
93
|
+
const nested = options.target
|
|
94
|
+
if (nested !== undefined && nested !== null) return {host: nested.host, port: nested.port}
|
|
95
|
+
if (typeof options.host !== "string" || typeof options.port !== "number") return undefined
|
|
96
|
+
return {host: options.host, port: options.port}
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
/** No configured gateway is currently safe for a new virtual stream. */
|
|
86
100
|
export class SocketductTransportSetNotReadyError extends Error {
|
|
87
101
|
/** @param {string} [message] - Privacy-safe detail. */
|
|
@@ -330,6 +344,19 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
330
344
|
return this.#startPromise
|
|
331
345
|
}
|
|
332
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Whether this member most recently reported exactly this target unserviceable.
|
|
349
|
+
* A target-scoped reset only removes the member from rotation for that target,
|
|
350
|
+
* so an unreachable machine cannot cost the set its other machines.
|
|
351
|
+
* @param {TransportMember} member - Candidate member.
|
|
352
|
+
* @param {{host: string, port: number} | undefined} target - Target the caller wants.
|
|
353
|
+
* @returns {boolean} True when the member is marked down for this exact target.
|
|
354
|
+
*/
|
|
355
|
+
#memberMarkedForTarget(member, target) {
|
|
356
|
+
if (target === undefined) return false
|
|
357
|
+
return member.connection.isTargetUnavailable(target)
|
|
358
|
+
}
|
|
359
|
+
|
|
333
360
|
/**
|
|
334
361
|
* Assigns one new virtual socket to the next ready member.
|
|
335
362
|
* @param {import('./multiplexed-connection.js').VirtualConnectionOptions} options - Target/socket options.
|
|
@@ -341,7 +368,7 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
341
368
|
if (this.#closed) throw closedError()
|
|
342
369
|
const socketOptions = normalizeVirtualSocketOptions(options)
|
|
343
370
|
if (this.#startupError !== undefined) return this.#failedSocket(socketOptions, this.#startupError, callback)
|
|
344
|
-
const selection = this.#pendingOpens.length === 0 && this.#startupResolved ? this.#selectReadyMember() : undefined
|
|
371
|
+
const selection = this.#pendingOpens.length === 0 && this.#startupResolved ? this.#selectReadyMember(options) : undefined
|
|
345
372
|
if (selection === undefined) return this.#deferConnection(options, socketOptions, callback)
|
|
346
373
|
try {
|
|
347
374
|
/** @type {((error: Error | null, socket: import('./virtual-socket.js').VirtualSocket) => void) | undefined} */
|
|
@@ -581,11 +608,13 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
581
608
|
* not recently reported unavailable. When every ready member carries a
|
|
582
609
|
* gateway-unavailable mark, any ready member may still be selected so a stale
|
|
583
610
|
* mark can never wedge the set.
|
|
611
|
+
* @param {{host?: string, port?: number, target?: {host: string, port: number} | null} | undefined} options - Caller open options, used for the exact target.
|
|
584
612
|
* @returns {{member: TransportMember, previousCursor: number} | undefined}
|
|
585
613
|
*/
|
|
586
|
-
#selectReadyMember() {
|
|
614
|
+
#selectReadyMember(options) {
|
|
587
615
|
if (this.#closed) throw closedError()
|
|
588
616
|
const previousCursor = this.#cursor
|
|
617
|
+
const target = resolveOpenTarget(options)
|
|
589
618
|
/** @type {{member: TransportMember, index: number} | undefined} */
|
|
590
619
|
let fallback
|
|
591
620
|
for (let offset = 0; offset < this.#members.length; offset += 1) {
|
|
@@ -593,7 +622,7 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
593
622
|
const member = /** @type {TransportMember} */ (this.#members[index])
|
|
594
623
|
const status = member.connection.status()
|
|
595
624
|
if (status.physicalState !== "ready") continue
|
|
596
|
-
if (status.gatewayUnavailable === true) {
|
|
625
|
+
if (status.gatewayUnavailable === true || this.#memberMarkedForTarget(member, target)) {
|
|
597
626
|
fallback ??= {member, index}
|
|
598
627
|
continue
|
|
599
628
|
}
|
|
@@ -668,7 +697,8 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
668
697
|
#drainPendingOpens() {
|
|
669
698
|
if (!this.#startupResolved || this.#closed || this.#startupError !== undefined) return
|
|
670
699
|
while (this.#pendingOpens.length > 0) {
|
|
671
|
-
const
|
|
700
|
+
const nextPending = this.#pendingOpens[0]
|
|
701
|
+
const selection = nextPending === undefined ? undefined : this.#selectReadyMember(nextPending.options)
|
|
672
702
|
if (selection === undefined) return
|
|
673
703
|
const pending = this.#pendingOpens.shift()
|
|
674
704
|
if (pending === undefined) return
|
package/src/reverse/client.js
CHANGED
|
@@ -32,6 +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 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
|
+
}
|
|
35
45
|
|
|
36
46
|
/**
|
|
37
47
|
* @typedef {object} ReverseGatewayConnectionOptions
|
|
@@ -169,6 +179,14 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
169
179
|
/** @type {number} */ #spoolCleanupFailures = 0
|
|
170
180
|
/** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
|
|
171
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()
|
|
172
190
|
/** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */ #disconnectAttribution
|
|
173
191
|
|
|
174
192
|
/** @param {ReverseGatewayConnectionOptions} options - Hub route and safety policy. */
|
|
@@ -386,6 +404,34 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
386
404
|
return this.#gatewayUnavailableSince !== undefined
|
|
387
405
|
}
|
|
388
406
|
|
|
407
|
+
/**
|
|
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.
|
|
413
|
+
*/
|
|
414
|
+
isTargetUnavailable(target) {
|
|
415
|
+
return this.#targetUnavailableSince.has(targetKey(target))
|
|
416
|
+
}
|
|
417
|
+
|
|
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)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
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))
|
|
433
|
+
}
|
|
434
|
+
|
|
389
435
|
status() {
|
|
390
436
|
/** @type {Record<string, number>} */
|
|
391
437
|
const streamStates = {}
|
|
@@ -785,6 +831,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
785
831
|
stream.sendWindow = Math.min(stream.sendWindow, detail.initialWindow)
|
|
786
832
|
stream.state = stream.localEnded ? "half-closed" : "open"
|
|
787
833
|
stream.opened = true
|
|
834
|
+
this.#clearTargetUnavailable(stream.target)
|
|
788
835
|
if (this.#gatewayUnavailableSince !== undefined) {
|
|
789
836
|
this.#gatewayUnavailableSince = undefined
|
|
790
837
|
logDiagnostic(this.#options.logger, "reverse_client.gateway_available", {
|
|
@@ -853,6 +900,13 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
853
900
|
// selected, so no open can succeed, so no mark can ever be cleared.
|
|
854
901
|
// Route-class availability is owned by the hub's GATEWAY_SELECTED response,
|
|
855
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)
|
|
856
910
|
const error = attachDisconnectAttribution(new SocketductStreamError("Reverse gateway stream was reset", code), validHubAttribution(detail) ?? (code === "GATEWAY_DISCONNECTED" ? this.#disconnectAttribution : undefined))
|
|
857
911
|
stream.socket._receiveError(error)
|
|
858
912
|
return
|
|
@@ -539,6 +539,12 @@ describe("reverse gateway route-class", () => {
|
|
|
539
539
|
// stall every later open in the set until the caller's own deadline.
|
|
540
540
|
expect({value: client.status().gatewayUnavailable, context: "a target-scoped reset must not mark the route class unavailable"}).toMatchObject({value: false})
|
|
541
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
|
+
|
|
542
548
|
// A healthy target on the same route class still opens and exchanges data.
|
|
543
549
|
const healthyStream = client.createConnection({host: targetA.host, port: targetA.port})
|
|
544
550
|
await onceEvent(healthyStream, "connect")
|
|
@@ -72,6 +72,7 @@ class FakeMember extends EventEmitter {
|
|
|
72
72
|
this.generation = 0
|
|
73
73
|
this.physicalConnects = 0
|
|
74
74
|
this.gatewayUnavailable = false
|
|
75
|
+
/** @type {Set<string>} */ this.unavailableTargets = new Set()
|
|
75
76
|
/** @type {Promise<void>} */
|
|
76
77
|
this.connectPromise = new Promise((resolve, reject) => {
|
|
77
78
|
this.resolveConnect = resolve
|
|
@@ -133,6 +134,11 @@ class FakeMember extends EventEmitter {
|
|
|
133
134
|
return this.recoveryDescriptors.get(identifier)
|
|
134
135
|
}
|
|
135
136
|
|
|
137
|
+
/** @param {{host: string, port: number}} target - Logical target. */
|
|
138
|
+
isTargetUnavailable(target) {
|
|
139
|
+
return this.unavailableTargets.has(`${target.host}:${target.port}`)
|
|
140
|
+
}
|
|
141
|
+
|
|
136
142
|
status() {
|
|
137
143
|
return {
|
|
138
144
|
physicalState: this.state,
|
|
@@ -558,6 +564,21 @@ describe("multiplexed transport set", () => {
|
|
|
558
564
|
await set.close()
|
|
559
565
|
})
|
|
560
566
|
|
|
567
|
+
it("a member marked down for one target keeps serving every other target", async () => {
|
|
568
|
+
const {set, members} = fixture()
|
|
569
|
+
const started = set.start()
|
|
570
|
+
members.forEach((member) => member.ready())
|
|
571
|
+
await started
|
|
572
|
+
at(members, 0).unavailableTargets.add("broken.internal:443")
|
|
573
|
+
for (let offset = 0; offset < 6; offset += 1) {
|
|
574
|
+
const socket = set.createConnection({host: "broken.internal", port: 443})
|
|
575
|
+
expect(gatewayOf(socket) === "starlink").toBe(false)
|
|
576
|
+
}
|
|
577
|
+
const healthy = set.createConnection({host: "healthy.internal", port: 443})
|
|
578
|
+
expect(gatewayOf(healthy)).toBe("starlink")
|
|
579
|
+
await set.close()
|
|
580
|
+
})
|
|
581
|
+
|
|
561
582
|
it("a stale gateway-unavailable mark cannot wedge the set when every ready member is marked", async () => {
|
|
562
583
|
const {set, members} = fixture()
|
|
563
584
|
const started = set.start()
|