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
@@ -27,6 +27,7 @@ import {
27
27
  STABLE_IDENTIFIER,
28
28
  writeSocket
29
29
  } from "./common.js"
30
+ import {attachDisconnectAttribution, createDisconnectAttribution, parseDisconnectAttribution, safeSocketCode} from "./disconnect-attribution.js"
30
31
 
31
32
  const DEFAULT_STREAM_BUFFER_BYTES = 4 * 1024 * 1024
32
33
  const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
@@ -70,6 +71,8 @@ const MANIFEST_PAGE_STREAMS = 64
70
71
  * @property {{host: string, port: number}} target
71
72
  * @property {VirtualSocket} socket
72
73
  * @property {'opening' | 'open' | 'half-closed' | 'closed' | 'reset'} state
74
+ * @property {boolean} opened
75
+ * @property {boolean} openingSent
73
76
  * @property {number} sendSequence
74
77
  * @property {number} transmittedSequence
75
78
  * @property {number} sendAck
@@ -77,8 +80,9 @@ const MANIFEST_PAGE_STREAMS = 64
77
80
  * @property {number} sendWindowLimit
78
81
  * @property {number} receiveSequence
79
82
  * @property {number} receiveAck
80
- * @property {number | undefined} pendingReceiveAck
83
+ * @property {Array<{sequence: number, remaining: number}>} receiveFrames
81
84
  * @property {boolean} remoteEnded
85
+ * @property {boolean} remoteEndPending
82
86
  * @property {number} bufferedBytes
83
87
  * @property {ClientChunk[]} replay
84
88
  * @property {AdaptiveCompressor} compressor
@@ -93,6 +97,29 @@ const MANIFEST_PAGE_STREAMS = 64
93
97
  * @property {number} spoolReservedBytes
94
98
  * @property {Promise<void> | undefined} spoolRemovalPromise
95
99
  * @property {boolean} spoolCleanupFailureRecorded
100
+ * @property {boolean} ownsSpool
101
+ * @property {boolean} recoveredObject
102
+ * @typedef {object} ReverseClientRecoveryState
103
+ * @property {number} version
104
+ * @property {'reverse-client'} role
105
+ * @property {string} connectionId
106
+ * @property {number} generation
107
+ * @property {string} [gatewayId]
108
+ * @property {string} [routeClass]
109
+ * @property {number} streamId
110
+ * @property {string} identifier
111
+ * @property {string} name
112
+ * @property {{host: string, port: number}} target
113
+ * @property {'opening' | 'open' | 'half-closed'} state
114
+ * @property {boolean} opened
115
+ * @property {true} openingSent
116
+ * @property {number} sendSequence
117
+ * @property {number} sendAck
118
+ * @property {number} receiveSequence
119
+ * @property {number} receiveAck
120
+ * @property {boolean} localEnded
121
+ * @property {boolean} remoteEnded
122
+ * @property {boolean} endSent
96
123
  */
97
124
 
98
125
  /** Server3/TensorBuzz-facing virtual socket connection to an exact gateway or server-resolved route class. */
