socketduct 0.1.19 → 0.1.21

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 (59) hide show
  1. package/.env.example +17 -5
  2. package/README.md +3 -3
  3. package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
  4. package/changelog.d/20260925180000-manual-ack-durable-admission.md +1 -0
  5. package/docs/docker.md +5 -4
  6. package/docs/logging.md +131 -159
  7. package/docs/protocol.md +48 -7
  8. package/docs/reverse-gateway.md +83 -8
  9. package/docs/running.md +17 -13
  10. package/docs/security.md +7 -3
  11. package/docs/session-lifecycle.md +22 -0
  12. package/docs/socket-api.md +22 -17
  13. package/package.json +5 -1
  14. package/scripts/verify-zstd-interoperability.mjs +41 -0
  15. package/src/cli/socketduct.js +143 -0
  16. package/src/client/deferred-virtual-socket.js +42 -1
  17. package/src/client/multiplexed-connection.js +17 -0
  18. package/src/client/multiplexed-transport-set.js +43 -8
  19. package/src/client/multiplexed.js +1 -1
  20. package/src/client/virtual-socket.js +119 -4
  21. package/src/config/environment.js +56 -7
  22. package/src/index.js +65 -27
  23. package/src/logging/compressed-log-reader.js +256 -0
  24. package/src/logging/compressed-log-worker.js +430 -0
  25. package/src/logging/compressed-log-writer.js +538 -0
  26. package/src/logging/log-batch.js +72 -0
  27. package/src/logging/log-retention.js +241 -0
  28. package/src/logging/log-segment-index.js +176 -0
  29. package/src/logging/structured-logger.js +136 -30
  30. package/src/logging/zstd-frame-codec.js +73 -0
  31. package/src/reverse/client.js +245 -34
  32. package/src/reverse/disconnect-attribution.js +191 -0
  33. package/src/reverse/gateway.js +68 -9
  34. package/src/reverse/hub.js +215 -32
  35. package/src/reverse/runtime.js +5 -3
  36. package/src/spool/spool-file.js +92 -6
  37. package/tensorbuzz.yml +2 -1
  38. package/test/e2e/logger-shutdown-process.test.js +86 -0
  39. package/test/e2e/reverse-client-recovery-process.test.js +3 -0
  40. package/test/e2e/reverse-gateway-process.test.js +15 -14
  41. package/test/fixtures/reverse-client-recovery.mjs +14 -2
  42. package/test/integration/logging-cli.test.js +168 -0
  43. package/test/integration/logging-compressed-recovery.test.js +186 -0
  44. package/test/integration/multiplexed-transport-set.test.js +1 -5
  45. package/test/integration/reverse-gateway.test.js +334 -1
  46. package/test/integration/reverse-route-class.test.js +6 -1
  47. package/test/unit/client/multiplexed-transport-set.test.js +50 -3
  48. package/test/unit/client/virtual-socket.test.js +123 -1
  49. package/test/unit/config/docker-gateway-files.test.js +2 -0
  50. package/test/unit/config/environment.test.js +54 -14
  51. package/test/unit/index.test.js +5 -0
  52. package/test/unit/logging/compressed-log-worker.test.js +29 -0
  53. package/test/unit/logging/compressed-log-writer.test.js +183 -0
  54. package/test/unit/logging/log-retention.test.js +86 -0
  55. package/test/unit/logging/log-segment-index.test.js +104 -0
  56. package/test/unit/logging/structured-logger.test.js +14 -1
  57. package/test/unit/logging/zstd-frame-codec.test.js +24 -0
  58. package/test/unit/reverse/disconnect-attribution.test.js +82 -0
  59. package/test/unit/spool/spool-file.test.js +58 -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
@@ -62,6 +63,7 @@ const MANIFEST_PAGE_STREAMS = 64
62
63
  * @typedef {object} PendingClientWrite
63
64
  * @property {Buffer} data
64
65
  * @property {number} offset
66
+ * @property {boolean} durable
65
67
  * @property {(error?: Error | null) => void} callback
66
68
  * @typedef {object} ReverseClientStream
67
69
  * @property {number} id
@@ -79,6 +81,9 @@ const MANIFEST_PAGE_STREAMS = 64
79
81
  * @property {number} sendWindowLimit
80
82
  * @property {number} receiveSequence
81
83
  * @property {number} receiveAck
84
+ * @property {number} receiveDelivered
85
+ * @property {number} pendingReceiveAckBytes
86
+ * @property {boolean} manualReceiveAck
82
87
  * @property {Array<{sequence: number, remaining: number}>} receiveFrames
83
88
  * @property {boolean} remoteEnded
84
89
  * @property {boolean} remoteEndPending
@@ -86,8 +91,9 @@ const MANIFEST_PAGE_STREAMS = 64
86
91
  * @property {ClientChunk[]} replay
87
92
  * @property {AdaptiveCompressor} compressor
88
93
  * @property {boolean} pumping
89
- * @property {PendingClientWrite | undefined} pendingWrite
94
+ * @property {PendingClientWrite[]} pendingWrites
90
95
  * @property {boolean} progressingWrite
96
+ * @property {Promise<void> | undefined} writeTask
91
97
  * @property {boolean} localEnded
92
98
  * @property {boolean} endSent
93
99
  * @property {((error?: Error | null) => void) | undefined} endCallback
