socketduct 0.1.17 → 0.1.18

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.
@@ -1,11 +1,14 @@
1
1
  /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-jsdoc, jsdoc/require-param, jsdoc/require-property-description, jsdoc/require-returns */
2
2
  import {randomBytes} from "node:crypto"
3
3
  import {EventEmitter} from "node:events"
4
+ import {tmpdir} from "node:os"
5
+ import {join} from "node:path"
4
6
 
5
7
  import {availableCodecs, getMaxDecompressedBytes, ZSTD_CODEC, zstdAvailable} from "../protocol/compression.js"
6
8
  import {errorDiagnostics, logDiagnostic} from "../logging/structured-logger.js"
7
9
  import {decodeV3Data, encodeV3Data, AdaptiveCompressor} from "../protocol/v3-compression.js"
8
10
  import {decodeV3Control, encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../protocol/v3-frame.js"
11
+ import {SessionSpool} from "../spool/spool-file.js"
9
12
  import {SocketductSessionLostError, SocketductStreamError, VirtualSocket} from "../client/virtual-socket.js"
10
13
  import {
11
14
  asError,
@@ -27,6 +30,7 @@ import {
27
30
 
28
31
  const DEFAULT_STREAM_BUFFER_BYTES = 4 * 1024 * 1024
29
32
  const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
33
+ const MANIFEST_PAGE_STREAMS = 64
30
34
 
31
35
  /**
32
36
  * @typedef {object} ReverseGatewayConnectionOptions
@@ -40,6 +44,8 @@ const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
40
44
  * @property {number} [maxStreams] - Concurrent stream limit.
41
45
  * @property {number} [maxStreamBufferBytes] - Per-stream client output bound.
42
46
  * @property {number} [maxConnectionBufferBytes] - Aggregate client output bound.
47
+ * @property {string} [spoolDirectory] - Durable replay spool root.
48
+ * @property {number} [maxAgeMs] - Maximum reverse-stream spool age.
43
49
  * @property {number} [connectTimeoutMs] - TLS/auth deadline.
44
50
  * @property {number} [reconnectDelayMs] - Initial reconnect delay.
45
51
  * @property {number} [maxReconnectDelayMs] - Maximum reconnect delay.
@@ -82,6 +88,11 @@ const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
82
88
  * @property {boolean} localEnded
83
89
  * @property {boolean} endSent
84
90
  * @property {((error?: Error | null) => void) | undefined} endCallback
91
+ * @property {Promise<void>} spoolReady
92
+ * @property {Promise<void>} spoolQueue
93
+ * @property {number} spoolReservedBytes
94
+ * @property {Promise<void> | undefined} spoolRemovalPromise
95
+ * @property {boolean} spoolCleanupFailureRecorded
85
96
  */
86
97
 
87
98
  /** Server3/TensorBuzz-facing virtual socket connection to an exact gateway or server-resolved route class. */
@@ -99,6 +110,9 @@ export class ReverseGatewayConnection extends EventEmitter {
99
110
  /** @type {Error | undefined} */ #fatalError
100
111
  /** @type {boolean} */ #referenced = true
101
112
  /** @type {boolean} */ #warm = false
113
+ /** @type {boolean} */ #resuming = false
114
+ /** @type {Array<Record<string, unknown>>} */ #resumeResults = []
115
+ /** @type {number} */ #resumeNextPage = 0
102
116
  /** @type {ReturnType<typeof setTimeout> | undefined} */ #reconnectTimer
103
117
  /** @type {Promise<void> | undefined} */ #connectPromise
104
118
  /** @type {(() => void) | undefined} */ #resolveConnect
@@ -112,6 +126,11 @@ export class ReverseGatewayConnection extends EventEmitter {
112
126
  /** @type {number} */ #rawDataBytes = 0
113
127
  /** @type {number} */ #wireDataBytes = 0
114
128
  /** @type {number} */ #lastProgressedStreamId = 0
129
+ /** @type {Promise<void>} */ #openQueue = Promise.resolve()
130
+ /** @type {SessionSpool} */ #spool
131
+ /** @type {number} */ #spoolBytes = 0
132
+ /** @type {number} */ #spoolCleanupFailures = 0
133
+ /** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
115
134
 
116
135
  /** @param {ReverseGatewayConnectionOptions} options - Hub route and safety policy. */
117
136
  constructor(options) {
@@ -119,6 +138,11 @@ export class ReverseGatewayConnection extends EventEmitter {
119
138
  validateOptions(options)
120
139
  this.#options = options
121
140
  this.#connectionId = options.connectionId ?? randomBytes(24).toString("base64url")
141
+ this.#spool = new SessionSpool({
142
+ rootDir: options.spoolDirectory ?? join(tmpdir(), `socketduct-${typeof process.getuid === "function" ? process.getuid() : "user"}`, "reverse-client", this.#connectionId),
143
+ maxBytes: options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES,
144
+ maxAgeMs: options.maxAgeMs ?? 24 * 60 * 60 * 1000
145
+ })
122
146
  this.#generation = options.initialGeneration ?? Date.now() * 1000
123
147
  this.#reconnectDelay = options.reconnectDelayMs ?? 250
124
148
  this.#sendWindowLimit = this.#initialWindow()
@@ -176,9 +200,17 @@ export class ReverseGatewayConnection extends EventEmitter {
176
200
  progressingWrite: false,
177
201
  localEnded: false,
178
202
  endSent: false,
179
- endCallback: undefined
203
+ endCallback: undefined,
204
+ spoolReady: Promise.resolve(),
205
+ spoolQueue: Promise.resolve(),
206
+ spoolReservedBytes: 0,
207
+ spoolRemovalPromise: undefined,
208
+ spoolCleanupFailureRecorded: false
180
209
  }
181
210
  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)))
213
+ stream.spoolQueue = stream.spoolReady.catch(() => {})
182
214
  logDiagnostic(this.#options.logger, "virtual_stream.opening", {
183
215
  streamId: id,
184
216
  generation: this.#generation,
@@ -186,13 +218,17 @@ export class ReverseGatewayConnection extends EventEmitter {
186
218
  recovered: false
187
219
  })
188
220
  if (callback !== undefined) installCallback(socket, callback)
189
- void this.#sendControl(V3FrameType.OPEN, id, 0, {...this.#routeIdentityFields(), identifier, name, target, initialWindow: this.#initialWindow()}).catch((error) => socket._receiveError(asError(error)))
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)))
190
226
  return socket