@@ -106,6 +133,7 @@ export class ReverseGatewayConnection extends EventEmitter {
106
133
  /** @type {number} */ #generation
107
134
  /** @type {string | undefined} */ #codec
108
135
  /** @type {boolean} */ #closing = false
136
+ /** @type {boolean} */ #preserveSpools = false
109
137
  /** @type {boolean} */ #fatal = false
110
138
  /** @type {Error | undefined} */ #fatalError
111
139
  /** @type {boolean} */ #referenced = true
@@ -127,10 +155,12 @@ export class ReverseGatewayConnection extends EventEmitter {
127
155
  /** @type {number} */ #wireDataBytes = 0
128
156
  /** @type {number} */ #lastProgressedStreamId = 0
129
157
  /** @type {Promise<void>} */ #openQueue = Promise.resolve()
158
+ /** @type {Promise<void>} */ #physicalFrameQueue = Promise.resolve()
130
159
  /** @type {SessionSpool} */ #spool
131
160
  /** @type {number} */ #spoolBytes = 0
132
161
  /** @type {number} */ #spoolCleanupFailures = 0
133
162
  /** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
163
+ /** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */ #disconnectAttribution
134
164
 
135
165
  /** @param {ReverseGatewayConnectionOptions} options - Hub route and safety policy. */
136
166
  constructor(options) {
@@ -163,15 +193,45 @@ export class ReverseGatewayConnection extends EventEmitter {
163
193
 
164
194
  /** Allocates one stream on the already-warm gateway transport. */
165
195
  createConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback = undefined) {
196
+ if (options?.recover === true) throw new TypeError("Durable recovery must use registerRecoveredConnection()")
197
+ return this.#createConnection(options, callback, false)
198
+ }
199
+
200
+ /** Registers one durable stream without starting a physical connection. */
201
+ registerRecoveredConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback = undefined) {
202
+ if (options?.recover !== true) throw new TypeError("registerRecoveredConnection requires recover: true")
203
+ if (this.#options.connectionId === undefined) throw new TypeError("registerRecoveredConnection requires an explicit stable connectionId")
204
+ if (this.#options.spoolDirectory === undefined) throw new TypeError("registerRecoveredConnection requires an explicit persistent spoolDirectory")
205
+ if (this.#physicalAttempts > 0) throw new Error("Recovered streams must be registered before any physical connection attempt")
206
+ return this.#createConnection(options, callback, true)
207
+ }
208
+
209
+ /** Creates a fresh or synchronously recovered reverse stream. */
210
+ #createConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback, /** @type {boolean} */ recovering) {
166
211
  if (this.#closing) throw closedError()
167
- if (this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
212
+ if (!recovering && this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
168
213
  if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum reverse virtual stream count exceeded")
169
214
  const target = parseTarget(options?.target ?? {host: options?.host, port: options?.port})
170
215
  if (target === undefined) throw new TypeError("Reverse virtual connection target host and port are required")
171
- const identifier = options.identifier ?? randomBytes(24).toString("base64url")
216
+ const identifier = recovering ? options.identifier : (options.identifier ?? randomBytes(24).toString("base64url"))
172
217
  if (typeof identifier !== "string" || !STABLE_IDENTIFIER.test(identifier)) throw new TypeError("Reverse virtual stream identifier must be 8-128 URL-safe characters")
173
- const id = this.#allocateStreamId()
174
- const name = options.sessionName ?? `socketduct-reverse-${id}`
218
+ const recovery = recovering
219
+ ? validateRecoveryState(this.#spool.readRecoveryStateSync(identifier, "reverse-client-v3"), {
220
+ connectionId: this.#connectionId,
221
+ identifier,
222
+ target,
223
+ selection: this.#routeIdentityFields(),
224
+ initialWindow: this.#initialWindow(),
225
+ ...(options.sessionName === undefined ? {} : {sessionName: options.sessionName})
226
+ })
227
+ : undefined
228
+ const chunks = recovery === undefined ? [] : this.#spool.loadChunksSync(identifier, "reverse-client-v3", recovery.sendAck, recovery.sendSequence)
229
+ if (recovery !== undefined) validateRecoveredChunks(recovery, chunks, this.#options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES, this.#maxFrameBytes())
230
+ const bufferedBytes = chunks.reduce((sum, chunk) => sum + chunk.data.length, 0)
231
+ if (this.#bufferedBytes + bufferedBytes > (this.#options.maxConnectionBufferBytes ?? DEFAULT_CONNECTION_BUFFER_BYTES)) throw new Error("Recovered reverse data exceeds the connection buffer limit")
232
+ const id = recovery?.streamId ?? this.#allocateStreamId()
233
+ if (this.#streams.has(id)) throw new Error(`Reverse v3 stream ID ${id} is already active`)
234
+ const name = options.sessionName ?? recovery?.name ?? `socketduct-reverse-${id}`
175
235
  if (typeof name !== "string" || name.length === 0 || name.length > 256) throw new TypeError("Reverse virtual stream name must be 1-256 characters")
176
236
  const socket = new VirtualSocket(/** @type {import('../client/multiplexed-connection.js').MultiplexedSocketductConnection} */ (/** @type {unknown} */ (this)), id, {
177
237
  ...target,
@@ -182,55 +242,78 @@ export class ReverseGatewayConnection extends EventEmitter {
182
242
  /** @type {ReverseClientStream} */
183
243
  const stream = {
184
244
  id, identifier, name, target, socket,
185
- state: "opening",
186
- sendSequence: 0,
187
- transmittedSequence: 0,
188
- sendAck: 0,
245
+ state: recovery?.state ?? "opening",
246
+ opened: recovery?.opened ?? false,
247
+ openingSent: recovery?.openingSent ?? false,
248
+ sendSequence: recovery?.sendSequence ?? 0,
249
+ transmittedSequence: recovery?.sendAck ?? 0,
250
+ sendAck: recovery?.sendAck ?? 0,
189
251
  sendWindow: this.#sendWindowLimit,
190
252
  sendWindowLimit: this.#sendWindowLimit,
191
- receiveSequence: 0,
192
- receiveAck: 0,
193
- pendingReceiveAck: undefined,
194
- remoteEnded: false,
195
- bufferedBytes: 0,
196
- replay: [],
253
+ // Bytes buffered only in the dead VirtualSocket were not durably
254
+ // delivered. Resume inbound delivery from the persisted ACK boundary.
255
+ receiveSequence: recovery?.receiveAck ?? 0,
256
+ receiveAck: recovery?.receiveAck ?? 0,
257
+ receiveFrames: [],
258
+ remoteEnded: recovery?.remoteEnded ?? false,
259
+ remoteEndPending: recovery?.remoteEnded ?? false,
260
+ bufferedBytes,
261
+ replay: chunks.map((chunk) => ({...chunk, sent: false})),
197
262
  compressor: new AdaptiveCompressor(),
198
263
  pumping: false,
199
264
  pendingWrite: undefined,
200
265
  progressingWrite: false,
201
- localEnded: false,
202
- endSent: false,
266
+ localEnded: recovery?.localEnded ?? false,
267
+ endSent: recovery?.endSent ?? false,
203
268
  endCallback: undefined,
204
269
  spoolReady: Promise.resolve(),
205
270
  spoolQueue: Promise.resolve(),
206
- spoolReservedBytes: 0,
271
+ spoolReservedBytes: bufferedBytes,
207
272
  spoolRemovalPromise: undefined,
208
- spoolCleanupFailureRecorded: false
273
+ spoolCleanupFailureRecorded: false,
274
+ ownsSpool: recovery !== undefined,
275
+ recoveredObject: recovery !== undefined
276
+ }
277
+ if (recovery !== undefined) {
278
+ this.#options.aggregateBudget?.reserveData(bufferedBytes)
279
+ this.#bufferedBytes += bufferedBytes
280
+ this.#spoolBytes += bufferedBytes
281
+ this.#nextStreamId = Math.max(this.#nextStreamId, id + 2)
282
+ this.#generation = Math.max(this.#generation, recovery.generation)
209
283
  }
210
284
  this.#streams.set(id, stream)
211
- stream.spoolReady = this.#spool.createNew({sessionId: identifier, ownerId: "reverse-client-v3"})
212
- .then(() => this.#spool.writeRecoveryState(identifier, this.#recoveryState(stream)))
285
+ stream.spoolReady = recovery === undefined
286
+ ? this.#spool.createNew({sessionId: identifier, ownerId: "reverse-client-v3"})
287
+ .then(() => {
288
+ stream.ownsSpool = true
289
+ return this.#spool.writeRecoveryState(identifier, this.#recoveryState(stream))
290
+ })
291
+ : this.#spool.truncateAcked(identifier, recovery.sendAck)
213
292
  stream.spoolQueue = stream.spoolReady.catch(() => {})
214
293
  logDiagnostic(this.#options.logger, "virtual_stream.opening", {
215
294
  streamId: id,
216
295
  generation: this.#generation,
217
296
  ...this.#routeIdentityFields(),
218
- recovered: false
297
+ recovered: recovery !== undefined
219
298
  })
220
299
  if (callback !== undefined) installCallback(socket, callback)
221
- const opening = this.#openQueue
222
- .then(() => stream.spoolReady)
223
- .then(() => this.#streams.get(id) === stream ? this.#sendControl(V3FrameType.OPEN, id, 0, {...this.#routeIdentityFields(), identifier, name, target, initialWindow: this.#initialWindow()}) : undefined)
224
- this.#openQueue = opening.then(() => undefined, () => undefined)
225
- void opening.catch((error) => socket._receiveError(asError(error)))
300
+ if (recovery === undefined) {
301
+ const opening = this.#openQueue
302
+ .then(() => stream.spoolReady)
303
+ .then(async () => {
304
+ if (this.#streams.get(id) !== stream) return
305
+ stream.openingSent = true
306
+ await this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(identifier, this.#recoveryState(stream)))
307
+ if (this.#streams.get(id) === stream) await this.#sendControl(V3FrameType.OPEN, id, 0, {...this.#routeIdentityFields(), identifier, name, target, initialWindow: this.#initialWindow()})
308
+ })
309
+ this.#openQueue = opening.then(() => undefined, () => undefined)
310
+ void opening.catch((error) => socket._receiveError(asError(error)))
311
+ } else {
312
+ void stream.spoolReady.catch((error) => socket._receiveError(asError(error)))
313
+ }
226
314
  return socket
227
315
  }
228
316
 
229
- /** Process replacement cannot inherit the live reverse target/app socket pair. */
230
- registerRecoveredConnection() {
231
- throw Object.assign(new Error("Reverse gateway process recovery is unsupported; live streams resume only within the original process"), {code: "REVERSE_STREAM_RECOVERY_UNSUPPORTED"})
232
- }
233
-
234
317
  status() {
235
318
  /** @type {Record<string, number>} */
236
319
  const streamStates = {}
@@ -280,23 +363,46 @@ export class ReverseGatewayConnection extends EventEmitter {
280
363
  if (failures.length > 0) throw new AggregateError(failures, "Reverse client spool cleanup failed")
281
364
  }
282
365
 
283
- /** Orderly object detach is terminal; in-object physical reconnect uses paired resume. */
284
- detach() { return this.close() }
366
+ /** Preserves durable streams while retiring this object for replacement. */
367
+ async detach() {
368
+ if (this.#closing) return
369
+ if (this.#options.connectionId === undefined) throw new Error("detach() requires an explicit stable connectionId")
370
+ if (this.#options.spoolDirectory === undefined) throw new Error("detach() requires an explicit persistent spoolDirectory")
371
+ this.#preserveSpools = true
372
+ this.#closing = true
373
+ if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
374
+ this.#reconnectTimer = undefined
375
+ this.#setState("closed")
376
+ const physical = this.#physical
377
+ physical?.pause()
378
+ physical?.destroy()
379
+ const frameQueue = this.#physicalFrameQueue
380
+ try {
381
+ await frameQueue
382
+ await Promise.all([...this.#streams.values()].map((stream) => this.#persistStreamState(stream)))
383
+ } finally {
384
+ physical?.destroy()
385
+ this.#physical = undefined
386
+ this.#rejectConnect?.(new SocketductSessionLostError("Reverse connection detached for durable recovery", "CONNECTION_DETACHED"))
387
+ this.#settleConnect()
388
+ }
389
+ }
285
390
 
286
391
  _writeStream(/** @type {number} */ streamId, /** @type {Buffer} */ data, /** @type {(error?: Error | null) => void} */ callback) {
287
392
  const stream = this.#streams.get(streamId)
288
- if (stream === undefined || stream.localEnded || stream.state === "closed" || stream.state === "reset") { callback(new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED")); return }
393
+ if (this.#closing || stream === undefined || stream.localEnded || stream.state === "closed" || stream.state === "reset") { callback(new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED")); return }
289
394
  stream.pendingWrite = {data, offset: 0, callback}
290
395
  this.#progressPendingWrites()
291
396
  }
292
397
 
293
398
  _endStream(/** @type {number} */ streamId, /** @type {(error?: Error | null) => void} */ callback) {
294
399
  const stream = this.#streams.get(streamId)
295
- if (stream === undefined || stream.localEnded) { callback(); return }
400
+ if (this.#closing || stream === undefined || stream.localEnded) { callback(); return }
296
401
  stream.localEnded = true
297
402
  if (stream.state === "closed") { callback(); return }
403
+ if (stream.opened && stream.state === "open") stream.state = "half-closed"
298
404
  stream.endCallback = callback
299
- void this.#pumpStream(stream).catch((error) => this.#finishEnd(stream, asError(error)))
405
+ void this.#persistStreamState(stream).then(() => this.#pumpStream(stream)).catch((error) => this.#finishEnd(stream, asError(error)))
300
406
  }
301
407
 
302
408
  _destroyStream(/** @type {number} */ streamId, /** @type {Error | null} */ error) {
@@ -307,18 +413,34 @@ export class ReverseGatewayConnection extends EventEmitter {
307
413
  streamId,
308
414
  generation: this.#generation,
309
415
  ...this.#routeIdentityFields(),
310
- ...(error === null ? {} : errorDiagnostics(error))
416
+ ...(error === null ? {} : errorDiagnostics(error)),
417
+ ...(error === null ? {} : publicDisconnectAttribution(error))
311
418
  })
312
419
  this.#retireStream(stream, error ?? new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED"))
313
420
  if (!wasClosed && !this.#closing && this.#state === "ready") void this.#sendControl(V3FrameType.RESET, streamId, 0, {code: error instanceof SocketductStreamError ? error.code : "CLIENT_CANCELLED"}).catch(() => {})
314
421
  }
315
422
 
316
423
  _resumeReadable(/** @type {number} */ streamId) {
424
+ void streamId
425
+ }
426
+
427
+ _consumeReadable(/** @type {number} */ streamId, /** @type {number} */ bytes) {
317
428
  const stream = this.#streams.get(streamId)
318
- if (stream?.pendingReceiveAck === undefined) return
319
- const sequence = stream.pendingReceiveAck
320
- stream.pendingReceiveAck = undefined
321
- this.#ackReceived(stream, sequence)
429
+ if (stream === undefined || !Number.isSafeInteger(bytes) || bytes <= 0) return
430
+ let remaining = bytes
431
+ let acknowledgedSequence = stream.receiveAck
432
+ while (remaining > 0 && stream.receiveFrames.length > 0) {
433
+ const frame = /** @type {{sequence: number, remaining: number}} */ (stream.receiveFrames[0])
434
+ const consumed = Math.min(remaining, frame.remaining)
435
+ frame.remaining -= consumed
436
+ remaining -= consumed
437
+ if (frame.remaining === 0) {
438
+ acknowledgedSequence = frame.sequence
439
+ stream.receiveFrames.shift()
440
+ }
441
+ }
442
+ if (remaining !== 0) throw new Error("Reverse client readable accounting underflow")
443
+ this.#ackReceived(stream, acknowledgedSequence)
322
444
  }
323
445
 
324
446
  #allocateStreamId() {
@@ -361,32 +483,56 @@ export class ReverseGatewayConnection extends EventEmitter {
361
483
  physical.setKeepAlive(true, DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS)
362
484
  const decoder = new V3FrameDecoder({maxFrameBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4), maxBufferedBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4) * 2})
363
485
  let queue = Promise.resolve()
486
+ this.#physicalFrameQueue = queue
364
487
  let authenticated = false
365
488
  let selected = false
489
+ /** @type {import('./disconnect-attribution.js').DisconnectReason | undefined} */
490
+ let attemptDisconnectReason
491
+ /** @type {string | undefined} */
492
+ let attemptSafeSocketCode
493
+ /** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */
494
+ let attemptAttribution
366
495
  /** @type {Error | undefined} */
367
496
  let attemptError
368
- const timer = setTimeout(() => physical.destroy(Object.assign(new Error("Reverse client TLS/auth timeout"), {code: "SOCKETDUCT_AUTH_TIMEOUT"})), this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
497
+ const timer = setTimeout(() => {
498
+ attemptDisconnectReason ??= "heartbeat_timeout"
499
+ physical.destroy(Object.assign(new Error("Reverse client TLS/auth timeout"), {code: "SOCKETDUCT_AUTH_TIMEOUT"}))
500
+ }, this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
369
501
  timer.unref?.()
370
502
  physical.once("secureConnect", () => {
371
- const body = encodeV3Control({role: "client", token: this.#options.hub.token, versions: [3], connectionId: this.#connectionId, generation, compression: availableCodecs()})
372
- void writeSocket(physical, encodeV3Frame({type: V3FrameType.AUTH, streamId: 0, sequence: generation}, body))
373
- .catch((error) => physical.destroy(asError(error)))
503
+ // The attempted generation must survive a crash after AUTH reaches the
504
+ // hub but before AUTH_OK returns, otherwise a replacement could reuse a
505
+ // fenced generation after wall-clock rollback.
506
+ void Promise.all([...this.#streams.values()].map((stream) => this.#persistStreamState(stream))).then(async () => {
507
+ if (this.#physical !== physical || physical.destroyed || this.#closing) return
508
+ const body = encodeV3Control({role: "client", token: this.#options.hub.token, versions: [3], connectionId: this.#connectionId, generation, compression: availableCodecs()})
509
+ await writeSocket(physical, encodeV3Frame({type: V3FrameType.AUTH, streamId: 0, sequence: generation}, body))
510
+ }).catch((error) => physical.destroy(asError(error)))
374
511
  })
375
512
  physical.on("data", (chunk) => {
513
+ if (this.#closing || this.#physical !== physical) return
376
514
  physical.pause()
377
515
  queue = queue.then(async () => {
378
516
  for (const frame of decoder.push(chunk)) {
517
+ if (this.#closing || this.#physical !== physical) return
379
518
  if (frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.GOAWAY) {
380
519
  const detail = decodeV3Control(frame.payload)
381
- throw connectionError(typeof detail.code === "string" ? detail.code : "CONNECTION_REJECTED")
520
+ if (frame.type === V3FrameType.GOAWAY) attemptDisconnectReason ??= "peer_goaway"
521
+ attemptAttribution = validHubAttribution(detail) ?? attemptAttribution
522
+ throw connectionError(typeof detail.code === "string" ? detail.code : "CONNECTION_REJECTED", attemptAttribution)
382
523
  }
383
524
  if (!authenticated) {
384
525
  if (frame.type !== V3FrameType.AUTH_OK) throw fatalError("AUTH_NEGOTIATION_FAILED")
385
526
  const detail = decodeV3Control(frame.payload)
386
527
  if (detail.version !== 3 || detail.role !== "client" || detail.connectionId !== this.#connectionId || detail.generation !== generation || detail.compression !== ZSTD_CODEC || !validPeerWindow(detail.initialWindow)) throw fatalError("AUTH_NEGOTIATION_FAILED")
387
528
  authenticated = true
529
+ this.#disconnectAttribution = undefined
388
530
  this.#codec = ZSTD_CODEC
389
531
  this.#sendWindowLimit = Math.min(this.#initialWindow(), detail.initialWindow)
532
+ for (const stream of this.#streams.values()) {
533
+ stream.sendWindowLimit = Math.min(stream.sendWindowLimit, this.#sendWindowLimit)
534
+ stream.sendWindow = Math.min(stream.sendWindow, stream.sendWindowLimit)
535
+ }
390
536
  await writeSocket(physical, encodeV3Frame({type: V3FrameType.GATEWAY_SELECT, streamId: 0, sequence: generation}, encodeV3Control(this.#routeIdentityFields())))
391
537
  continue
392
538
  }
@@ -397,30 +543,35 @@ export class ReverseGatewayConnection extends EventEmitter {
397
543
  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")
398
544
  if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_SELECTION_FAILED")
399
545
  selected = true
400
- const reconnecting = this.#physicalConnects > 0
401
- if (reconnecting && this.#streams.size > 0) {
402
- if (!detail.resumed) {
403
- for (const stream of [...this.#streams.values()]) stream.socket._receiveError(new SocketductSessionLostError("Reverse gateway hub lost the retained stream", "RESUME_SESSION_LOST"))
404
- this.#completePhysical(generation, timer)
405
- } else {
406
- clearTimeout(timer)
407
- this.#resuming = true
408
- await this.#sendResumeManifest(generation)
409
- }
546
+ if (detail.resumed) {
547
+ clearTimeout(timer)
548
+ this.#resuming = true
549
+ await this.#sendResumeManifest(generation)
550
+ } else if ([...this.#streams.values()].some((stream) => stream.openingSent || stream.opened)) {
551
+ for (const stream of [...this.#streams.values()]) stream.socket._receiveError(new SocketductSessionLostError("Reverse gateway hub lost the retained stream", "RESUME_SESSION_LOST"))
552
+ this.#completePhysical(generation, timer)
410
553
  } else {
411
554
  this.#completePhysical(generation, timer)
412
555
  }
413
556
  continue
414
557
  }
415
- await this.#handleFrame(frame)
558
+ await this.#handleFrame(frame, physical)
416
559
  }
417
560
  }).catch((error) => {
418
561
  attemptError = asError(error)
562
+ attemptDisconnectReason ??= "protocol_violation"
419
563
  if (/** @type {{fatal?: unknown}} */ (attemptError).fatal === true) this.#fatal = true
420
564
  physical.destroy(attemptError)
421
565
  }).finally(() => { if (!physical.destroyed) physical.resume() })
566
+ this.#physicalFrameQueue = queue
567
+ })
568
+ physical.once("end", () => { attemptDisconnectReason ??= "remote_eof" })
569
+ physical.once("error", (error) => {
570
+ attemptError = error
571
+ attemptDisconnectReason ??= "remote_socket_error"
572
+ attemptSafeSocketCode ??= safeSocketCode(error)
573
+ if (isFatalTransportError(error)) this.#fatal = true
422
574
  })
423
- physical.once("error", (error) => { attemptError = error; if (isFatalTransportError(error)) this.#fatal = true })
424
575
  physical.once("close", () => {
425
576
  clearTimeout(timer)
426
577
  void queue.finally(() => {
@@ -432,11 +583,28 @@ export class ReverseGatewayConnection extends EventEmitter {
432
583
  this.#resumeNextPage = 0
433
584
  if (!this.#closing && !this.#fatal) this.#setState("reconnecting")
434
585
  const lost = new SocketductSessionLostError("Reverse gateway physical transport was interrupted", "GATEWAY_DISCONNECTED")
586
+ const attribution = attemptAttribution ?? this.#disconnectAttribution ?? createDisconnectAttribution({
587
+ disconnectReason: attemptDisconnectReason ?? "unknown_transport_close",
588
+ incidentOrigin: "reverse_client",
589
+ physicalRole: "client",
590
+ clientConnectionGeneration: generation,
591
+ fanOutCount: this.#streams.size,
592
+ resumeSuccessCount: 0,
593
+ terminalStreamCount: 0
594
+ })
595
+ this.#disconnectAttribution = attribution
435
596
  if (!this.#closing) {
597
+ logDiagnostic(this.#options.logger, "reverse_client.physical_detached", {
598
+ ...this.#routeIdentityFields(),
599
+ ...attribution,
600
+ ...(attemptSafeSocketCode === undefined ? {} : {safeSocketCode: attemptSafeSocketCode})
601
+ })
436
602
  logDiagnostic(this.#options.logger, "transport.disconnected", {
437
- generation,
603
+ clientConnectionGeneration: generation,
438
604
  ...this.#routeIdentityFields(),
439
605
  virtualStreams: this.#streams.size,
606
+ ...attribution,
607
+ ...(attemptSafeSocketCode === undefined ? {} : {safeSocketCode: attemptSafeSocketCode}),
440
608
  ...errorDiagnostics(attemptError ?? lost, "GATEWAY_DISCONNECTED")
441
609
  })
442
610
  }
@@ -447,6 +615,8 @@ export class ReverseGatewayConnection extends EventEmitter {
447
615
  if (this.#closing) return
448
616
  if (this.#fatal) {
449
617
  const terminalError = attemptError ?? lost
618
+ const terminalCode = /** @type {{code?: unknown}} */ (terminalError).code
619
+ if (terminalCode === "STALE_GENERATION" || terminalCode === "CONNECTION_REPLACED") this.#preserveSpools = true
450
620
  this.#fatalError = terminalError
451
621
  this.#setState("closed", terminalError)
452
622
  logDiagnostic(this.#options.logger, "transport.failed", {
@@ -465,8 +635,9 @@ export class ReverseGatewayConnection extends EventEmitter {
465
635
  })
466
636
  }
467
637
 
468
- /** @param {import('../protocol/v3-frame.js').V3Frame} frame - Hub frame. */
469
- async #handleFrame(frame) {
638
+ /** @param {import('../protocol/v3-frame.js').V3Frame} frame - Hub frame. @param {import('node:tls').TLSSocket} physical - Owning physical generation. */
639
+ async #handleFrame(frame, physical) {
640
+ if (this.#closing || this.#physical !== physical) return
470
641
  if (frame.type === V3FrameType.PING) { await this.#sendControl(V3FrameType.PONG, 0, frame.sequence, {}); return }
471
642
  if (frame.type === V3FrameType.PONG) return
472
643
  if (frame.type === V3FrameType.RESUMED) {
@@ -476,7 +647,8 @@ export class ReverseGatewayConnection extends EventEmitter {
476
647
  this.#resumeNextPage += 1
477
648
  this.#resumeResults.push(...manifest.streams.filter((item) => typeof item === "object" && item !== null))
478
649
  if (manifest.final !== true) return
479
- for (const item of this.#resumeResults) this.#applyResumeResult(item)
650
+ for (const item of this.#resumeResults) await this.#applyResumeResult(item)
651
+ if (this.#closing || this.#physical !== physical) return
480
652
  this.#resumeResults = []
481
653
  this.#resumeNextPage = 0
482
654
  this.#resuming = false
@@ -493,13 +665,16 @@ export class ReverseGatewayConnection extends EventEmitter {
493
665
  if (stream.state !== "opening" || frame.sequence !== 0 || detail.identifier !== stream.identifier || !validPeerWindow(detail.initialWindow)) throw new Error("Invalid reverse OPENED response")
494
666
  stream.sendWindowLimit = Math.min(stream.sendWindowLimit, detail.initialWindow)
495
667
  stream.sendWindow = Math.min(stream.sendWindow, stream.sendWindowLimit)
496
- stream.state = "open"
668
+ stream.state = stream.localEnded ? "half-closed" : "open"
669
+ stream.opened = true
497
670
  logDiagnostic(this.#options.logger, "virtual_stream.opened", {
498
671
  streamId: stream.id,
499
672
  generation: this.#generation,
500
673
  ...this.#routeIdentityFields(),
501
674
  recovered: false
502
675
  })
676
+ await this.#persistStreamState(stream)
677
+ if (this.#closing || this.#physical !== physical) return
503
678
  stream.socket._markConnected()
504
679
  this.#progressPendingWrites()
505
680
  await this.#pumpStream(stream)
@@ -508,11 +683,11 @@ export class ReverseGatewayConnection extends EventEmitter {
508
683
  if (frame.type === V3FrameType.DATA) {
509
684
  if (stream.remoteEnded) throw new Error("Reverse client received DATA after END")
510
685
  const data = await decodeV3Data(frame, this.#codec, this.#options.compressionLimits)
686
+ if (this.#closing || this.#physical !== physical) return
511
687
  if (data.length === 0 || data.length > this.#maxFrameBytes() || frame.sequence !== stream.receiveSequence + data.length || frame.sequence - stream.receiveAck > this.#initialWindow()) throw new Error("Invalid reverse client DATA sequence or window")
512
688
  stream.receiveSequence = frame.sequence
513
- const canContinue = stream.socket._receive(data)
514
- if (canContinue) this.#ackReceived(stream, frame.sequence)
515
- else stream.pendingReceiveAck = frame.sequence
689
+ stream.receiveFrames.push({sequence: frame.sequence, remaining: data.length})
690
+ stream.socket._receive(data)
516
691
  return
517
692
  }
518
693
  if (frame.type === V3FrameType.ACK) { this.#acknowledge(stream, frame.sequence); return }
@@ -520,12 +695,15 @@ export class ReverseGatewayConnection extends EventEmitter {
520
695
  if (frame.type === V3FrameType.END) {
521
696
  if (stream.remoteEnded || frame.sequence !== stream.receiveSequence) throw new Error("Invalid reverse client END")
522
697
  stream.remoteEnded = true
698
+ await this.#persistStreamState(stream)
699
+ if (this.#closing || this.#physical !== physical) return
523
700
  stream.socket._receiveEnd()
524
701
  return
525
702
  }
526
703
  if (frame.type === V3FrameType.CLOSE) {
527
704
  if (frame.sequence !== stream.receiveSequence) throw new Error("Invalid reverse client CLOSE")
528
705
  await this.#sendControl(V3FrameType.CLOSE, stream.id, stream.transmittedSequence, {})
706
+ if (this.#closing || this.#physical !== physical) return
529
707
  stream.state = "closed"
530
708
  logDiagnostic(this.#options.logger, "virtual_stream.closed", {
531
709
  streamId: stream.id,
@@ -542,7 +720,7 @@ export class ReverseGatewayConnection extends EventEmitter {
542
720
  const detail = decodeV3Control(frame.payload)
543
721
  const code = typeof detail.code === "string" ? detail.code : "STREAM_RESET"
544
722
  stream.state = "reset"
545
- const error = new SocketductStreamError("Reverse gateway stream was reset", code)
723
+ const error = attachDisconnectAttribution(new SocketductStreamError("Reverse gateway stream was reset", code), validHubAttribution(detail) ?? (code === "GATEWAY_DISCONNECTED" ? this.#disconnectAttribution : undefined))
546
724
  stream.socket._receiveError(error)
547
725
  return
548
726
  }
@@ -558,7 +736,9 @@ export class ReverseGatewayConnection extends EventEmitter {
558
736
  identifier: stream.identifier,
559
737
  clientToGatewaySequence: stream.sendSequence,
560
738
  clientToGatewayAck: stream.sendAck,
561
- gatewayToClientSequence: stream.receiveSequence,
739
+ // A replacement cannot inherit the dead VirtualSocket's process-only
740
+ // readable buffer. Ask the surviving gateway to replay above durable ACK.
741
+ gatewayToClientSequence: stream.recoveredObject ? stream.receiveAck : stream.receiveSequence,
562
742
  gatewayToClientAck: stream.receiveAck,
563
743
  localEnded: stream.localEnded,
564
744
  remoteEnded: stream.remoteEnded
@@ -576,7 +756,7 @@ export class ReverseGatewayConnection extends EventEmitter {
576
756
  }
577
757
 
578
758
  /** @param {unknown} value - Hub resume result. */
579
- #applyResumeResult(value) {
759
+ async #applyResumeResult(value) {
580
760
  if (typeof value !== "object" || value === null) throw new Error("Invalid reverse client resume result")
581
761
  const result = /** @type {{streamId?: unknown, status?: unknown, clientToGatewaySequence?: unknown, clientToGatewayAck?: unknown, gatewayToClientSequence?: unknown, gatewayToClientAck?: unknown, clientEnded?: unknown, gatewayEnded?: unknown, opened?: unknown, code?: unknown}} */ (value)
582
762
  if (!Number.isInteger(result.streamId)) throw new Error("Invalid reverse client resume stream ID")
@@ -584,7 +764,7 @@ export class ReverseGatewayConnection extends EventEmitter {
584
764
  if (stream === undefined) return
585
765
  if (result.status !== "resumed") {
586
766
  const code = typeof result.code === "string" ? result.code : "RESUME_REJECTED"
587
- stream.socket._receiveError(new SocketductSessionLostError("Reverse gateway stream could not be resumed", code))
767
+ stream.socket._receiveError(attachDisconnectAttribution(new SocketductSessionLostError("Reverse gateway stream could not be resumed", code), validHubAttribution(result) ?? this.#disconnectAttribution))
588
768
  return
589
769
  }
590
770
  const clientToGatewaySequence = /** @type {number} */ (result.clientToGatewaySequence)
@@ -596,15 +776,20 @@ export class ReverseGatewayConnection extends EventEmitter {
596
776
  this.#acknowledge(stream, clientToGatewayAck)
597
777
  stream.transmittedSequence = clientToGatewayAck
598
778
  stream.receiveAck = gatewayToClientAck
599
- stream.pendingReceiveAck = stream.pendingReceiveAck !== undefined && stream.pendingReceiveAck > stream.receiveAck ? stream.pendingReceiveAck : undefined
600
779
  stream.sendWindow = Math.max(0, stream.sendWindowLimit - (clientToGatewaySequence - clientToGatewayAck))
601
780
  for (const chunk of stream.replay) chunk.sent = chunk.sequence <= clientToGatewaySequence
602
781
  stream.endSent = /** @type {boolean} */ (result.clientEnded)
603
- if (result.gatewayEnded === true && !stream.remoteEnded) {
782
+ if (result.gatewayEnded === true) {
783
+ stream.remoteEndPending = stream.recoveredObject || !stream.remoteEnded
604
784
  stream.remoteEnded = true
605
- stream.socket._receiveEnd()
606
785
  }
607
- if (result.opened === true && stream.state === "opening") stream.state = "open"
786
+ if (result.opened === true) {
787
+ stream.opened = true
788
+ if (stream.state === "opening") stream.state = stream.localEnded ? "half-closed" : "open"
789
+ }
790
+ stream.openingSent = true
791
+ stream.recoveredObject = false
792
+ await this.#persistStreamState(stream)
608
793
  }
609
794
 
610
795
  /** Marks one authenticated/selected physical generation ready. */
@@ -632,6 +817,10 @@ export class ReverseGatewayConnection extends EventEmitter {
632
817
  this.#settleConnect()
633
818
  for (const stream of this.#streams.values()) {
634
819
  if (stream.state !== "opening") stream.socket._markConnected()
820
+ if (stream.remoteEndPending) {
821
+ stream.remoteEndPending = false
822
+ stream.socket._receiveEnd()
823
+ }
635
824
  void this.#pumpStream(stream).catch((error) => stream.socket._receiveError(asError(error)))
636
825
  }
637
826
  this.#progressPendingWrites()
@@ -662,6 +851,7 @@ export class ReverseGatewayConnection extends EventEmitter {
662
851
  stream.endSent = false
663
852
  return
664
853
  }
854
+ await this.#persistStreamState(stream)
665
855
  this.#finishEnd(stream)
666
856
  }
667
857
  } finally { stream.pumping = false }
@@ -756,7 +946,7 @@ export class ReverseGatewayConnection extends EventEmitter {
756
946
  stream.replay = []
757
947
  stream.bufferedBytes = 0
758
948
  this.#finishWrite(stream, pendingWriteError)
759
- this.#trackSpoolCleanup(stream)
949
+ if (!this.#preserveSpools) this.#trackSpoolCleanup(stream)
760
950
  this.#progressPendingWrites()
761
951
  }
762
952
 
@@ -803,7 +993,19 @@ export class ReverseGatewayConnection extends EventEmitter {
803
993
  /** @param {number} type - Type. @param {number} streamId - Stream. @param {number} sequence - Sequence. @param {Record<string, unknown>} body - Body. */
804
994
  async #sendControl(type, streamId, sequence, body) {
805
995
  const physical = this.#physical
806
- if (physical === undefined || physical.destroyed) throw new SocketductSessionLostError("Reverse gateway transport is unavailable", "GATEWAY_DISCONNECTED")
996
+ if (physical === undefined || physical.destroyed) {
997
+ this.#disconnectAttribution ??= createDisconnectAttribution({
998
+ disconnectReason: "send_after_transport_loss",
999
+ incidentOrigin: "reverse_client",
1000
+ physicalRole: "client",
1001
+ clientConnectionGeneration: this.#generation,
1002
+ fanOutCount: this.#streams.size,
1003
+ resumeSuccessCount: 0,
1004
+ terminalStreamCount: 0
1005
+ })
1006
+ logDiagnostic(this.#options.logger, "reverse_client.send_after_transport_loss", {...this.#routeIdentityFields(), ...this.#disconnectAttribution})
1007
+ throw attachDisconnectAttribution(new SocketductSessionLostError("Reverse gateway transport is unavailable", "GATEWAY_DISCONNECTED"), this.#disconnectAttribution)
1008
+ }
807
1009
  return await writeSocket(physical, encodeV3Frame({type, streamId, sequence}, encodeV3Control(body)))
808
1010
  }
809
1011
 
@@ -841,19 +1043,30 @@ export class ReverseGatewayConnection extends EventEmitter {
841
1043
  version: 3,
842
1044
  role: "reverse-client",
843
1045
  connectionId: this.#connectionId,
1046
+ generation: this.#generation,
1047
+ ...this.#routeIdentityFields(),
844
1048
  streamId: stream.id,
845
1049
  identifier: stream.identifier,
846
1050
  name: stream.name,
847
1051
  target: stream.target,
1052
+ state: stream.state,
1053
+ opened: stream.opened,
1054
+ openingSent: stream.openingSent,
848
1055
  sendSequence: stream.sendSequence,
849
1056
  sendAck: stream.sendAck,
850
1057
  receiveSequence: stream.receiveSequence,
851
1058
  receiveAck: stream.receiveAck,
852
1059
  localEnded: stream.localEnded,
853
- remoteEnded: stream.remoteEnded
1060
+ remoteEnded: stream.remoteEnded,
1061
+ endSent: stream.endSent
854
1062
  }
855
1063
  }
856
1064
 
1065
+ /** @param {ReverseClientStream} stream - Durable stream state. */
1066
+ async #persistStreamState(stream) {
1067
+ await this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))
1068
+ }
1069
+
857
1070
  /** @param {ReverseClientStream} stream - Spool owner. @param {() => Promise<void>} operation - Serialized durable operation. */
858
1071
  async #queueSpoolOperation(stream, operation) {
859
1072
  const next = stream.spoolQueue.then(async () => {
@@ -880,7 +1093,10 @@ export class ReverseGatewayConnection extends EventEmitter {
880
1093
 
881
1094
  /** @param {ReverseClientStream} stream - Terminal stream. */
882
1095
  #trackSpoolCleanup(stream) {
883
- const task = this.#removeSpool(stream).catch((error) => {
1096
+ const removal = stream.ownsSpool
1097
+ ? this.#removeSpool(stream)
1098
+ : stream.spoolReady.then(() => stream.ownsSpool ? this.#removeSpool(stream) : undefined)
1099
+ const task = removal.catch((error) => {
884
1100
  if (!stream.spoolCleanupFailureRecorded) {
885
1101
  stream.spoolCleanupFailureRecorded = true
886
1102
  this.#spoolCleanupFailures += 1
@@ -927,6 +1143,71 @@ function validateOptions(options) {
927
1143
  if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
928
1144
  }
929
1145
 
1146
+ /**
1147
+ * @param {Record<string, unknown>} value - Parsed durable state.
1148
+ * @param {{connectionId: string, identifier: string, target: {host: string, port: number}, selection: {gatewayId: string} | {routeClass: string}, initialWindow: number, sessionName?: string}} expected - Caller-owned recovery identity.
1149
+ * @returns {ReverseClientRecoveryState} Validated state.
1150
+ */
1151
+ function validateRecoveryState(value, expected) {
1152
+ const state = /** @type {Partial<ReverseClientRecoveryState>} */ (value)
1153
+ const sequences = [state.generation, state.streamId, state.sendSequence, state.sendAck, state.receiveSequence, state.receiveAck]
1154
+ const validSequences = sequences.every((sequence) => Number.isSafeInteger(sequence) && /** @type {number} */ (sequence) >= 0)
1155
+ const validTarget = state.target?.host === expected.target.host && state.target?.port === expected.target.port
1156
+ const expectedState = state.opened === true ? (state.localEnded === true ? "half-closed" : "open") : "opening"
1157
+ const selectorMatches = "gatewayId" in expected.selection
1158
+ ? state.gatewayId === expected.selection.gatewayId && !Object.hasOwn(state, "routeClass")
1159
+ : state.routeClass === expected.selection.routeClass && !Object.hasOwn(state, "gatewayId")
1160
+ if (
1161
+ state.version !== 3
1162
+ || state.role !== "reverse-client"
1163
+ || state.connectionId !== expected.connectionId
1164
+ || state.identifier !== expected.identifier
1165
+ || !validSequences
1166
+ || state.generation === 0
1167
+ || state.generation === Number.MAX_SAFE_INTEGER
1168
+ || state.streamId === 0
1169
+ || /** @type {number} */ (state.streamId) % 2 === 0
1170
+ || /** @type {number} */ (state.streamId) > 0xffff_fffd
1171
+ || typeof state.name !== "string"
1172
+ || state.name.length === 0
1173
+ || state.name.length > 256
1174
+ || !validTarget
1175
+ || !selectorMatches
1176
+ || typeof state.opened !== "boolean"
1177
+ || state.openingSent !== true
1178
+ || typeof state.localEnded !== "boolean"
1179
+ || typeof state.remoteEnded !== "boolean"
1180
+ || typeof state.endSent !== "boolean"
1181
+ || (state.endSent && !state.localEnded)
1182
+ || (state.endSent && state.opened !== true)
1183
+ || (state.remoteEnded && state.opened !== true)
1184
+ || (state.opened !== true && (/** @type {number} */ (state.sendSequence) > 0 || /** @type {number} */ (state.receiveSequence) > 0))
1185
+ || state.state !== expectedState
1186
+ || /** @type {number} */ (state.sendAck) > /** @type {number} */ (state.sendSequence)
1187
+ || /** @type {number} */ (state.receiveAck) > /** @type {number} */ (state.receiveSequence)
1188
+ || /** @type {number} */ (state.receiveSequence) - /** @type {number} */ (state.receiveAck) > expected.initialWindow
1189
+ || (expected.sessionName !== undefined && state.name !== expected.sessionName)
1190
+ ) throw new Error("Invalid reverse client recovery identity, selector, lifecycle, or sequence state")
1191
+ return /** @type {ReverseClientRecoveryState} */ (state)
1192
+ }
1193
+
1194
+ /**
1195
+ * @param {ReverseClientRecoveryState} state - Validated durable state.
1196
+ * @param {import('../spool/spool-file.js').SpoolChunk[]} chunks - Durable chunks.
1197
+ * @param {number} maxBytes - Per-stream byte bound.
1198
+ * @param {number} maxFrameBytes - Per-chunk frame bound.
1199
+ */
1200
+ function validateRecoveredChunks(state, chunks, maxBytes, maxFrameBytes) {
1201
+ let sequence = state.sendAck
1202
+ let bytes = 0
1203
+ for (const chunk of chunks) {
1204
+ bytes += chunk.data.length
1205
+ if (chunk.data.length === 0 || chunk.data.length > maxFrameBytes || !Number.isSafeInteger(sequence + chunk.data.length) || chunk.sequence !== sequence + chunk.data.length || chunk.sequence > state.sendSequence) throw new Error("Invalid reverse client recovery chunk sequence")
1206
+ sequence = chunk.sequence
1207
+ }
1208
+ if (sequence !== state.sendSequence || bytes > maxBytes) throw new Error("Invalid or oversized reverse client recovery replay state")
1209
+ }
1210
+
930
1211
  /** @param {VirtualSocket} socket - Socket. @param {(error: Error | null, socket: VirtualSocket) => void} callback - Callback. */
931
1212
  function installCallback(socket, callback) {
932
1213
  let settled = false
@@ -938,9 +1219,18 @@ function installCallback(socket, callback) {
938
1219
  }
939
1220
 
940
1221
  function fatalError(/** @type {string} */ code) { return Object.assign(new Error("Reverse gateway client was rejected by the hub"), {code, fatal: true}) }
941
- function connectionError(/** @type {string} */ code) {
942
- if (code === "HUB_SHUTDOWN" || code === "GATEWAY_UNAVAILABLE" || code === "GATEWAY_DISCONNECTED" || code === "GATEWAY_REPLACED") return Object.assign(new Error("Selected reverse gateway is temporarily unavailable"), {code, fatal: false})
943
- return fatalError(code)
1222
+ function connectionError(/** @type {string} */ code, /** @type {unknown} */ attribution = undefined) {
1223
+ if (code === "HUB_SHUTDOWN" || code === "GATEWAY_UNAVAILABLE" || code === "GATEWAY_DISCONNECTED" || code === "GATEWAY_REPLACED") return attachDisconnectAttribution(Object.assign(new Error("Selected reverse gateway is temporarily unavailable"), {code, fatal: false}), attribution)
1224
+ return attachDisconnectAttribution(fatalError(code), attribution)
1225
+ }
1226
+ /** Accepts attribution only from the authenticated hub transport. */
1227
+ function validHubAttribution(/** @type {unknown} */ value) {
1228
+ const attribution = parseDisconnectAttribution(value)
1229
+ return attribution?.incidentOrigin === "reverse_hub" ? attribution : undefined
1230
+ }
1231
+ /** Extracts only the closed public attribution contract from an error. */
1232
+ function publicDisconnectAttribution(/** @type {unknown} */ error) {
1233
+ return parseDisconnectAttribution(error) ?? {}
944
1234
  }
945
1235
  /** @param {unknown} value - Advertised peer receive window. @returns {value is number} Whether valid. */
946
1236
  function validPeerWindow(value) { return Number.isSafeInteger(value) && /** @type {number} */ (value) > 0 }