@@ -98,6 +104,7 @@ const MANIFEST_PAGE_STREAMS = 64
98
104
  * @property {boolean} spoolCleanupFailureRecorded
99
105
  * @property {boolean} ownsSpool
100
106
  * @property {boolean} recoveredObject
107
+ * @property {Promise<number>} manualAckQueue
101
108
  * @typedef {object} ReverseClientRecoveryState
102
109
  * @property {number} version
103
110
  * @property {'reverse-client'} role
@@ -119,6 +126,7 @@ const MANIFEST_PAGE_STREAMS = 64
119
126
  * @property {boolean} localEnded
120
127
  * @property {boolean} remoteEnded
121
128
  * @property {boolean} endSent
129
+ * @property {boolean} [manualReceiveAck]
122
130
  */
123
131
 
124
132
  /** Server3/TensorBuzz-facing virtual socket connection to an exact gateway or server-resolved route class. */
@@ -132,6 +140,7 @@ export class ReverseGatewayConnection extends EventEmitter {
132
140
  /** @type {number} */ #generation
133
141
  /** @type {string | undefined} */ #codec
134
142
  /** @type {boolean} */ #closing = false
143
+ /** @type {boolean} */ #detached = false
135
144
  /** @type {boolean} */ #preserveSpools = false
136
145
  /** @type {boolean} */ #fatal = false
137
146
  /** @type {Error | undefined} */ #fatalError
@@ -159,6 +168,7 @@ export class ReverseGatewayConnection extends EventEmitter {
159
168
  /** @type {number} */ #spoolBytes = 0
160
169
  /** @type {number} */ #spoolCleanupFailures = 0
161
170
  /** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
171
+ /** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */ #disconnectAttribution
162
172
 
163
173
  /** @param {ReverseGatewayConnectionOptions} options - Hub route and safety policy. */
164
174
  constructor(options) {
@@ -204,9 +214,31 @@ export class ReverseGatewayConnection extends EventEmitter {
204
214
  return this.#createConnection(options, callback, true)
205
215
  }
206
216
 
217
+ /**
218
+ * Reads and validates one durable descriptor without allocating a socket.
219
+ * @param {string} identifier - Stable stream identifier.
220
+ * @returns {{identifier: string, target: {host: string, port: number}, sessionName: string, manualReceiveAck: boolean} | undefined} Recovery descriptor.
221
+ */
222
+ _inspectRecoveredConnection(identifier) {
223
+ if (typeof identifier !== "string" || !STABLE_IDENTIFIER.test(identifier)) throw new TypeError("Reverse virtual stream identifier must be 8-128 URL-safe characters")
224
+ const value = this.#spool.findRecoveryStateSync(identifier, "reverse-client-v3")
225
+ if (value === undefined) return undefined
226
+ const target = parseTarget(/** @type {{host?: unknown, port?: unknown}} */ (value.target))
227
+ if (target === undefined) throw new Error("Invalid reverse client recovery target")
228
+ const state = validateRecoveryState(value, {
229
+ connectionId: this.#connectionId,
230
+ identifier,
231
+ target,
232
+ selection: this.#routeIdentityFields(),
233
+ initialWindow: this.#initialWindow()
234
+ })
235
+ return {identifier, target, sessionName: state.name, manualReceiveAck: state.manualReceiveAck === true}
236
+ }
237
+
207
238
  /** Creates a fresh or synchronously recovered reverse stream. */
208
239
  #createConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback, /** @type {boolean} */ recovering) {
209
240
  if (this.#closing) throw closedError()
241
+ if (options?.manualReceiveAck !== undefined && typeof options.manualReceiveAck !== "boolean") throw new TypeError("manualReceiveAck must be a boolean")
210
242
  if (!recovering && this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
211
243
  if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum reverse virtual stream count exceeded")
212
244
  const target = parseTarget(options?.target ?? {host: options?.host, port: options?.port})
@@ -220,7 +252,8 @@ export class ReverseGatewayConnection extends EventEmitter {
220
252
  target,
221
253
  selection: this.#routeIdentityFields(),
222
254
  initialWindow: this.#initialWindow(),
223
- ...(options.sessionName === undefined ? {} : {sessionName: options.sessionName})
255
+ ...(options.sessionName === undefined ? {} : {sessionName: options.sessionName}),
256
+ ...(options.manualReceiveAck === undefined ? {} : {manualReceiveAck: options.manualReceiveAck})
224
257
  })
225
258
  : undefined
226
259
  const chunks = recovery === undefined ? [] : this.#spool.loadChunksSync(identifier, "reverse-client-v3", recovery.sendAck, recovery.sendSequence)
@@ -231,11 +264,13 @@ export class ReverseGatewayConnection extends EventEmitter {
231
264
  if (this.#streams.has(id)) throw new Error(`Reverse v3 stream ID ${id} is already active`)
232
265
  const name = options.sessionName ?? recovery?.name ?? `socketduct-reverse-${id}`
233
266
  if (typeof name !== "string" || name.length === 0 || name.length > 256) throw new TypeError("Reverse virtual stream name must be 1-256 characters")
267
+ const manualReceiveAck = recovery?.manualReceiveAck ?? options.manualReceiveAck ?? false
234
268
  const socket = new VirtualSocket(/** @type {import('../client/multiplexed-connection.js').MultiplexedSocketductConnection} */ (/** @type {unknown} */ (this)), id, {
235
269
  ...target,
236
270
  ...(options.allowHalfOpen === undefined ? {} : {allowHalfOpen: options.allowHalfOpen}),
237
271
  ...(options.readableHighWaterMark === undefined ? {} : {readableHighWaterMark: options.readableHighWaterMark}),
238
- ...(options.writableHighWaterMark === undefined ? {} : {writableHighWaterMark: options.writableHighWaterMark})
272
+ ...(options.writableHighWaterMark === undefined ? {} : {writableHighWaterMark: options.writableHighWaterMark}),
273
+ manualReceiveAck
239
274
  })
240
275
  /** @type {ReverseClientStream} */
241
276
  const stream = {
@@ -252,6 +287,9 @@ export class ReverseGatewayConnection extends EventEmitter {
252
287
  // delivered. Resume inbound delivery from the persisted ACK boundary.
253
288
  receiveSequence: recovery?.receiveAck ?? 0,
254
289
  receiveAck: recovery?.receiveAck ?? 0,
290
+ receiveDelivered: recovery?.receiveAck ?? 0,
291
+ pendingReceiveAckBytes: 0,
292
+ manualReceiveAck,
255
293
  receiveFrames: [],
256
294
  remoteEnded: recovery?.remoteEnded ?? false,
257
295
  remoteEndPending: recovery?.remoteEnded ?? false,
@@ -259,8 +297,9 @@ export class ReverseGatewayConnection extends EventEmitter {
259
297
  replay: chunks.map((chunk) => ({...chunk, sent: false})),
260
298
  compressor: new AdaptiveCompressor(),
261
299
  pumping: false,
262
- pendingWrite: undefined,
300
+ pendingWrites: [],
263
301
  progressingWrite: false,
302
+ writeTask: undefined,
264
303
  localEnded: recovery?.localEnded ?? false,
265
304
  endSent: recovery?.endSent ?? false,
266
305
  endCallback: undefined,
@@ -270,7 +309,8 @@ export class ReverseGatewayConnection extends EventEmitter {
270
309
  spoolRemovalPromise: undefined,
271
310
  spoolCleanupFailureRecorded: false,
272
311
  ownsSpool: recovery !== undefined,
273
- recoveredObject: recovery !== undefined
312
+ recoveredObject: recovery !== undefined,
313
+ manualAckQueue: Promise.resolve(recovery?.receiveAck ?? 0)
274
314
  }
275
315
  if (recovery !== undefined) {
276
316
  this.#options.aggregateBudget?.reserveData(bufferedBytes)
@@ -367,6 +407,7 @@ export class ReverseGatewayConnection extends EventEmitter {
367
407
  if (this.#options.connectionId === undefined) throw new Error("detach() requires an explicit stable connectionId")
368
408
  if (this.#options.spoolDirectory === undefined) throw new Error("detach() requires an explicit persistent spoolDirectory")
369
409
  this.#preserveSpools = true
410
+ this.#detached = true
370
411
  this.#closing = true
371
412
  if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
372
413
  this.#reconnectTimer = undefined
@@ -377,7 +418,12 @@ export class ReverseGatewayConnection extends EventEmitter {
377
418
  const frameQueue = this.#physicalFrameQueue
378
419
  try {
379
420
  await frameQueue
380
- await Promise.all([...this.#streams.values()].map((stream) => this.#persistStreamState(stream)))
421
+ const streams = [...this.#streams.values()]
422
+ await Promise.all(streams.map((stream) => stream.writeTask))
423
+ await Promise.all(streams.map((stream) => stream.manualAckQueue))
424
+ const detached = new SocketductSessionLostError("Reverse connection detached before queued write admission", "SOCKETDUCT_CONNECTION_DETACHED")
425
+ for (const stream of streams) while (stream.pendingWrites.length > 0) this.#finishWrite(stream, detached)
426
+ await Promise.all(streams.filter((stream) => this.#streams.get(stream.id) === stream).map((stream) => this.#persistStreamState(stream)))
381
427
  } finally {
382
428
  physical?.destroy()
383
429
  this.#physical = undefined
@@ -389,7 +435,15 @@ export class ReverseGatewayConnection extends EventEmitter {
389
435
  _writeStream(/** @type {number} */ streamId, /** @type {Buffer} */ data, /** @type {(error?: Error | null) => void} */ callback) {
390
436
  const stream = this.#streams.get(streamId)
391
437
  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 }
392
- stream.pendingWrite = {data, offset: 0, callback}
438
+ stream.pendingWrites.push({data, offset: 0, durable: false, callback})
439
+ this.#progressPendingWrites()
440
+ }
441
+
442
+ _writeDurableStream(/** @type {number} */ streamId, /** @type {Buffer} */ data, /** @type {(error?: Error | null) => void} */ callback) {
443
+ const stream = this.#streams.get(streamId)
444
+ if (this.#detached) { callback(new SocketductSessionLostError("Reverse connection is detached", "SOCKETDUCT_CONNECTION_DETACHED")); return }
445
+ 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 }
446
+ stream.pendingWrites.push({data, offset: 0, durable: true, callback})
393
447
  this.#progressPendingWrites()
394
448
  }
395
449
 
@@ -411,7 +465,8 @@ export class ReverseGatewayConnection extends EventEmitter {
411
465
  streamId,
412
466
  generation: this.#generation,
413
467
  ...this.#routeIdentityFields(),
414
- ...(error === null ? {} : errorDiagnostics(error))
468
+ ...(error === null ? {} : errorDiagnostics(error)),
469
+ ...(error === null ? {} : publicDisconnectAttribution(error))
415
470
  })
416
471
  this.#retireStream(stream, error ?? new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED"))
417
472
  if (!wasClosed && !this.#closing && this.#state === "ready") void this.#sendControl(V3FrameType.RESET, streamId, 0, {code: error instanceof SocketductStreamError ? error.code : "CLIENT_CANCELLED"}).catch(() => {})
@@ -421,9 +476,17 @@ export class ReverseGatewayConnection extends EventEmitter {
421
476
  void streamId
422
477
  }
423
478
 
479
+ _deliverReadable(/** @type {number} */ streamId, /** @type {number} */ bytes) {
480
+ const stream = this.#streams.get(streamId)
481
+ if (stream === undefined || !stream.manualReceiveAck || !Number.isSafeInteger(bytes) || bytes <= 0) return
482
+ stream.receiveDelivered += bytes
483
+ if (stream.receiveDelivered > stream.receiveSequence) throw new Error("Reverse client readable delivery accounting overflow")
484
+ }
485
+
424
486
  _consumeReadable(/** @type {number} */ streamId, /** @type {number} */ bytes) {
425
487
  const stream = this.#streams.get(streamId)
426
488
  if (stream === undefined || !Number.isSafeInteger(bytes) || bytes <= 0) return
489
+ if (stream.manualReceiveAck) return
427
490
  let remaining = bytes
428
491
  let acknowledgedSequence = stream.receiveAck
429
492
  while (remaining > 0 && stream.receiveFrames.length > 0) {
@@ -440,6 +503,27 @@ export class ReverseGatewayConnection extends EventEmitter {
440
503
  this.#ackReceived(stream, acknowledgedSequence)
441
504
  }
442
505
 
506
+ _acknowledgeReadable(/** @type {number} */ streamId, /** @type {number} */ bytes) {
507
+ const stream = this.#streams.get(streamId)
508
+ if (this.#detached) return Promise.reject(new SocketductStreamError("Reverse connection is detached", "SOCKETDUCT_CONNECTION_DETACHED"))
509
+ if (this.#closing) return Promise.reject(closedError())
510
+ if (stream === undefined) return Promise.reject(new SocketductStreamError("Reverse stream is destroyed", "ERR_STREAM_DESTROYED"))
511
+ if (!stream.manualReceiveAck) return Promise.reject(new SocketductStreamError("Manual receive acknowledgement is not enabled", "SOCKETDUCT_MANUAL_ACK_DISABLED"))
512
+ if (stream.receiveAck + stream.pendingReceiveAckBytes + bytes > stream.receiveDelivered) throw new SocketductStreamError("Receive acknowledgement exceeds bytes delivered to the application", "SOCKETDUCT_INVALID_RECEIVE_ACK")
513
+ stream.pendingReceiveAckBytes += bytes
514
+ const acknowledged = stream.manualAckQueue.then(async () => {
515
+ const sequence = stream.receiveAck + bytes
516
+ await this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream, {receiveAck: sequence})))
517
+ stream.receiveAck = sequence
518
+ stream.pendingReceiveAckBytes -= bytes
519
+ await this.#sendControl(V3FrameType.ACK, stream.id, sequence, {}).catch(() => {})
520
+ return sequence
521
+ })
522
+ stream.manualAckQueue = acknowledged
523
+ void acknowledged.catch((error) => stream.socket._receiveError(asError(error)))
524
+ return acknowledged
525
+ }
526
+
443
527
  #allocateStreamId() {
444
528
  const id = this.#nextStreamId
445
529
  if (id > 0xffff_fffd) throw new Error("Reverse v3 stream ID space exhausted")
@@ -483,9 +567,18 @@ export class ReverseGatewayConnection extends EventEmitter {
483
567
  this.#physicalFrameQueue = queue
484
568
  let authenticated = false
485
569
  let selected = false
570
+ /** @type {import('./disconnect-attribution.js').DisconnectReason | undefined} */
571
+ let attemptDisconnectReason
572
+ /** @type {string | undefined} */
573
+ let attemptSafeSocketCode
574
+ /** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */
575
+ let attemptAttribution
486
576
  /** @type {Error | undefined} */
487
577
  let attemptError
488
- 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)
578
+ const timer = setTimeout(() => {
579
+ attemptDisconnectReason ??= "heartbeat_timeout"
580
+ physical.destroy(Object.assign(new Error("Reverse client TLS/auth timeout"), {code: "SOCKETDUCT_AUTH_TIMEOUT"}))
581
+ }, this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
489
582
  timer.unref?.()
490
583
  physical.once("secureConnect", () => {
491
584
  // The attempted generation must survive a crash after AUTH reaches the
@@ -505,13 +598,16 @@ export class ReverseGatewayConnection extends EventEmitter {
505
598
  if (this.#closing || this.#physical !== physical) return
506
599
  if (frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.GOAWAY) {
507
600
  const detail = decodeV3Control(frame.payload)
508
- throw connectionError(typeof detail.code === "string" ? detail.code : "CONNECTION_REJECTED")
601
+ if (frame.type === V3FrameType.GOAWAY) attemptDisconnectReason ??= "peer_goaway"
602
+ attemptAttribution = validHubAttribution(detail) ?? attemptAttribution
603
+ throw connectionError(typeof detail.code === "string" ? detail.code : "CONNECTION_REJECTED", attemptAttribution)
509
604
  }
510
605
  if (!authenticated) {
511
606
  if (frame.type !== V3FrameType.AUTH_OK) throw fatalError("AUTH_NEGOTIATION_FAILED")
512
607
  const detail = decodeV3Control(frame.payload)
513
608
  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")
514
609
  authenticated = true
610
+ this.#disconnectAttribution = undefined
515
611
  this.#codec = ZSTD_CODEC
516
612
  this.#sendWindowLimit = Math.min(this.#initialWindow(), detail.initialWindow)
517
613
  for (const stream of this.#streams.values()) {
@@ -544,12 +640,19 @@ export class ReverseGatewayConnection extends EventEmitter {
544
640
  }
545
641
  }).catch((error) => {
546
642
  attemptError = asError(error)
643
+ attemptDisconnectReason ??= "protocol_violation"
547
644
  if (/** @type {{fatal?: unknown}} */ (attemptError).fatal === true) this.#fatal = true
548
645
  physical.destroy(attemptError)
549
646
  }).finally(() => { if (!physical.destroyed) physical.resume() })
550
647
  this.#physicalFrameQueue = queue
551
648
  })
552
- physical.once("error", (error) => { attemptError = error; if (isFatalTransportError(error)) this.#fatal = true })
649
+ physical.once("end", () => { attemptDisconnectReason ??= "remote_eof" })
650
+ physical.once("error", (error) => {
651
+ attemptError = error
652
+ attemptDisconnectReason ??= "remote_socket_error"
653
+ attemptSafeSocketCode ??= safeSocketCode(error)
654
+ if (isFatalTransportError(error)) this.#fatal = true
655
+ })
553
656
  physical.once("close", () => {
554
657
  clearTimeout(timer)
555
658
  void queue.finally(() => {
@@ -561,11 +664,28 @@ export class ReverseGatewayConnection extends EventEmitter {
561
664
  this.#resumeNextPage = 0
562
665
  if (!this.#closing && !this.#fatal) this.#setState("reconnecting")
563
666
  const lost = new SocketductSessionLostError("Reverse gateway physical transport was interrupted", "GATEWAY_DISCONNECTED")
667
+ const attribution = attemptAttribution ?? this.#disconnectAttribution ?? createDisconnectAttribution({
668
+ disconnectReason: attemptDisconnectReason ?? "unknown_transport_close",
669
+ incidentOrigin: "reverse_client",
670
+ physicalRole: "client",
671
+ clientConnectionGeneration: generation,
672
+ fanOutCount: this.#streams.size,
673
+ resumeSuccessCount: 0,
674
+ terminalStreamCount: 0
675
+ })
676
+ this.#disconnectAttribution = attribution
564
677
  if (!this.#closing) {
678
+ logDiagnostic(this.#options.logger, "reverse_client.physical_detached", {
679
+ ...this.#routeIdentityFields(),
680
+ ...attribution,
681
+ ...(attemptSafeSocketCode === undefined ? {} : {safeSocketCode: attemptSafeSocketCode})
682
+ })
565
683
  logDiagnostic(this.#options.logger, "transport.disconnected", {
566
- generation,
684
+ clientConnectionGeneration: generation,
567
685
  ...this.#routeIdentityFields(),
568
686
  virtualStreams: this.#streams.size,
687
+ ...attribution,
688
+ ...(attemptSafeSocketCode === undefined ? {} : {safeSocketCode: attemptSafeSocketCode}),
569
689
  ...errorDiagnostics(attemptError ?? lost, "GATEWAY_DISCONNECTED")
570
690
  })
571
691
  }
@@ -647,7 +767,7 @@ export class ReverseGatewayConnection extends EventEmitter {
647
767
  if (this.#closing || this.#physical !== physical) return
648
768
  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")
649
769
  stream.receiveSequence = frame.sequence
650
- stream.receiveFrames.push({sequence: frame.sequence, remaining: data.length})
770
+ if (!stream.manualReceiveAck) stream.receiveFrames.push({sequence: frame.sequence, remaining: data.length})
651
771
  stream.socket._receive(data)
652
772
  return
653
773
  }
@@ -681,7 +801,7 @@ export class ReverseGatewayConnection extends EventEmitter {
681
801
  const detail = decodeV3Control(frame.payload)
682
802
  const code = typeof detail.code === "string" ? detail.code : "STREAM_RESET"
683
803
  stream.state = "reset"
684
- const error = new SocketductStreamError("Reverse gateway stream was reset", code)
804
+ const error = attachDisconnectAttribution(new SocketductStreamError("Reverse gateway stream was reset", code), validHubAttribution(detail) ?? (code === "GATEWAY_DISCONNECTED" ? this.#disconnectAttribution : undefined))
685
805
  stream.socket._receiveError(error)
686
806
  return
687
807
  }
@@ -725,7 +845,7 @@ export class ReverseGatewayConnection extends EventEmitter {
725
845
  if (stream === undefined) return
726
846
  if (result.status !== "resumed") {
727
847
  const code = typeof result.code === "string" ? result.code : "RESUME_REJECTED"
728
- stream.socket._receiveError(new SocketductSessionLostError("Reverse gateway stream could not be resumed", code))
848
+ stream.socket._receiveError(attachDisconnectAttribution(new SocketductSessionLostError("Reverse gateway stream could not be resumed", code), validHubAttribution(result) ?? this.#disconnectAttribution))
729
849
  return
730
850
  }
731
851
  const clientToGatewaySequence = /** @type {number} */ (result.clientToGatewaySequence)
@@ -820,10 +940,18 @@ export class ReverseGatewayConnection extends EventEmitter {
820
940
 
821
941
  /** @param {ReverseClientStream} stream - Stream with an active application write. */
822
942
  async #progressWrite(stream) {
823
- const pending = stream.pendingWrite
943
+ const pending = stream.pendingWrites[0]
824
944
  if (pending === undefined || stream.progressingWrite || stream.state === "opening") return
825
945
  stream.progressingWrite = true
826
946
  try {
947
+ if (pending.durable) {
948
+ await this.#admitDurableWrite(stream, pending)
949
+ if (stream.pendingWrites[0] === pending) this.#finishWrite(stream)
950
+ queueMicrotask(() => {
951
+ void this.#pumpStream(stream).catch((error) => stream.socket._receiveError(asError(error)))
952
+ })
953
+ return
954
+ }
827
955
  if (pending.offset < pending.data.length && this.#streams.get(stream.id) === stream) {
828
956
  const available = this.#writeCapacity(stream)
829
957
  if (available <= 0) return
@@ -847,17 +975,65 @@ export class ReverseGatewayConnection extends EventEmitter {
847
975
  })
848
976
  }
849
977
  await this.#pumpStream(stream)
850
- if (pending.offset === pending.data.length && stream.pendingWrite === pending) this.#finishWrite(stream)
978
+ if (pending.offset === pending.data.length && stream.pendingWrites[0] === pending) this.#finishWrite(stream)
851
979
  } catch (error) {
852
980
  const failure = asError(error)
853
981
  this.#finishWrite(stream, failure)
854
- stream.socket._receiveError(failure)
982
+ if (!pending.durable) stream.socket._receiveError(failure)
855
983
  } finally {
856
984
  stream.progressingWrite = false
857
985
  this.#progressPendingWrites()
858
986
  }
859
987
  }
860
988
 
989
+ /** @param {ReverseClientStream} stream - Stream. @param {PendingClientWrite} pending - Complete durable operation. */
990
+ async #admitDurableWrite(stream, pending) {
991
+ const bytes = pending.data.length
992
+ if (bytes === 0) return
993
+ const streamCapacity = (this.#options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES) - stream.bufferedBytes
994
+ const connectionCapacity = (this.#options.maxConnectionBufferBytes ?? DEFAULT_CONNECTION_BUFFER_BYTES) - this.#bufferedBytes
995
+ if (bytes > streamCapacity || bytes > connectionCapacity || !Number.isSafeInteger(stream.sendSequence + bytes)) throw new SocketductStreamError("Complete durable write exceeds available retained-byte capacity", "SOCKETDUCT_DURABLE_WRITE_LIMIT")
996
+ let reserved = false
997
+ try {
998
+ this.#options.aggregateBudget?.reserveData(bytes)
999
+ reserved = true
1000
+ let sequence = stream.sendSequence
1001
+ /** @type {ClientChunk[]} */
1002
+ const chunks = []
1003
+ for (let offset = 0; offset < bytes; offset += this.#maxFrameBytes()) {
1004
+ const data = Buffer.from(pending.data.subarray(offset, Math.min(bytes, offset + this.#maxFrameBytes())))
1005
+ sequence += data.length
1006
+ chunks.push({sequence, data, sent: false})
1007
+ }
1008
+ await this.#queueSpoolOperation(stream, () => this.#spool.appendChunksAndRecoveryState({
1009
+ sessionId: stream.identifier,
1010
+ ownerId: "reverse-client-v3",
1011
+ previousSequence: stream.sendSequence,
1012
+ chunks,
1013
+ recoveryState: this.#recoveryState(stream, {sendSequence: sequence})
1014
+ }))
1015
+ if (this.#streams.get(stream.id) !== stream) throw new SocketductStreamError("Reverse stream was destroyed during durable admission", "ERR_STREAM_DESTROYED")
1016
+ stream.sendSequence = sequence
1017
+ stream.replay.push(...chunks)
1018
+ stream.bufferedBytes += bytes
1019
+ this.#bufferedBytes += bytes
1020
+ stream.spoolReservedBytes += bytes
1021
+ this.#spoolBytes += bytes
1022
+ pending.offset = bytes
1023
+ } catch (error) {
1024
+ if (reserved) {
1025
+ this.#options.aggregateBudget?.releaseMemory(bytes)
1026
+ this.#options.aggregateBudget?.releaseSpool(bytes)
1027
+ }
1028
+ const failure = asError(error)
1029
+ const code = /** @type {{code?: unknown}} */ (failure).code
1030
+ if (failure instanceof SocketductStreamError || (typeof code === "string" && code.startsWith("SOCKETDUCT_SET_"))) throw failure
1031
+ const wrapped = new SocketductStreamError("Durable write persistence failed before admission", "SOCKETDUCT_DURABLE_WRITE_PERSISTENCE")
1032
+ wrapped.cause = failure
1033
+ throw wrapped
1034
+ }
1035
+ }
1036
+
861
1037
  /** @param {ReverseClientStream} stream - Stream. @returns {number} Retained-byte headroom for its next frame. */
862
1038
  #writeCapacity(stream) {
863
1039
  return Math.max(0, Math.min(
@@ -870,25 +1046,27 @@ export class ReverseGatewayConnection extends EventEmitter {
870
1046
 
871
1047
  /** Starts one bounded pending-write slice using rotating stream priority. */
872
1048
  #progressPendingWrites() {
1049
+ if (this.#closing) return
873
1050
  const streams = [...this.#streams.values()]
874
1051
  if (streams.length === 0) return
875
1052
  const previousIndex = streams.findIndex((stream) => stream.id === this.#lastProgressedStreamId)
876
1053
  const startIndex = previousIndex === -1 ? 0 : (previousIndex + 1) % streams.length
877
1054
  for (let offset = 0; offset < streams.length; offset += 1) {
878
1055
  const stream = /** @type {ReverseClientStream} */ (streams[(startIndex + offset) % streams.length])
879
- const pending = stream.pendingWrite
880
- if (pending === undefined || stream.progressingWrite || stream.state === "opening" || (pending.offset < pending.data.length && this.#writeCapacity(stream) <= 0)) continue
1056
+ const pending = stream.pendingWrites[0]
1057
+ if (pending === undefined || stream.progressingWrite || stream.state === "opening" || (!pending.durable && pending.offset < pending.data.length && this.#writeCapacity(stream) <= 0)) continue
881
1058
  this.#lastProgressedStreamId = stream.id
882
- void this.#progressWrite(stream)
1059
+ const task = this.#progressWrite(stream)
1060
+ stream.writeTask = task
1061
+ void task.finally(() => { if (stream.writeTask === task) stream.writeTask = undefined })
883
1062
  return
884
1063
  }
885
1064
  }
886
1065
 
887
1066
  /** @param {ReverseClientStream} stream - Stream. @param {Error} [error] - Write failure. */
888
1067
  #finishWrite(stream, error) {
889
- const pending = stream.pendingWrite
1068
+ const pending = stream.pendingWrites.shift()
890
1069
  if (pending === undefined) return
891
- stream.pendingWrite = undefined
892
1070
  pending.callback(error)
893
1071
  }
894
1072
 
@@ -906,7 +1084,7 @@ export class ReverseGatewayConnection extends EventEmitter {
906
1084
  this.#options.aggregateBudget?.releaseMemory(stream.bufferedBytes)
907
1085
  stream.replay = []
908
1086
  stream.bufferedBytes = 0
909
- this.#finishWrite(stream, pendingWriteError)
1087
+ while (stream.pendingWrites.length > 0) this.#finishWrite(stream, pendingWriteError)
910
1088
  if (!this.#preserveSpools) this.#trackSpoolCleanup(stream)
911
1089
  this.#progressPendingWrites()
912
1090
  }
@@ -918,7 +1096,16 @@ export class ReverseGatewayConnection extends EventEmitter {
918
1096
  const delta = sequence - stream.sendAck
919
1097
  stream.sendAck = sequence
920
1098
  let released = 0
921
- stream.replay = stream.replay.filter((chunk) => { if (chunk.sequence <= sequence) { released += chunk.data.length; return false } return true })
1099
+ stream.replay = stream.replay.flatMap((chunk) => {
1100
+ const start = chunk.sequence - chunk.data.length
1101
+ if (chunk.sequence <= sequence) { released += chunk.data.length; return [] }
1102
+ if (start < sequence) {
1103
+ const acknowledgedBytes = sequence - start
1104
+ released += acknowledgedBytes
1105
+ return [{...chunk, data: Buffer.from(chunk.data.subarray(acknowledgedBytes))}]
1106
+ }
1107
+ return [chunk]
1108
+ })
922
1109
  stream.bufferedBytes = Math.max(0, stream.bufferedBytes - released)
923
1110
  this.#bufferedBytes = Math.max(0, this.#bufferedBytes - released)
924
1111
  this.#options.aggregateBudget?.releaseMemory(released)
@@ -954,7 +1141,19 @@ export class ReverseGatewayConnection extends EventEmitter {
954
1141
  /** @param {number} type - Type. @param {number} streamId - Stream. @param {number} sequence - Sequence. @param {Record<string, unknown>} body - Body. */
955
1142
  async #sendControl(type, streamId, sequence, body) {
956
1143
  const physical = this.#physical
957
- if (physical === undefined || physical.destroyed) throw new SocketductSessionLostError("Reverse gateway transport is unavailable", "GATEWAY_DISCONNECTED")
1144
+ if (physical === undefined || physical.destroyed) {
1145
+ this.#disconnectAttribution ??= createDisconnectAttribution({
1146
+ disconnectReason: "send_after_transport_loss",
1147
+ incidentOrigin: "reverse_client",
1148
+ physicalRole: "client",
1149
+ clientConnectionGeneration: this.#generation,
1150
+ fanOutCount: this.#streams.size,
1151
+ resumeSuccessCount: 0,
1152
+ terminalStreamCount: 0
1153
+ })
1154
+ logDiagnostic(this.#options.logger, "reverse_client.send_after_transport_loss", {...this.#routeIdentityFields(), ...this.#disconnectAttribution})
1155
+ throw attachDisconnectAttribution(new SocketductSessionLostError("Reverse gateway transport is unavailable", "GATEWAY_DISCONNECTED"), this.#disconnectAttribution)
1156
+ }
958
1157
  return await writeSocket(physical, encodeV3Frame({type, streamId, sequence}, encodeV3Control(body)))
959
1158
  }
960
1159
 
@@ -986,8 +1185,8 @@ export class ReverseGatewayConnection extends EventEmitter {
986
1185
  }
987
1186
  }
988
1187
 
989
- /** @param {ReverseClientStream} stream - Durable stream state. */
990
- #recoveryState(stream) {
1188
+ /** @param {ReverseClientStream} stream - Durable stream state. @param {{sendSequence?: number, receiveAck?: number}} [overrides] - Commit-boundary overrides. */
1189
+ #recoveryState(stream, overrides = {}) {
991
1190
  return {
992
1191
  version: 3,
993
1192
  role: "reverse-client",
@@ -1001,10 +1200,11 @@ export class ReverseGatewayConnection extends EventEmitter {
1001
1200
  state: stream.state,
1002
1201
  opened: stream.opened,
1003
1202
  openingSent: stream.openingSent,
1004
- sendSequence: stream.sendSequence,
1203
+ sendSequence: overrides.sendSequence ?? stream.sendSequence,
1005
1204
  sendAck: stream.sendAck,
1006
1205
  receiveSequence: stream.receiveSequence,
1007
- receiveAck: stream.receiveAck,
1206
+ receiveAck: overrides.receiveAck ?? stream.receiveAck,
1207
+ manualReceiveAck: stream.manualReceiveAck,
1008
1208
  localEnded: stream.localEnded,
1009
1209
  remoteEnded: stream.remoteEnded,
1010
1210
  endSent: stream.endSent
@@ -1094,7 +1294,7 @@ function validateOptions(options) {
1094
1294
 
1095
1295
  /**
1096
1296
  * @param {Record<string, unknown>} value - Parsed durable state.
1097
- * @param {{connectionId: string, identifier: string, target: {host: string, port: number}, selection: {gatewayId: string} | {routeClass: string}, initialWindow: number, sessionName?: string}} expected - Caller-owned recovery identity.
1297
+ * @param {{connectionId: string, identifier: string, target: {host: string, port: number}, selection: {gatewayId: string} | {routeClass: string}, initialWindow: number, sessionName?: string, manualReceiveAck?: boolean}} expected - Caller-owned recovery identity.
1098
1298
  * @returns {ReverseClientRecoveryState} Validated state.
1099
1299
  */
1100
1300
  function validateRecoveryState(value, expected) {
@@ -1127,6 +1327,7 @@ function validateRecoveryState(value, expected) {
1127
1327
  || typeof state.localEnded !== "boolean"
1128
1328
  || typeof state.remoteEnded !== "boolean"
1129
1329
  || typeof state.endSent !== "boolean"
1330
+ || (state.manualReceiveAck !== undefined && typeof state.manualReceiveAck !== "boolean")
1130
1331
  || (state.endSent && !state.localEnded)
1131
1332
  || (state.endSent && state.opened !== true)
1132
1333
  || (state.remoteEnded && state.opened !== true)
@@ -1136,6 +1337,7 @@ function validateRecoveryState(value, expected) {
1136
1337
  || /** @type {number} */ (state.receiveAck) > /** @type {number} */ (state.receiveSequence)
1137
1338
  || /** @type {number} */ (state.receiveSequence) - /** @type {number} */ (state.receiveAck) > expected.initialWindow
1138
1339
  || (expected.sessionName !== undefined && state.name !== expected.sessionName)
1340
+ || (expected.manualReceiveAck !== undefined && (state.manualReceiveAck === true) !== expected.manualReceiveAck)
1139
1341
  ) throw new Error("Invalid reverse client recovery identity, selector, lifecycle, or sequence state")
1140
1342
  return /** @type {ReverseClientRecoveryState} */ (state)
1141
1343
  }
@@ -1168,9 +1370,18 @@ function installCallback(socket, callback) {
1168
1370
  }
1169
1371
 
1170
1372
  function fatalError(/** @type {string} */ code) { return Object.assign(new Error("Reverse gateway client was rejected by the hub"), {code, fatal: true}) }
1171
- function connectionError(/** @type {string} */ code) {
1172
- 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})
1173
- return fatalError(code)
1373
+ function connectionError(/** @type {string} */ code, /** @type {unknown} */ attribution = undefined) {
1374
+ 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)
1375
+ return attachDisconnectAttribution(fatalError(code), attribution)
1376
+ }
1377
+ /** Accepts attribution only from the authenticated hub transport. */
1378
+ function validHubAttribution(/** @type {unknown} */ value) {
1379
+ const attribution = parseDisconnectAttribution(value)
1380
+ return attribution?.incidentOrigin === "reverse_hub" ? attribution : undefined
1381
+ }
1382
+ /** Extracts only the closed public attribution contract from an error. */
1383
+ function publicDisconnectAttribution(/** @type {unknown} */ error) {
1384
+ return parseDisconnectAttribution(error) ?? {}
1174
1385
  }
1175
1386
  /** @param {unknown} value - Advertised peer receive window. @returns {value is number} Whether valid. */
1176
1387
  function validPeerWindow(value) { return Number.isSafeInteger(value) && /** @type {number} */ (value) > 0 }