socketduct 0.1.18 → 0.1.20

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.
Files changed (55) hide show
  1. package/.env.example +17 -5
  2. package/README.md +5 -5
  3. package/changelog.d/20260925143000-reverse-client-process-recovery.md +1 -0
  4. package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
  5. package/docs/architecture.md +11 -9
  6. package/docs/docker.md +5 -4
  7. package/docs/logging.md +131 -159
  8. package/docs/protocol.md +43 -7
  9. package/docs/reverse-gateway.md +75 -9
  10. package/docs/running.md +16 -13
  11. package/docs/security.md +16 -5
  12. package/docs/session-lifecycle.md +14 -3
  13. package/docs/socket-api.md +54 -20
  14. package/package.json +5 -1
  15. package/scripts/verify-zstd-interoperability.mjs +41 -0
  16. package/src/cli/socketduct.js +143 -0
  17. package/src/client/multiplexed-transport-set.js +12 -5
  18. package/src/client/virtual-socket.js +52 -0
  19. package/src/config/environment.js +56 -7
  20. package/src/index.js +64 -26
  21. package/src/logging/compressed-log-reader.js +256 -0
  22. package/src/logging/compressed-log-worker.js +430 -0
  23. package/src/logging/compressed-log-writer.js +538 -0
  24. package/src/logging/log-batch.js +72 -0
  25. package/src/logging/log-retention.js +241 -0
  26. package/src/logging/log-segment-index.js +176 -0
  27. package/src/logging/structured-logger.js +136 -30
  28. package/src/logging/zstd-frame-codec.js +73 -0
  29. package/src/reverse/client.js +372 -82
  30. package/src/reverse/disconnect-attribution.js +191 -0
  31. package/src/reverse/gateway.js +68 -18
  32. package/src/reverse/hub.js +215 -32
  33. package/src/reverse/runtime.js +5 -3
  34. package/src/spool/spool-file.js +136 -7
  35. package/tensorbuzz.yml +4 -2
  36. package/test/e2e/logger-shutdown-process.test.js +86 -0
  37. package/test/e2e/reverse-client-recovery-process.test.js +170 -0
  38. package/test/e2e/reverse-gateway-process.test.js +15 -14
  39. package/test/fixtures/reverse-client-recovery.mjs +123 -0
  40. package/test/integration/logging-cli.test.js +168 -0
  41. package/test/integration/logging-compressed-recovery.test.js +186 -0
  42. package/test/integration/reverse-gateway.test.js +597 -1
  43. package/test/integration/reverse-route-class.test.js +6 -1
  44. package/test/unit/client/multiplexed-transport-set.test.js +12 -1
  45. package/test/unit/config/docker-gateway-files.test.js +2 -0
  46. package/test/unit/config/environment.test.js +54 -14
  47. package/test/unit/index.test.js +3 -0
  48. package/test/unit/logging/compressed-log-worker.test.js +29 -0
  49. package/test/unit/logging/compressed-log-writer.test.js +183 -0
  50. package/test/unit/logging/log-retention.test.js +86 -0
  51. package/test/unit/logging/log-segment-index.test.js +104 -0
  52. package/test/unit/logging/structured-logger.test.js +14 -1
  53. package/test/unit/logging/zstd-frame-codec.test.js +24 -0
  54. package/test/unit/reverse/disconnect-attribution.test.js +82 -0
  55. package/test/unit/spool/spool-file.test.js +133 -0