191
227
  }
192
228
 
193
- /** Reverse established operations are intentionally not recoverable across process/transport loss. */
229
+ /** Process replacement cannot inherit the live reverse target/app socket pair. */
194
230
  registerRecoveredConnection() {
195
- throw Object.assign(new Error("Reverse gateway streams cannot be recovered; reconcile the Docker operation before retrying"), {code: "REVERSE_STREAM_RECOVERY_UNSUPPORTED"})
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"})
196
232
  }
197
233
 
198
234
  status() {
@@ -207,8 +243,8 @@ export class ReverseGatewayConnection extends EventEmitter {
207
243
  queuedBytes: this.#bufferedBytes,
208
244
  dataQueuedBytes: this.#bufferedBytes,
209
245
  controlQueuedBytes: 0,
210
- spoolBytes: 0,
211
- spoolCleanupFailures: 0,
246
+ spoolBytes: this.#spoolBytes,
247
+ spoolCleanupFailures: this.#spoolCleanupFailures,
212
248
  physicalConnects: this.#physicalConnects,
213
249
  reconnectAttempts: this.#reconnectAttempts,
214
250
  reconnectSuccesses: Math.max(0, this.#physicalConnects - 1),
@@ -239,9 +275,12 @@ export class ReverseGatewayConnection extends EventEmitter {
239
275
  }
240
276
  this.#rejectConnect?.(closedError())
241
277
  this.#settleConnect()
278
+ const cleanup = await Promise.allSettled([...this.#spoolCleanupTasks])
279
+ const failures = cleanup.filter((result) => result.status === "rejected").map((result) => asError(result.reason))
280
+ if (failures.length > 0) throw new AggregateError(failures, "Reverse client spool cleanup failed")
242
281
  }
243
282
 
244
- /** Detach is terminal because ambiguous reverse streams are never durably replayed. */
283
+ /** Orderly object detach is terminal; in-object physical reconnect uses paired resume. */
245
284
  detach() { return this.close() }
246
285
 
247
286
  _writeStream(/** @type {number} */ streamId, /** @type {Buffer} */ data, /** @type {(error?: Error | null) => void} */ callback) {
@@ -356,28 +395,21 @@ export class ReverseGatewayConnection extends EventEmitter {
356
395
  const detail = decodeV3Control(frame.payload)
357
396
  const selection = this.#routeIdentityFields()
358
397
  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
+ if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_SELECTION_FAILED")
359
399
  selected = true
360
- clearTimeout(timer)
361
- this.#physicalConnects += 1
362
- this.#reconnectDelay = this.#options.reconnectDelayMs ?? 250
363
- this.#setState("ready")
364
- logDiagnostic(this.#options.logger, "transport.connected", {
365
- generation,
366
- attempt: this.#physicalAttempts,
367
- ...this.#routeIdentityFields(),
368
- physicalConnects: this.#physicalConnects,
369
- reconnect: this.#physicalConnects > 1
370
- })
371
- if (this.#physicalConnects > 1) {
372
- logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
373
- generation,
374
- attempt: this.#reconnectAttempts,
375
- ...this.#routeIdentityFields(),
376
- physicalConnects: this.#physicalConnects
377
- })
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
+ }
410
+ } else {
411
+ this.#completePhysical(generation, timer)
378
412
  }
379
- this.#resolveConnect?.()
380
- this.#settleConnect()
381
413
  continue
382
414
  }
383
415
  await this.#handleFrame(frame)
@@ -395,8 +427,11 @@ export class ReverseGatewayConnection extends EventEmitter {
395
427
  if (this.#physical !== physical) return
396
428
  this.#physical = undefined
397
429
  this.#codec = undefined
430
+ this.#resuming = false
431
+ this.#resumeResults = []
432
+ this.#resumeNextPage = 0
398
433
  if (!this.#closing && !this.#fatal) this.#setState("reconnecting")
399
- const lost = new SocketductSessionLostError("Reverse gateway transport was lost; operation outcome may be uncertain", "GATEWAY_DISCONNECTED")
434
+ const lost = new SocketductSessionLostError("Reverse gateway physical transport was interrupted", "GATEWAY_DISCONNECTED")
400
435
  if (!this.#closing) {
401
436
  logDiagnostic(this.#options.logger, "transport.disconnected", {
402
437
  generation,
@@ -405,7 +440,10 @@ export class ReverseGatewayConnection extends EventEmitter {
405
440
  ...errorDiagnostics(attemptError ?? lost, "GATEWAY_DISCONNECTED")
406
441
  })
407
442
  }
408
- for (const stream of [...this.#streams.values()]) stream.socket._receiveError(lost)
443
+ for (const stream of this.#streams.values()) {
444
+ stream.sendWindow = stream.sendWindowLimit
445
+ for (const replay of stream.replay) replay.sent = false
446
+ }
409
447
  if (this.#closing) return
410
448
  if (this.#fatal) {
411
449
  const terminalError = attemptError ?? lost
@@ -419,6 +457,7 @@ export class ReverseGatewayConnection extends EventEmitter {
419
457
  })
420
458
  this.#rejectConnect?.(terminalError)
421
459
  this.#settleConnect()
460
+ for (const stream of [...this.#streams.values()]) stream.socket._receiveError(terminalError)
422
461
  return
423
462
  }
424
463
  if (this.#warm || this.#options.continuousReconnect !== false) this.#scheduleReconnect(attemptError)
@@ -430,6 +469,20 @@ export class ReverseGatewayConnection extends EventEmitter {
430
469
  async #handleFrame(frame) {
431
470
  if (frame.type === V3FrameType.PING) { await this.#sendControl(V3FrameType.PONG, 0, frame.sequence, {}); return }
432
471
  if (frame.type === V3FrameType.PONG) return
472
+ if (frame.type === V3FrameType.RESUMED) {
473
+ if (!this.#resuming) throw new Error("Unexpected reverse client RESUMED response")
474
+ const manifest = decodeV3Control(frame.payload)
475
+ if (manifest.page !== this.#resumeNextPage || !Array.isArray(manifest.streams)) throw new Error("Invalid reverse client RESUMED response")
476
+ this.#resumeNextPage += 1
477
+ this.#resumeResults.push(...manifest.streams.filter((item) => typeof item === "object" && item !== null))
478
+ if (manifest.final !== true) return
479
+ for (const item of this.#resumeResults) this.#applyResumeResult(item)
480
+ this.#resumeResults = []
481
+ this.#resumeNextPage = 0
482
+ this.#resuming = false
483
+ this.#completePhysical(this.#generation)
484
+ return
485
+ }
433
486
  const stream = this.#streams.get(frame.streamId)
434
487
  if (stream === undefined) {
435
488
  if (frame.type !== V3FrameType.CLOSE && frame.type !== V3FrameType.RESET) await this.#sendControl(V3FrameType.RESET, frame.streamId, 0, {code: "UNKNOWN_STREAM"})
@@ -496,6 +549,94 @@ export class ReverseGatewayConnection extends EventEmitter {
496
549
  throw new Error("Unsupported reverse client frame")
497
550
  }
498
551
 
552
+ /** Sends the client half of the paired reverse-stream resume manifest. */
553
+ async #sendResumeManifest(/** @type {number} */ generation) {
554
+ const activeStreams = [...this.#streams.values()]
555
+ await Promise.all(activeStreams.map((stream) => this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))))
556
+ const streams = activeStreams.filter((stream) => this.#streams.get(stream.id) === stream).map((stream) => ({
557
+ streamId: stream.id,
558
+ identifier: stream.identifier,
559
+ clientToGatewaySequence: stream.sendSequence,
560
+ clientToGatewayAck: stream.sendAck,
561
+ gatewayToClientSequence: stream.receiveSequence,
562
+ gatewayToClientAck: stream.receiveAck,
563
+ localEnded: stream.localEnded,
564
+ remoteEnded: stream.remoteEnded
565
+ }))
566
+ this.#resumeResults = []
567
+ this.#resumeNextPage = 0
568
+ const pageCount = Math.max(1, Math.ceil(streams.length / MANIFEST_PAGE_STREAMS))
569
+ for (let page = 0; page < pageCount; page += 1) {
570
+ await this.#sendControl(V3FrameType.RESUME, 0, generation, {
571
+ page,
572
+ final: page === pageCount - 1,
573
+ streams: streams.slice(page * MANIFEST_PAGE_STREAMS, (page + 1) * MANIFEST_PAGE_STREAMS)
574
+ })
575
+ }
576
+ }
577
+
578
+ /** @param {unknown} value - Hub resume result. */
579
+ #applyResumeResult(value) {
580
+ if (typeof value !== "object" || value === null) throw new Error("Invalid reverse client resume result")
581
+ 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
+ if (!Number.isInteger(result.streamId)) throw new Error("Invalid reverse client resume stream ID")
583
+ const stream = this.#streams.get(/** @type {number} */ (result.streamId))
584
+ if (stream === undefined) return
585
+ if (result.status !== "resumed") {
586
+ const code = typeof result.code === "string" ? result.code : "RESUME_REJECTED"
587
+ stream.socket._receiveError(new SocketductSessionLostError("Reverse gateway stream could not be resumed", code))
588
+ return
589
+ }
590
+ const clientToGatewaySequence = /** @type {number} */ (result.clientToGatewaySequence)
591
+ const clientToGatewayAck = /** @type {number} */ (result.clientToGatewayAck)
592
+ const gatewayToClientSequence = /** @type {number} */ (result.gatewayToClientSequence)
593
+ const gatewayToClientAck = /** @type {number} */ (result.gatewayToClientAck)
594
+ const validClientToGatewayBoundary = clientToGatewaySequence === stream.sendAck || stream.replay.some((chunk) => chunk.sequence === clientToGatewaySequence)
595
+ if (!Number.isSafeInteger(clientToGatewaySequence) || clientToGatewaySequence < stream.sendAck || clientToGatewaySequence > stream.sendSequence || !Number.isSafeInteger(clientToGatewayAck) || clientToGatewayAck < stream.sendAck || clientToGatewayAck > clientToGatewaySequence || !validClientToGatewayBoundary || !Number.isSafeInteger(gatewayToClientSequence) || gatewayToClientSequence !== stream.receiveSequence || !Number.isSafeInteger(gatewayToClientAck) || gatewayToClientAck < stream.receiveAck || gatewayToClientAck > gatewayToClientSequence || typeof result.clientEnded !== "boolean" || typeof result.gatewayEnded !== "boolean" || typeof result.opened !== "boolean") throw new Error("Invalid reverse client resume positions")
596
+ this.#acknowledge(stream, clientToGatewayAck)
597
+ stream.transmittedSequence = clientToGatewayAck
598
+ stream.receiveAck = gatewayToClientAck
599
+ stream.pendingReceiveAck = stream.pendingReceiveAck !== undefined && stream.pendingReceiveAck > stream.receiveAck ? stream.pendingReceiveAck : undefined
600
+ stream.sendWindow = Math.max(0, stream.sendWindowLimit - (clientToGatewaySequence - clientToGatewayAck))
601
+ for (const chunk of stream.replay) chunk.sent = chunk.sequence <= clientToGatewaySequence
602
+ stream.endSent = /** @type {boolean} */ (result.clientEnded)
603
+ if (result.gatewayEnded === true && !stream.remoteEnded) {
604
+ stream.remoteEnded = true
605
+ stream.socket._receiveEnd()
606
+ }
607
+ if (result.opened === true && stream.state === "opening") stream.state = "open"
608
+ }
609
+
610
+ /** Marks one authenticated/selected physical generation ready. */
611
+ #completePhysical(/** @type {number} */ generation, /** @type {ReturnType<typeof setTimeout> | undefined} */ timer = undefined) {
612
+ if (timer !== undefined) clearTimeout(timer)
613
+ this.#physicalConnects += 1
614
+ this.#reconnectDelay = this.#options.reconnectDelayMs ?? 250
615
+ this.#setState("ready")
616
+ logDiagnostic(this.#options.logger, "transport.connected", {
617
+ generation,
618
+ attempt: this.#physicalAttempts,
619
+ ...this.#routeIdentityFields(),
620
+ physicalConnects: this.#physicalConnects,
621
+ reconnect: this.#physicalConnects > 1
622
+ })
623
+ if (this.#physicalConnects > 1) {
624
+ logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
625
+ generation,
626
+ attempt: this.#reconnectAttempts,
627
+ ...this.#routeIdentityFields(),
628
+ physicalConnects: this.#physicalConnects
629
+ })
630
+ }
631
+ this.#resolveConnect?.()
632
+ this.#settleConnect()
633
+ for (const stream of this.#streams.values()) {
634
+ if (stream.state !== "opening") stream.socket._markConnected()
635
+ void this.#pumpStream(stream).catch((error) => stream.socket._receiveError(asError(error)))
636
+ }
637
+ this.#progressPendingWrites()
638
+ }
639
+
499
640
  /** @param {ReverseClientStream} stream - Stream. */
500
641
  async #pumpStream(stream) {
501
642
  if (stream.pumping || stream.state === "opening" || !this.#streams.has(stream.id) || this.#state !== "ready") return
@@ -516,7 +657,11 @@ export class ReverseGatewayConnection extends EventEmitter {
516
657
  if (stream.localEnded && !stream.endSent && stream.replay.every((chunk) => chunk.sent)) {
517
658
  stream.endSent = true
518
659
  stream.state = "half-closed"
519
- await this.#sendControl(V3FrameType.END, stream.id, stream.sendSequence, {})
660
+ const delivered = await this.#sendControl(V3FrameType.END, stream.id, stream.sendSequence, {})
661
+ if (!delivered) {
662
+ stream.endSent = false
663
+ return
664
+ }
520
665
  this.#finishEnd(stream)
521
666
  }
522
667
  } finally { stream.pumping = false }
@@ -533,8 +678,7 @@ export class ReverseGatewayConnection extends EventEmitter {
533
678
  if (available <= 0) return
534
679
  const chunk = Buffer.from(pending.data.subarray(pending.offset, Math.min(pending.data.length, pending.offset + available)))
535
680
  try {
536
- if (this.#options.aggregateBudget?.reserveMemory !== undefined) this.#options.aggregateBudget.reserveMemory(chunk.length)
537
- else this.#options.aggregateBudget?.reserveData(chunk.length)
681
+ this.#options.aggregateBudget?.reserveData(chunk.length)
538
682
  } catch (error) {
539
683
  this.#finishWrite(stream, asError(error))
540
684
  return
@@ -544,11 +688,19 @@ export class ReverseGatewayConnection extends EventEmitter {
544
688
  stream.replay.push({sequence: stream.sendSequence, data: chunk, sent: false})
545
689
  stream.bufferedBytes += chunk.length
546
690
  this.#bufferedBytes += chunk.length
691
+ stream.spoolReservedBytes += chunk.length
692
+ this.#spoolBytes += chunk.length
693
+ await this.#queueSpoolOperation(stream, async () => {
694
+ await this.#spool.appendChunk({sessionId: stream.identifier, ownerId: "reverse-client-v3", sequence: stream.sendSequence, data: chunk})
695
+ await this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream))
696
+ })
547
697
  }
548
698
  await this.#pumpStream(stream)
549
699
  if (pending.offset === pending.data.length && stream.pendingWrite === pending) this.#finishWrite(stream)
550
700
  } catch (error) {
551
- this.#finishWrite(stream, asError(error))
701
+ const failure = asError(error)
702
+ this.#finishWrite(stream, failure)
703
+ stream.socket._receiveError(failure)
552
704
  } finally {
553
705
  stream.progressingWrite = false
554
706
  this.#progressPendingWrites()
@@ -601,10 +753,10 @@ export class ReverseGatewayConnection extends EventEmitter {
601
753
  if (!this.#streams.delete(stream.id)) return
602
754
  this.#bufferedBytes = Math.max(0, this.#bufferedBytes - stream.bufferedBytes)
603
755
  this.#options.aggregateBudget?.releaseMemory(stream.bufferedBytes)
604
- if (this.#options.aggregateBudget?.reserveMemory === undefined) this.#options.aggregateBudget?.releaseSpool(stream.bufferedBytes)
605
756
  stream.replay = []
606
757
  stream.bufferedBytes = 0
607
758
  this.#finishWrite(stream, pendingWriteError)
759
+ this.#trackSpoolCleanup(stream)
608
760
  this.#progressPendingWrites()
609
761
  }
610
762
 
@@ -619,8 +771,15 @@ export class ReverseGatewayConnection extends EventEmitter {
619
771
  stream.bufferedBytes = Math.max(0, stream.bufferedBytes - released)
620
772
  this.#bufferedBytes = Math.max(0, this.#bufferedBytes - released)
621
773
  this.#options.aggregateBudget?.releaseMemory(released)
622
- if (this.#options.aggregateBudget?.reserveMemory === undefined) this.#options.aggregateBudget?.releaseSpool(released)
623
774
  stream.sendWindow = Math.min(stream.sendWindowLimit, stream.sendWindow + delta)
775
+ void this.#queueSpoolOperation(stream, async () => {
776
+ await this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream))
777
+ await this.#spool.truncateAcked(stream.identifier, sequence)
778
+ if (released > stream.spoolReservedBytes) throw new Error("Reverse client spool budget release underflow")
779
+ stream.spoolReservedBytes -= released
780
+ this.#spoolBytes = Math.max(0, this.#spoolBytes - released)
781
+ this.#options.aggregateBudget?.releaseSpool(released)
782
+ }).catch((error) => stream.socket._receiveError(asError(error)))
624
783
  this.#progressPendingWrites()
625
784
  void this.#pumpStream(stream).catch((error) => stream.socket._receiveError(asError(error)))
626
785
  }
@@ -636,7 +795,9 @@ export class ReverseGatewayConnection extends EventEmitter {
636
795
  #ackReceived(stream, sequence) {
637
796
  if (sequence <= stream.receiveAck) return
638
797
  stream.receiveAck = sequence
639
- void this.#sendControl(V3FrameType.ACK, stream.id, sequence, {}).catch(() => {})
798
+ void this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))
799
+ .then(() => this.#sendControl(V3FrameType.ACK, stream.id, sequence, {}).catch(() => {}))
800
+ .catch((error) => stream.socket._receiveError(asError(error)))
640
801
  }
641
802
 
642
803
  /** @param {number} type - Type. @param {number} streamId - Stream. @param {number} sequence - Sequence. @param {Record<string, unknown>} body - Body. */
@@ -674,6 +835,62 @@ export class ReverseGatewayConnection extends EventEmitter {
674
835
  }
675
836
  }
676
837
 
838
+ /** @param {ReverseClientStream} stream - Durable stream state. */
839
+ #recoveryState(stream) {
840
+ return {
841
+ version: 3,
842
+ role: "reverse-client",
843
+ connectionId: this.#connectionId,
844
+ streamId: stream.id,
845
+ identifier: stream.identifier,
846
+ name: stream.name,
847
+ target: stream.target,
848
+ sendSequence: stream.sendSequence,
849
+ sendAck: stream.sendAck,
850
+ receiveSequence: stream.receiveSequence,
851
+ receiveAck: stream.receiveAck,
852
+ localEnded: stream.localEnded,
853
+ remoteEnded: stream.remoteEnded
854
+ }
855
+ }
856
+
857
+ /** @param {ReverseClientStream} stream - Spool owner. @param {() => Promise<void>} operation - Serialized durable operation. */
858
+ async #queueSpoolOperation(stream, operation) {
859
+ const next = stream.spoolQueue.then(async () => {
860
+ await stream.spoolReady
861
+ await operation()
862
+ })
863
+ stream.spoolQueue = next.catch(() => {})
864
+ await next
865
+ }
866
+
867
+ /** @param {ReverseClientStream} stream - Terminal stream. */
868
+ async #removeSpool(stream) {
869
+ if (stream.spoolRemovalPromise === undefined) {
870
+ stream.spoolRemovalPromise = this.#queueSpoolOperation(stream, async () => {
871
+ await this.#spool.remove(stream.identifier)
872
+ const removed = stream.spoolReservedBytes
873
+ stream.spoolReservedBytes = 0
874
+ this.#spoolBytes = Math.max(0, this.#spoolBytes - removed)
875
+ this.#options.aggregateBudget?.releaseSpool(removed)
876
+ })
877
+ }
878
+ await stream.spoolRemovalPromise
879
+ }
880
+
881
+ /** @param {ReverseClientStream} stream - Terminal stream. */
882
+ #trackSpoolCleanup(stream) {
883
+ const task = this.#removeSpool(stream).catch((error) => {
884
+ if (!stream.spoolCleanupFailureRecorded) {
885
+ stream.spoolCleanupFailureRecorded = true
886
+ this.#spoolCleanupFailures += 1
887
+ }
888
+ throw error
889
+ })
890
+ this.#spoolCleanupTasks.add(task)
891
+ void task.catch(() => {}).finally(() => this.#spoolCleanupTasks.delete(task))
892
+ }
893
+
677
894
  #settleConnect() { this.#connectPromise = undefined; this.#resolveConnect = undefined; this.#rejectConnect = undefined }
678
895
  #setState(/** @type {'configured' | 'connecting' | 'ready' | 'reconnecting' | 'closed'} */ state, /** @type {Error | undefined} */ error = undefined) { if (this.#state === state) return; this.#state = state; this.emit("stateChange", state, error) }
679
896
  /** @returns {{gatewayId: string} | {routeClass: string}} Stable physical route identity. */
@@ -705,6 +922,8 @@ function validateOptions(options) {
705
922
  positiveInteger(options.maxStreams, "maxStreams", DEFAULT_MAX_STREAMS)
706
923
  positiveInteger(options.maxStreamBufferBytes, "maxStreamBufferBytes", DEFAULT_STREAM_BUFFER_BYTES)
707
924
  positiveInteger(options.maxConnectionBufferBytes, "maxConnectionBufferBytes", DEFAULT_CONNECTION_BUFFER_BYTES)
925
+ if (options.spoolDirectory !== undefined && (typeof options.spoolDirectory !== "string" || options.spoolDirectory.length === 0)) throw new TypeError("spoolDirectory must be a non-empty string")
926
+ positiveInteger(options.maxAgeMs, "maxAgeMs", 24 * 60 * 60 * 1000)
708
927
  if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
709
928
  }
710
929