socketduct 0.1.33 → 0.1.35

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 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 @@
1
+ The reverse hub's periodic status now reports rejected opens broken down by reset code, and clients log each relay reset as a `virtual_stream.reset` diagnostic event, so gateway outages can be told apart from authorization or routing rejections without new instrumentation.
@@ -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 "$@"
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socketduct",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "Resilient authenticated tunnel/proxy for keeping socket streams alive across flaky links.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -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>} */
@@ -948,6 +959,7 @@ export class MultiplexedSocketductConnection extends EventEmitter {
948
959
  const code = typeof detail.code === "string" ? detail.code : "STREAM_RESET"
949
960
  stream.remoteClosed = true
950
961
  stream.state = "reset"
962
+ logDiagnostic(this.#options.logger, "virtual_stream.reset", {streamId: stream.id, errorCode: code})
951
963
  const lost = code.startsWith("RESUME_") || code === "SESSION_LOST" || code.includes("UNCERTAIN")
952
964
  stream.socket._receiveError(lost
953
965
  ? new SocketductSessionLostError("Virtual stream could not be resumed", code)
@@ -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 selection = this.#selectReadyMember()
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
@@ -27,6 +27,7 @@ const COMPRESSION = new Set(["auto", "off", "zstd", "none"])
27
27
  const STATES = new Set(["configured", "connecting", "registering", "ready", "reconnecting", "disconnected", "closed", "starting", "degraded", "failed"])
28
28
  const SAFE_ERROR_CODES = new Set([
29
29
  "AUTH_FAILED",
30
+ "AMBIGUOUS_ROUTE",
30
31
  "AUTH_NEGOTIATION_FAILED",
31
32
  "AUTH_OWNERSHIP_MISMATCH",
32
33
  "AUTH_REQUIRED",
@@ -51,6 +52,7 @@ const SAFE_ERROR_CODES = new Set([
51
52
  "HANDSHAKE_TIMEOUT",
52
53
  "HUB_SHUTDOWN",
53
54
  "INPUT_LIMIT_EXCEEDED",
55
+ "INVALID_ROUTE_RESOLUTION",
54
56
  "INVALID_STREAM_OPEN",
55
57
  "PROTOCOL_NEGOTIATION_FAILED",
56
58
  "PROTOCOL_VIOLATION",
@@ -77,6 +79,7 @@ const SAFE_ERROR_CODES = new Set([
77
79
  "TARGET_OPEN_TIMEOUT",
78
80
  "TARGET_READ_FAILED",
79
81
  "TARGET_UNAUTHORIZED",
82
+ "UNKNOWN_GATEWAY_ID",
80
83
  "TLS_ERROR",
81
84
  "TRANSPORT_CLOSED",
82
85
  "UNKNOWN"
@@ -156,7 +159,7 @@ const NUMBER_FIELDS = new Set([
156
159
  "virtualStreams",
157
160
  "wireDataBytes"
158
161
  ])
159
- const RUNTIME_STATUS_FIELDS = new Set([...NUMBER_FIELDS, "compression", "physicalState"])
162
+ const RUNTIME_STATUS_FIELDS = new Set([...NUMBER_FIELDS, "compression", "physicalState", "rejectedOpensByCode"])
160
163
  const EVENT_FIELDS = new Map([
161
164
  ["process.started", new Set(["runtimeMode", "transportSecurity", "compression", "compressionLevel", "frameMaxBytes", "flushIntervalMs", "criticalFlushIntervalMs", "maxSegmentBytes", "retentionDays", "retentionBytes", "maxQueueBytes", "criticalReserveBytes"])],
162
165
  ["process.stopped", new Set(["runtimeMode"])],
@@ -174,6 +177,7 @@ const EVENT_FIELDS = new Map([
174
177
  ["transport_set.terminal_failure", new Set(["configuredMembers", "closedMembers", "errorCode"])],
175
178
  ["virtual_stream.opening", new Set(["streamId", "generation", "gatewayId", "recovered"])],
176
179
  ["virtual_stream.opened", new Set(["streamId", "generation", "gatewayId", "recovered"])],
180
+ ["virtual_stream.reset", new Set(["streamId", "errorCode"])],
177
181
  ["virtual_stream.closed", new Set(["streamId", "generation", "gatewayId"])],
178
182
  ["virtual_stream.error", new Set(["streamId", "generation", "gatewayId", "routeClass", "errorCode", "disconnectIncidentId", "disconnectReason", "incidentOrigin", "physicalRole", "clientConnectionGeneration", "gatewayConnectionGeneration", "fanOutCount", "resumeSuccessCount", "terminalStreamCount"])],
179
183
  ["reverse_gateway.target_opened", new Set(["streamId", "generation", "gatewayId"])],
@@ -249,7 +253,7 @@ export function formatDiagnosticLogLine(options) {
249
253
  const base = {timestamp: timestamp.toISOString(), event: options.event, processId, role: options.role, ...envelope}
250
254
  let line = formatLogLine(base, safeFields)
251
255
  if (Buffer.byteLength(line) > MAX_JSONL_BYTES && safeFields.errorCode !== undefined) {
252
- /** @type {Record<string, string | number | boolean>} */
256
+ /** @type {Record<string, string | number | boolean | Record<string, number>>} */
253
257
  const boundedFields = {...safeFields}
254
258
  delete boundedFields.errorStack
255
259
  if (typeof boundedFields.errorMessage === "string") {
@@ -595,12 +599,12 @@ export function errorDiagnostics(error, fallback = "UNKNOWN") {
595
599
  /**
596
600
  * @param {string} event - Event name.
597
601
  * @param {Record<string, unknown>} fields - Candidate fields.
598
- * @returns {Record<string, string | number | boolean> | undefined} Allowlisted fields, or undefined for an unknown event.
602
+ * @returns {Record<string, string | number | boolean | Record<string, number>> | undefined} Allowlisted fields, or undefined for an unknown event.
599
603
  */
600
604
  function sanitizeFields(event, fields) {
601
605
  const allowed = EVENT_FIELDS.get(event)
602
606
  if (allowed === undefined || typeof fields !== "object" || fields === null || Array.isArray(fields)) return undefined
603
- /** @type {Record<string, string | number | boolean>} */
607
+ /** @type {Record<string, string | number | boolean | Record<string, number>>} */
604
608
  const safe = {}
605
609
  for (const field of allowed) {
606
610
  const value = fields[field]
@@ -656,6 +660,22 @@ function sanitizeFields(event, fields) {
656
660
  const errorCode = normalizeErrorCode(value)
657
661
  safe.errorCode = errorCode
658
662
  safe.errorClass = classifyError(errorCode)
663
+ } else if (field === "rejectedOpensByCode" && typeof value === "object" && value !== null && !Array.isArray(value)) {
664
+ const entries = Object.entries(value)
665
+ if (entries.length <= 16) {
666
+ /** @type {Record<string, number>} */
667
+ const bounded = {}
668
+ let valid = true
669
+
670
+ for (const [code, count] of entries) {
671
+ if (!SAFE_ERROR_CODES.has(code) || typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) {
672
+ valid = false
673
+ break
674
+ }
675
+ bounded[code] = count
676
+ }
677
+ if (valid) safe.rejectedOpensByCode = bounded
678
+ }
659
679
  }
660
680
  }
661
681
  const errorDetails = Object.getOwnPropertyDescriptor(fields, ERROR_DETAILS)?.value
@@ -671,7 +691,7 @@ function sanitizeFields(event, fields) {
671
691
 
672
692
  /**
673
693
  * @param {{event: string, processId: number, role: string, timestamp: string}} base - Required JSONL fields.
674
- * @param {Record<string, string | number | boolean>} fields - Sanitized event fields.
694
+ * @param {Record<string, string | number | boolean | Record<string, number>>} fields - Sanitized event fields.
675
695
  * @returns {string} One JSONL record.
676
696
  */
677
697
  function formatLogLine(base, fields) {
@@ -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
@@ -175,6 +175,8 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
175
175
  let gatewayRegistrations = 0
176
176
  let rejectedRegistrations = 0
177
177
  let rejectedOpens = 0
178
+ /** @type {Map<string, number>} */
179
+ const rejectedOpensByCode = new Map()
178
180
  let rawDataBytes = 0
179
181
  let wireDataBytes = 0
180
182
  let closing = false
@@ -707,6 +709,12 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
707
709
  }
708
710
  }
709
711
 
712
+ /** @param {string} code - V3 reset code sent to the rejecting client. */
713
+ function countRejectedOpen(code) {
714
+ rejectedOpens += 1
715
+ rejectedOpensByCode.set(code, (rejectedOpensByCode.get(code) || 0) + 1)
716
+ }
717
+
710
718
  /** @param {Record<string, unknown>} value - Resume manifest entry. */
711
719
  function parseResumeItem(value) {
712
720
  const item = /** @type {{streamId?: unknown, identifier?: unknown, clientToGatewaySequence?: unknown, clientToGatewayAck?: unknown, gatewayToClientSequence?: unknown, gatewayToClientAck?: unknown, localEnded?: unknown, remoteEnded?: unknown}} */ (value)
@@ -734,17 +742,17 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
734
742
  }
735
743
  const candidates = options.resolveOpenGateways?.({principal: client.principal, routeClass: client.routeClass, target})
736
744
  if (!Array.isArray(candidates) || candidates.some((candidate) => typeof candidate !== "string" || !GATEWAY_ID.test(candidate))) {
737
- rejectedOpens += 1
745
+ countRejectedOpen("INVALID_ROUTE_RESOLUTION")
738
746
  await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "INVALID_ROUTE_RESOLUTION"})
739
747
  return
740
748
  }
741
749
  if (candidates.length === 0) {
742
- rejectedOpens += 1
750
+ countRejectedOpen("TARGET_UNAUTHORIZED")
743
751
  await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "TARGET_UNAUTHORIZED"})
744
752
  return
745
753
  }
746
754
  if (candidates.length !== 1 || new Set(candidates).size !== 1) {
747
- rejectedOpens += 1
755
+ countRejectedOpen("AMBIGUOUS_ROUTE")
748
756
  await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "AMBIGUOUS_ROUTE"})
749
757
  return
750
758
  }
@@ -757,15 +765,15 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
757
765
  gatewayId = request.gatewayId
758
766
  }
759
767
  if (options.authorizeOpen({principal: client.principal, gatewayId, target}) !== true) {
760
- rejectedOpens += 1
768
+ countRejectedOpen("TARGET_UNAUTHORIZED")
761
769
  await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "TARGET_UNAUTHORIZED"})
762
770
  return
763
771
  }
764
772
  const gatewayOwner = gateways.get(gatewayId)
765
773
  const gateway = gatewayOwner?.physical
766
774
  if (gatewayOwner === undefined || gateway === undefined || gateway.socket.destroyed || gateway.closing || gateway.resumeRequired || gateway.role !== "gateway" || gateway.gatewayId !== gatewayId || gatewayOwner.gatewayId !== gatewayId || gatewayOwner.userId !== gateway.principal?.userId) {
767
- rejectedOpens += 1
768
775
  const known = gatewayOwner !== undefined || options.knownGatewayIds?.includes(gatewayId) === true
776
+ countRejectedOpen(known ? "GATEWAY_UNAVAILABLE" : "UNKNOWN_GATEWAY_ID")
769
777
  await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: known ? "GATEWAY_UNAVAILABLE" : "UNKNOWN_GATEWAY_ID"})
770
778
  return
771
779
  }
@@ -1222,6 +1230,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
1222
1230
  gatewayRegistrations,
1223
1231
  rejectedRegistrations,
1224
1232
  rejectedOpens,
1233
+ rejectedOpensByCode: Object.fromEntries(rejectedOpensByCode),
1225
1234
  virtualStreams: bridges,
1226
1235
  rawDataBytes,
1227
1236
  wireDataBytes,
@@ -80,8 +80,11 @@ describe("multiplexed failures", () => {
80
80
  it("authentication and exact-target authorization fail closed", async () => withTestCleanup(async (after) => {
81
81
  const relay = await startMultiplexedTcpRelay({tokens: tokens(), authorizeTarget: () => false})
82
82
  after(() => relay.close())
83
+ /** @type {Array<{event: string} & Record<string, unknown>>} */
84
+ const lifecycle = []
83
85
  const unauthorizedConnection = new MultiplexedSocketductConnection({
84
- relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}
86
+ relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"},
87
+ logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => lifecycle.push({event, ...fields})}
85
88
  })
86
89
  after(() => unauthorizedConnection.close())
87
90
  const denied = unauthorizedConnection.createConnection({host: "127.0.0.1", port: 9})
@@ -89,6 +92,7 @@ describe("multiplexed failures", () => {
89
92
  expect(deniedError instanceof SocketductStreamError).toBeTruthy()
90
93
  expect(deniedError.code).toBe("TARGET_UNAUTHORIZED")
91
94
  expect(relay.status().activeTargets).toBe(0)
95
+ expect({value: lifecycle.find((entry) => entry.event === "virtual_stream.reset")?.errorCode, context: "the client logs the relay reset code"}).toMatchObject({value: "TARGET_UNAUTHORIZED"})
92
96
 
93
97
  const invalidConnection = new MultiplexedSocketductConnection({
94
98
  relay: {host: "127.0.0.1", port: relay.port, token: "wrong-token"},
@@ -533,12 +533,19 @@ describe("reverse gateway route-class", () => {
533
533
  const reset = await nextError(unavailableStream)
534
534
  expect(reset.code).toBe("GATEWAY_UNAVAILABLE")
535
535
  unavailableStream.destroy()
536
+ expect({value: hub.status().rejectedOpensByCode?.GATEWAY_UNAVAILABLE, context: "the hub status counts the rejected open by its reset code"}).toMatchObject({value: 1})
536
537
 
537
538
  // The route class is healthy, so the reset must not take it out of service.
538
539
  // A member marked unavailable this way is never selected again, which would
539
540
  // stall every later open in the set until the caller's own deadline.
540
541
  expect({value: client.status().gatewayUnavailable, context: "a target-scoped reset must not mark the route class unavailable"}).toMatchObject({value: false})
541
542
 
543
+ // The member must still be skipped for the target the hub could not serve,
544
+ // otherwise every later open for it keeps paying a full round trip, while
545
+ // every other target on this member stays in rotation.
546
+ expect({value: client.isTargetUnavailable({host: targetB.host, port: targetB.port}), context: "the unserviceable target is marked on this member"}).toMatchObject({value: true})
547
+ expect({value: client.isTargetUnavailable({host: targetA.host, port: targetA.port}), context: "a different target stays available on the same member"}).toMatchObject({value: false})
548
+
542
549
  // A healthy target on the same route class still opens and exchanges data.
543
550
  const healthyStream = client.createConnection({host: targetA.host, port: targetA.port})
544
551
  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()
@@ -468,6 +468,17 @@ describe("structured logger", {timeoutMs: 15000}, () => {
468
468
  }
469
469
  })
470
470
 
471
+ it("sanitizes the rejected-opens-by-code histogram", () => {
472
+ /** @type {string[]} */
473
+ const lines = []
474
+ const logger = createStructuredLogger({role: "relay", writeLine: (line) => lines.push(line)})
475
+ logger.log("runtime.status", {rejectedOpensByCode: {GATEWAY_UNAVAILABLE: 3, UNKNOWN_GATEWAY_ID: 1}})
476
+ expect(JSON.parse(lines[0] ?? "{}").rejectedOpensByCode).toEqual({GATEWAY_UNAVAILABLE: 3, UNKNOWN_GATEWAY_ID: 1})
477
+
478
+ logger.log("runtime.status", {rejectedOpensByCode: {GATEWAY_UNAVAILABLE: 3, PRIVATE_TOKEN: 2}})
479
+ expect(JSON.parse(lines[1] ?? "{}").rejectedOpensByCode).toBeUndefined()
480
+ })
481
+
471
482
  it("oversized sanitized records fall back to a bounded envelope", () => {
472
483
  /** @type {string[]} */
473
484
  const lines = []