@@ -0,0 +1,191 @@
1
+ /* eslint-disable jsdoc/require-param-type, jsdoc/require-returns, jsdoc/require-returns-description */
2
+ import {createHmac, randomBytes} from "node:crypto"
3
+
4
+ const INCIDENT_ID = /^[A-Za-z0-9_-]{22}$/
5
+ const FINGERPRINT_KEY_EPOCH = /^[A-Za-z0-9_-]{1,32}$/
6
+ const SAFE_CONTROL_CODE = /^[A-Z][A-Z0-9_]{0,63}$/
7
+ const DISCONNECT_REASONS = new Set([
8
+ "remote_socket_error",
9
+ "remote_eof",
10
+ "peer_goaway",
11
+ "protocol_violation",
12
+ "output_backpressure_limit",
13
+ "heartbeat_timeout",
14
+ "gateway_replaced",
15
+ "gateway_shutdown",
16
+ "hub_shutdown",
17
+ "local_close",
18
+ "send_after_transport_loss",
19
+ "unknown_transport_close"
20
+ ])
21
+ const INCIDENT_ORIGINS = new Set(["reverse_hub", "reverse_client", "reverse_gateway"])
22
+ const PHYSICAL_ROLES = new Set(["client", "gateway"])
23
+ const RESERVED_CONTROL_FIELDS = new Set([
24
+ "disconnectIncidentId",
25
+ "disconnectReason",
26
+ "incidentOrigin",
27
+ "physicalRole",
28
+ "clientConnectionGeneration",
29
+ "gatewayConnectionGeneration",
30
+ "fanOutCount",
31
+ "resumeSuccessCount",
32
+ "terminalStreamCount",
33
+ "connectionFingerprint",
34
+ "fingerprintKeyEpoch"
35
+ ])
36
+
37
+ /** @typedef {'remote_socket_error' | 'remote_eof' | 'peer_goaway' | 'protocol_violation' | 'output_backpressure_limit' | 'heartbeat_timeout' | 'gateway_replaced' | 'gateway_shutdown' | 'hub_shutdown' | 'local_close' | 'send_after_transport_loss' | 'unknown_transport_close'} DisconnectReason */
38
+
39
+ /**
40
+ * @typedef {object} DisconnectAttribution
41
+ * @property {string} disconnectIncidentId - Random 128-bit incident identity.
42
+ * @property {DisconnectReason} disconnectReason - First physical cause.
43
+ * @property {'reverse_hub' | 'reverse_client' | 'reverse_gateway'} incidentOrigin - Component that observed the cause.
44
+ * @property {'client' | 'gateway'} physicalRole - Physical endpoint role where the cause originated.
45
+ * @property {number} [clientConnectionGeneration] - Named client physical generation.
46
+ * @property {number} [gatewayConnectionGeneration] - Named gateway physical generation.
47
+ * @property {number} fanOutCount - Logical streams affected by the incident.
48
+ * @property {number} resumeSuccessCount - Logical streams resumed so far.
49
+ * @property {number} terminalStreamCount - Logical streams made terminal so far.
50
+ */
51
+
52
+ /**
53
+ * Creates one validated disconnect-attribution value.
54
+ * @param fields
55
+ */
56
+ export function createDisconnectAttribution(/** @type {Record<string, unknown>} */ fields) {
57
+ const value = {
58
+ disconnectIncidentId: fields.disconnectIncidentId ?? randomBytes(16).toString("base64url"),
59
+ disconnectReason: fields.disconnectReason,
60
+ incidentOrigin: fields.incidentOrigin,
61
+ physicalRole: fields.physicalRole,
62
+ ...(fields.clientConnectionGeneration === undefined ? {} : {clientConnectionGeneration: fields.clientConnectionGeneration}),
63
+ ...(fields.gatewayConnectionGeneration === undefined ? {} : {gatewayConnectionGeneration: fields.gatewayConnectionGeneration}),
64
+ fanOutCount: fields.fanOutCount ?? 0,
65
+ resumeSuccessCount: fields.resumeSuccessCount ?? 0,
66
+ terminalStreamCount: fields.terminalStreamCount ?? 0
67
+ }
68
+ const parsed = parseDisconnectAttribution(value)
69
+ if (parsed === undefined) throw new TypeError("Invalid disconnect attribution")
70
+ return parsed
71
+ }
72
+
73
+ /**
74
+ * Returns a closed, validated attribution value or undefined.
75
+ * @param value
76
+ */
77
+ export function parseDisconnectAttribution(/** @type {unknown} */ value) {
78
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
79
+ const candidate = /** @type {Record<string, unknown>} */ (value)
80
+ if (typeof candidate.disconnectIncidentId !== "string" || !INCIDENT_ID.test(candidate.disconnectIncidentId)) return undefined
81
+ if (typeof candidate.disconnectReason !== "string" || !DISCONNECT_REASONS.has(candidate.disconnectReason)) return undefined
82
+ if (typeof candidate.incidentOrigin !== "string" || !INCIDENT_ORIGINS.has(candidate.incidentOrigin)) return undefined
83
+ if (typeof candidate.physicalRole !== "string" || !PHYSICAL_ROLES.has(candidate.physicalRole)) return undefined
84
+ if (!validOptionalGeneration(candidate.clientConnectionGeneration) || !validOptionalGeneration(candidate.gatewayConnectionGeneration)) return undefined
85
+ if (!validCount(candidate.fanOutCount) || !validCount(candidate.resumeSuccessCount) || !validCount(candidate.terminalStreamCount)) return undefined
86
+ if (candidate.physicalRole === "client" && candidate.clientConnectionGeneration === undefined) return undefined
87
+ if (candidate.physicalRole === "gateway" && candidate.gatewayConnectionGeneration === undefined) return undefined
88
+ return /** @type {DisconnectAttribution} */ ({
89
+ disconnectIncidentId: candidate.disconnectIncidentId,
90
+ disconnectReason: /** @type {DisconnectReason} */ (candidate.disconnectReason),
91
+ incidentOrigin: /** @type {'reverse_hub' | 'reverse_client' | 'reverse_gateway'} */ (candidate.incidentOrigin),
92
+ physicalRole: /** @type {'client' | 'gateway'} */ (candidate.physicalRole),
93
+ ...(candidate.clientConnectionGeneration === undefined ? {} : {clientConnectionGeneration: candidate.clientConnectionGeneration}),
94
+ ...(candidate.gatewayConnectionGeneration === undefined ? {} : {gatewayConnectionGeneration: candidate.gatewayConnectionGeneration}),
95
+ fanOutCount: candidate.fanOutCount,
96
+ resumeSuccessCount: candidate.resumeSuccessCount,
97
+ terminalStreamCount: candidate.terminalStreamCount
98
+ })
99
+ }
100
+
101
+ /**
102
+ * Copies only validated disconnect fields onto a public typed error.
103
+ * @param error
104
+ * @param value
105
+ */
106
+ export function attachDisconnectAttribution(/** @type {Error} */ error, /** @type {unknown} */ value) {
107
+ const attribution = parseDisconnectAttribution(value)
108
+ if (attribution !== undefined) Object.assign(error, attribution)
109
+ return error
110
+ }
111
+
112
+ /**
113
+ * Removes fields a peer is never authorized to assert.
114
+ * @param value
115
+ */
116
+ export function sanitizePeerControlDetail(/** @type {unknown} */ value) {
117
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {detail: {}, reservedFieldsStripped: 0}
118
+ const candidate = /** @type {Record<string, unknown>} */ (value)
119
+ let reservedFieldsStripped = 0
120
+ for (const field of RESERVED_CONTROL_FIELDS) if (Object.hasOwn(candidate, field)) reservedFieldsStripped += 1
121
+ return {
122
+ detail: typeof candidate.code === "string" && SAFE_CONTROL_CODE.test(candidate.code) ? {code: candidate.code} : {},
123
+ reservedFieldsStripped
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Computes the plan-defined hub-only, role-separated 128-bit fingerprint.
129
+ * @param epochKey
130
+ * @param physicalRole
131
+ * @param connectionId
132
+ */
133
+ export function connectionFingerprint(/** @type {Buffer | Uint8Array} */ epochKey, /** @type {'client' | 'gateway'} */ physicalRole, /** @type {string} */ connectionId) {
134
+ if (!(epochKey instanceof Uint8Array) || epochKey.byteLength < 32) throw new TypeError("connection fingerprint key must contain at least 32 bytes")
135
+ if (!PHYSICAL_ROLES.has(physicalRole)) throw new TypeError("physicalRole must be client or gateway")
136
+ if (typeof connectionId !== "string" || connectionId.length === 0) throw new TypeError("connectionId must be non-empty")
137
+ return createHmac("sha256", epochKey)
138
+ .update("socketduct-connection-id:v1")
139
+ .update(Buffer.from([0]))
140
+ .update(physicalRole)
141
+ .update(Buffer.from([0]))
142
+ .update(connectionId)
143
+ .digest()
144
+ .subarray(0, 16)
145
+ .toString("hex")
146
+ }
147
+
148
+ /**
149
+ * Validates the public epoch label used beside keyed fingerprints.
150
+ * @param value
151
+ */
152
+ export function validateFingerprintKeyEpoch(/** @type {unknown} */ value) {
153
+ if (typeof value !== "string" || !FINGERPRINT_KEY_EPOCH.test(value)) throw new TypeError("fingerprintKeyEpoch must be 1-32 URL-safe characters")
154
+ return value
155
+ }
156
+
157
+ /** Maps a terminal classification to its first-cause enum when locally initiated. */
158
+ /**
159
+ * @param code
160
+ * @param fallback
161
+ * @returns {DisconnectReason}
162
+ */
163
+ export function disconnectReasonForCode(/** @type {unknown} */ code, /** @type {DisconnectReason} */ fallback = "unknown_transport_close") {
164
+ if (code === "OUTPUT_BACKPRESSURE_LIMIT") return "output_backpressure_limit"
165
+ if (code === "HEARTBEAT_TIMEOUT" || code === "HANDSHAKE_TIMEOUT" || code === "RESUME_WINDOW_EXPIRED" || code === "SOCKETDUCT_AUTH_TIMEOUT") return "heartbeat_timeout"
166
+ if (code === "GATEWAY_REPLACED" || code === "CONNECTION_REPLACED") return "gateway_replaced"
167
+ if (code === "GATEWAY_SHUTDOWN") return "gateway_shutdown"
168
+ if (code === "HUB_SHUTDOWN") return "hub_shutdown"
169
+ if (typeof code === "string" && (code.includes("PROTOCOL") || code === "STALE_GENERATION" || code === "UNSUPPORTED_FRAME")) return "protocol_violation"
170
+ return fallback
171
+ }
172
+
173
+ /**
174
+ * Returns the safe socket error code for logs without retaining arbitrary text.
175
+ * @param error
176
+ */
177
+ export function safeSocketCode(/** @type {unknown} */ error) {
178
+ if (error === null || typeof error !== "object") return undefined
179
+ const code = /** @type {{code?: unknown}} */ (error).code
180
+ return typeof code === "string" && /^(?:E[A-Z0-9_]{1,31}|ERR_(?:TLS|SSL)_[A-Z0-9_]{1,48})$/.test(code) ? code : undefined
181
+ }
182
+
183
+ /** @param {unknown} value - Candidate generation. */
184
+ function validOptionalGeneration(value) {
185
+ return value === undefined || (Number.isSafeInteger(value) && /** @type {number} */ (value) >= 0)
186
+ }
187
+
188
+ /** @param {unknown} value - Candidate count. */
189
+ function validCount(value) {
190
+ return Number.isSafeInteger(value) && /** @type {number} */ (value) >= 0
191
+ }
@@ -25,6 +25,7 @@ import {
25
25
  STABLE_IDENTIFIER,
26
26
  writeSocket
27
27
  } from "./common.js"
28
+ import {attachDisconnectAttribution, createDisconnectAttribution, parseDisconnectAttribution, safeSocketCode} from "./disconnect-attribution.js"
28
29
 
29
30
  const DEFAULT_STREAM_BUFFER_BYTES = 4 * 1024 * 1024
30
31
  const DEFAULT_TOTAL_BUFFER_BYTES = 32 * 1024 * 1024
@@ -124,6 +125,7 @@ export class OutboundReverseGateway extends EventEmitter {
124
125
  /** @type {number} */ #spoolBytes = 0
125
126
  /** @type {number} */ #spoolCleanupFailures = 0
126
127
  /** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
128
+ /** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */ #disconnectAttribution
127
129
 
128
130
  /** @param {OutboundReverseGatewayOptions} options - Identity, hub, TLS and local policy. */
129
131
  constructor(options) {
@@ -240,7 +242,16 @@ export class OutboundReverseGateway extends EventEmitter {
240
242
  let attemptError
241
243
  let authenticated = false
242
244
  let registered = false
243
- const timer = setTimeout(() => physical.destroy(Object.assign(new Error("Reverse gateway connect/auth/register timeout"), {code: "SOCKETDUCT_AUTH_TIMEOUT"})), this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
245
+ /** @type {import('./disconnect-attribution.js').DisconnectReason | undefined} */
246
+ let attemptDisconnectReason
247
+ /** @type {string | undefined} */
248
+ let attemptSafeSocketCode
249
+ /** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */
250
+ let attemptAttribution
251
+ const timer = setTimeout(() => {
252
+ attemptDisconnectReason ??= "heartbeat_timeout"
253
+ physical.destroy(Object.assign(new Error("Reverse gateway connect/auth/register timeout"), {code: "SOCKETDUCT_AUTH_TIMEOUT"}))
254
+ }, this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
244
255
  timer.unref?.()
245
256
  physical.once("secureConnect", () => {
246
257
  if (this.#physical !== physical || physical.destroyed) return
@@ -260,13 +271,17 @@ export class OutboundReverseGateway extends EventEmitter {
260
271
  for (const frame of decoder.push(chunk)) {
261
272
  if (frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.GOAWAY) {
262
273
  const detail = decodeV3Control(frame.payload)
263
- throw connectionError(typeof detail.code === "string" ? detail.code : "CONNECTION_REJECTED")
274
+ if (frame.type === V3FrameType.GOAWAY) attemptDisconnectReason ??= "peer_goaway"
275
+ const parsedAttribution = parseDisconnectAttribution(detail)
276
+ if (parsedAttribution?.incidentOrigin === "reverse_hub") attemptAttribution = parsedAttribution
277
+ throw connectionError(typeof detail.code === "string" ? detail.code : "CONNECTION_REJECTED", attemptAttribution)
264
278
  }
265
279
  if (!authenticated) {
266
280
  if (frame.type !== V3FrameType.AUTH_OK) throw fatalError("AUTH_NEGOTIATION_FAILED")
267
281
  const detail = decodeV3Control(frame.payload)
268
282
  if (detail.version !== 3 || detail.role !== "gateway" || detail.connectionId !== this.#options.connectionId || detail.generation !== generation || detail.compression !== ZSTD_CODEC || !validPeerWindow(detail.initialWindow)) throw fatalError("AUTH_NEGOTIATION_FAILED")
269
283
  authenticated = true
284
+ this.#disconnectAttribution = undefined
270
285
  this.#codec = ZSTD_CODEC
271
286
  this.#sendWindowLimit = Math.min(this.#initialWindow(), detail.initialWindow)
272
287
  this.#setState("registering")
@@ -279,16 +294,16 @@ export class OutboundReverseGateway extends EventEmitter {
279
294
  if (detail.gatewayId !== this.#options.gatewayId || detail.generation !== generation) throw fatalError("GATEWAY_REGISTRATION_FAILED")
280
295
  if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_REGISTRATION_FAILED")
281
296
  registered = true
282
- const reconnecting = this.#physicalConnects > 0
283
- if (reconnecting && this.#streams.size > 0) {
284
- if (!detail.resumed) {
285
- for (const stream of [...this.#streams.values()]) this.#destroyStream(stream, "RESUME_SESSION_LOST")
286
- this.#completePhysical(generation, timer)
287
- } else {
288
- clearTimeout(timer)
289
- this.#resuming = true
290
- await this.#sendResumeManifest(generation)
291
- }
297
+ if (detail.resumed) {
298
+ // A replacement gateway intentionally owns no inherited target
299
+ // sockets. Its empty manifest makes the hub reset every omitted
300
+ // retained bridge instead of ever replaying an ambiguous OPEN.
301
+ clearTimeout(timer)
302
+ this.#resuming = true
303
+ await this.#sendResumeManifest(generation)
304
+ } else if (this.#streams.size > 0) {
305
+ for (const stream of [...this.#streams.values()]) this.#destroyStream(stream, "RESUME_SESSION_LOST")
306
+ this.#completePhysical(generation, timer)
292
307
  } else {
293
308
  this.#completePhysical(generation, timer)
294
309
  }
@@ -298,13 +313,20 @@ export class OutboundReverseGateway extends EventEmitter {
298
313
  }
299
314
  }).catch((error) => {
300
315
  attemptError = asError(error)
316
+ attemptDisconnectReason ??= "protocol_violation"
301
317
  if (/** @type {{fatal?: unknown}} */ (attemptError).fatal === true) this.#fatal = true
302
318
  physical.destroy(attemptError)
303
319
  }).finally(() => {
304
320
  if (!physical.destroyed) physical.resume()
305
321
  })
306
322
  })
307
- physical.once("error", (error) => { attemptError = error; if (isFatalTransportError(error)) this.#fatal = true })
323
+ physical.once("end", () => { attemptDisconnectReason ??= "remote_eof" })
324
+ physical.once("error", (error) => {
325
+ attemptError = error
326
+ attemptDisconnectReason ??= "remote_socket_error"
327
+ attemptSafeSocketCode ??= safeSocketCode(error)
328
+ if (isFatalTransportError(error)) this.#fatal = true
329
+ })
308
330
  physical.once("close", () => {
309
331
  clearTimeout(timer)
310
332
  void queue.finally(() => {
@@ -315,11 +337,28 @@ export class OutboundReverseGateway extends EventEmitter {
315
337
  this.#resumeResults = []
316
338
  this.#resumeNextPage = 0
317
339
  if (!this.#closing && !this.#fatal) this.#setState("reconnecting")
340
+ const attribution = attemptAttribution ?? this.#disconnectAttribution ?? createDisconnectAttribution({
341
+ disconnectReason: attemptDisconnectReason ?? "unknown_transport_close",
342
+ incidentOrigin: "reverse_gateway",
343
+ physicalRole: "gateway",
344
+ gatewayConnectionGeneration: generation,
345
+ fanOutCount: this.#streams.size,
346
+ resumeSuccessCount: 0,
347
+ terminalStreamCount: 0
348
+ })
349
+ this.#disconnectAttribution = attribution
318
350
  if (!this.#closing) {
351
+ logDiagnostic(this.#options.logger, "reverse_gateway.physical_detached", {
352
+ gatewayId: this.#options.gatewayId,
353
+ ...attribution,
354
+ ...(attemptSafeSocketCode === undefined ? {} : {safeSocketCode: attemptSafeSocketCode})
355
+ })
319
356
  logDiagnostic(this.#options.logger, "transport.disconnected", {
320
- generation,
357
+ gatewayConnectionGeneration: generation,
321
358
  gatewayId: this.#options.gatewayId,
322
359
  virtualStreams: this.#streams.size,
360
+ ...attribution,
361
+ ...(attemptSafeSocketCode === undefined ? {} : {safeSocketCode: attemptSafeSocketCode}),
323
362
  ...errorDiagnostics(attemptError, "TRANSPORT_CLOSED")
324
363
  })
325
364
  }
@@ -752,7 +791,18 @@ export class OutboundReverseGateway extends EventEmitter {
752
791
  /** @param {number} type - Type. @param {number} streamId - Stream. @param {number} sequence - Sequence. @param {Record<string, unknown>} body - Body. */
753
792
  async #sendControl(type, streamId, sequence, body) {
754
793
  const physical = this.#physical
755
- if (physical === undefined || physical.destroyed) return false
794
+ if (physical === undefined || physical.destroyed) {
795
+ this.#disconnectAttribution ??= createDisconnectAttribution({
796
+ disconnectReason: "send_after_transport_loss",
797
+ incidentOrigin: "reverse_gateway",
798
+ physicalRole: "gateway",
799
+ gatewayConnectionGeneration: this.#generation,
800
+ fanOutCount: this.#streams.size,
801
+ resumeSuccessCount: 0,
802
+ terminalStreamCount: 0
803
+ })
804
+ return false
805
+ }
756
806
  return await writeSocket(physical, encodeV3Frame({type, streamId, sequence}, encodeV3Control(body)))
757
807
  }
758
808
 
@@ -865,9 +915,9 @@ function fatalError(/** @type {string} */ code) {
865
915
  return error
866
916
  }
867
917
 
868
- function connectionError(/** @type {string} */ code) {
869
- if (code === "HUB_SHUTDOWN" || code === "CLIENT_DISCONNECTED" || code === "GATEWAY_DISCONNECTED") return Object.assign(new Error("Reverse gateway hub is temporarily unavailable"), {code, fatal: false})
870
- return fatalError(code)
918
+ function connectionError(/** @type {string} */ code, /** @type {unknown} */ attribution = undefined) {
919
+ if (code === "HUB_SHUTDOWN" || code === "CLIENT_DISCONNECTED" || code === "GATEWAY_DISCONNECTED") return attachDisconnectAttribution(Object.assign(new Error("Reverse gateway hub is temporarily unavailable"), {code, fatal: false}), attribution)
920
+ return attachDisconnectAttribution(fatalError(code), attribution)
871
921
  }
872
922
 
873
923
  /** @param {unknown} value - Advertised peer receive window. @returns {value is number} Whether valid. */