socketduct 0.1.17 → 0.1.19

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.
@@ -64,6 +70,8 @@ const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
64
70
  * @property {{host: string, port: number}} target
65
71
  * @property {VirtualSocket} socket
66
72
  * @property {'opening' | 'open' | 'half-closed' | 'closed' | 'reset'} state
73
+ * @property {boolean} opened
74
+ * @property {boolean} openingSent
67
75
  * @property {number} sendSequence
68
76
  * @property {number} transmittedSequence
69
77
  * @property {number} sendAck
@@ -71,8 +79,9 @@ const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
71
79
  * @property {number} sendWindowLimit
72
80
  * @property {number} receiveSequence
73
81
  * @property {number} receiveAck
74
- * @property {number | undefined} pendingReceiveAck
82
+ * @property {Array<{sequence: number, remaining: number}>} receiveFrames
75
83
  * @property {boolean} remoteEnded
84
+ * @property {boolean} remoteEndPending
76
85
  * @property {number} bufferedBytes
77
86
  * @property {ClientChunk[]} replay
78
87
  * @property {AdaptiveCompressor} compressor
@@ -82,6 +91,34 @@ const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
82
91
  * @property {boolean} localEnded
83
92
  * @property {boolean} endSent
84
93
  * @property {((error?: Error | null) => void) | undefined} endCallback
94
+ * @property {Promise<void>} spoolReady
95
+ * @property {Promise<void>} spoolQueue
96
+ * @property {number} spoolReservedBytes
97
+ * @property {Promise<void> | undefined} spoolRemovalPromise
98
+ * @property {boolean} spoolCleanupFailureRecorded
99
+ * @property {boolean} ownsSpool
100
+ * @property {boolean} recoveredObject
101
+ * @typedef {object} ReverseClientRecoveryState
102
+ * @property {number} version
103
+ * @property {'reverse-client'} role
104
+ * @property {string} connectionId
105
+ * @property {number} generation
106
+ * @property {string} [gatewayId]
107
+ * @property {string} [routeClass]
108
+ * @property {number} streamId
109
+ * @property {string} identifier
110
+ * @property {string} name
111
+ * @property {{host: string, port: number}} target
112
+ * @property {'opening' | 'open' | 'half-closed'} state
113
+ * @property {boolean} opened
114
+ * @property {true} openingSent
115
+ * @property {number} sendSequence
116
+ * @property {number} sendAck
117
+ * @property {number} receiveSequence
118
+ * @property {number} receiveAck
119
+ * @property {boolean} localEnded
120
+ * @property {boolean} remoteEnded
121
+ * @property {boolean} endSent
85
122
  */
86
123
 
87
124
  /** Server3/TensorBuzz-facing virtual socket connection to an exact gateway or server-resolved route class. */
@@ -95,10 +132,14 @@ export class ReverseGatewayConnection extends EventEmitter {
95
132
  /** @type {number} */ #generation
96
133
  /** @type {string | undefined} */ #codec
97
134
  /** @type {boolean} */ #closing = false
135
+ /** @type {boolean} */ #preserveSpools = false
98
136
  /** @type {boolean} */ #fatal = false
99
137
  /** @type {Error | undefined} */ #fatalError
100
138
  /** @type {boolean} */ #referenced = true
101
139
  /** @type {boolean} */ #warm = false
140
+ /** @type {boolean} */ #resuming = false
141
+ /** @type {Array<Record<string, unknown>>} */ #resumeResults = []
142
+ /** @type {number} */ #resumeNextPage = 0
102
143
  /** @type {ReturnType<typeof setTimeout> | undefined} */ #reconnectTimer
103
144
  /** @type {Promise<void> | undefined} */ #connectPromise
104
145
  /** @type {(() => void) | undefined} */ #resolveConnect
@@ -112,6 +153,12 @@ export class ReverseGatewayConnection extends EventEmitter {
112
153
  /** @type {number} */ #rawDataBytes = 0
113
154
  /** @type {number} */ #wireDataBytes = 0
114
155
  /** @type {number} */ #lastProgressedStreamId = 0
156
+ /** @type {Promise<void>} */ #openQueue = Promise.resolve()
157
+ /** @type {Promise<void>} */ #physicalFrameQueue = Promise.resolve()
158
+ /** @type {SessionSpool} */ #spool
159
+ /** @type {number} */ #spoolBytes = 0
160
+ /** @type {number} */ #spoolCleanupFailures = 0
161
+ /** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
115
162
 
116
163
  /** @param {ReverseGatewayConnectionOptions} options - Hub route and safety policy. */
117
164
  constructor(options) {
@@ -119,6 +166,11 @@ export class ReverseGatewayConnection extends EventEmitter {
119
166
  validateOptions(options)
120
167
  this.#options = options
121
168
  this.#connectionId = options.connectionId ?? randomBytes(24).toString("base64url")
169
+ this.#spool = new SessionSpool({
170
+ rootDir: options.spoolDirectory ?? join(tmpdir(), `socketduct-${typeof process.getuid === "function" ? process.getuid() : "user"}`, "reverse-client", this.#connectionId),
171
+ maxBytes: options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES,
172
+ maxAgeMs: options.maxAgeMs ?? 24 * 60 * 60 * 1000
173
+ })
122
174
  this.#generation = options.initialGeneration ?? Date.now() * 1000
123
175
  this.#reconnectDelay = options.reconnectDelayMs ?? 250
124
176
  this.#sendWindowLimit = this.#initialWindow()
@@ -139,15 +191,45 @@ export class ReverseGatewayConnection extends EventEmitter {
139
191
 
140
192
  /** Allocates one stream on the already-warm gateway transport. */
141
193
  createConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback = undefined) {
194
+ if (options?.recover === true) throw new TypeError("Durable recovery must use registerRecoveredConnection()")
195
+ return this.#createConnection(options, callback, false)
196
+ }
197
+
198
+ /** Registers one durable stream without starting a physical connection. */
199
+ registerRecoveredConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback = undefined) {
200
+ if (options?.recover !== true) throw new TypeError("registerRecoveredConnection requires recover: true")
201
+ if (this.#options.connectionId === undefined) throw new TypeError("registerRecoveredConnection requires an explicit stable connectionId")
202
+ if (this.#options.spoolDirectory === undefined) throw new TypeError("registerRecoveredConnection requires an explicit persistent spoolDirectory")
203
+ if (this.#physicalAttempts > 0) throw new Error("Recovered streams must be registered before any physical connection attempt")
204
+ return this.#createConnection(options, callback, true)
205
+ }
206
+
207
+ /** Creates a fresh or synchronously recovered reverse stream. */
208
+ #createConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback, /** @type {boolean} */ recovering) {
142
209
  if (this.#closing) throw closedError()
143
- if (this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
210
+ if (!recovering && this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
144
211
  if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum reverse virtual stream count exceeded")
145
212
  const target = parseTarget(options?.target ?? {host: options?.host, port: options?.port})
146
213
  if (target === undefined) throw new TypeError("Reverse virtual connection target host and port are required")
147
- const identifier = options.identifier ?? randomBytes(24).toString("base64url")
214
+ const identifier = recovering ? options.identifier : (options.identifier ?? randomBytes(24).toString("base64url"))
148
215
  if (typeof identifier !== "string" || !STABLE_IDENTIFIER.test(identifier)) throw new TypeError("Reverse virtual stream identifier must be 8-128 URL-safe characters")
149
- const id = this.#allocateStreamId()
150
- const name = options.sessionName ?? `socketduct-reverse-${id}`
216
+ const recovery = recovering
217
+ ? validateRecoveryState(this.#spool.readRecoveryStateSync(identifier, "reverse-client-v3"), {
218
+ connectionId: this.#connectionId,
219
+ identifier,
220
+ target,
221
+ selection: this.#routeIdentityFields(),
222
+ initialWindow: this.#initialWindow(),
223
+ ...(options.sessionName === undefined ? {} : {sessionName: options.sessionName})
224
+ })
225
+ : undefined
226
+ const chunks = recovery === undefined ? [] : this.#spool.loadChunksSync(identifier, "reverse-client-v3", recovery.sendAck, recovery.sendSequence)
227
+ if (recovery !== undefined) validateRecoveredChunks(recovery, chunks, this.#options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES, this.#maxFrameBytes())
228
+ const bufferedBytes = chunks.reduce((sum, chunk) => sum + chunk.data.length, 0)
229
+ if (this.#bufferedBytes + bufferedBytes > (this.#options.maxConnectionBufferBytes ?? DEFAULT_CONNECTION_BUFFER_BYTES)) throw new Error("Recovered reverse data exceeds the connection buffer limit")
230
+ const id = recovery?.streamId ?? this.#allocateStreamId()
231
+ if (this.#streams.has(id)) throw new Error(`Reverse v3 stream ID ${id} is already active`)
232
+ const name = options.sessionName ?? recovery?.name ?? `socketduct-reverse-${id}`
151
233
  if (typeof name !== "string" || name.length === 0 || name.length > 256) throw new TypeError("Reverse virtual stream name must be 1-256 characters")
152
234
  const socket = new VirtualSocket(/** @type {import('../client/multiplexed-connection.js').MultiplexedSocketductConnection} */ (/** @type {unknown} */ (this)), id, {
153
235
  ...target,
@@ -158,43 +240,78 @@ export class ReverseGatewayConnection extends EventEmitter {
158
240
  /** @type {ReverseClientStream} */
159
241
  const stream = {
160
242
  id, identifier, name, target, socket,
161
- state: "opening",
162
- sendSequence: 0,
163
- transmittedSequence: 0,
164
- sendAck: 0,
243
+ state: recovery?.state ?? "opening",
244
+ opened: recovery?.opened ?? false,
245
+ openingSent: recovery?.openingSent ?? false,
246
+ sendSequence: recovery?.sendSequence ?? 0,
247
+ transmittedSequence: recovery?.sendAck ?? 0,
248
+ sendAck: recovery?.sendAck ?? 0,
165
249
  sendWindow: this.#sendWindowLimit,
166
250
  sendWindowLimit: this.#sendWindowLimit,
167
- receiveSequence: 0,
168
- receiveAck: 0,
169
- pendingReceiveAck: undefined,
170
- remoteEnded: false,
171
- bufferedBytes: 0,
172
- replay: [],
251
+ // Bytes buffered only in the dead VirtualSocket were not durably
252
+ // delivered. Resume inbound delivery from the persisted ACK boundary.
253
+ receiveSequence: recovery?.receiveAck ?? 0,
254
+ receiveAck: recovery?.receiveAck ?? 0,
255
+ receiveFrames: [],
256
+ remoteEnded: recovery?.remoteEnded ?? false,
257
+ remoteEndPending: recovery?.remoteEnded ?? false,
258
+ bufferedBytes,
259
+ replay: chunks.map((chunk) => ({...chunk, sent: false})),
173
260
  compressor: new AdaptiveCompressor(),
174
261
  pumping: false,
175
262
  pendingWrite: undefined,
176
263
  progressingWrite: false,
177
- localEnded: false,
178
- endSent: false,
179
- endCallback: undefined
264
+ localEnded: recovery?.localEnded ?? false,
265
+ endSent: recovery?.endSent ?? false,
266
+ endCallback: undefined,
267
+ spoolReady: Promise.resolve(),
268
+ spoolQueue: Promise.resolve(),
269
+ spoolReservedBytes: bufferedBytes,
270
+ spoolRemovalPromise: undefined,
271
+ spoolCleanupFailureRecorded: false,
272
+ ownsSpool: recovery !== undefined,
273
+ recoveredObject: recovery !== undefined
274
+ }
275
+ if (recovery !== undefined) {
276
+ this.#options.aggregateBudget?.reserveData(bufferedBytes)
277
+ this.#bufferedBytes += bufferedBytes
278
+ this.#spoolBytes += bufferedBytes
279
+ this.#nextStreamId = Math.max(this.#nextStreamId, id + 2)
280
+ this.#generation = Math.max(this.#generation, recovery.generation)
180
281
  }
181
282
  this.#streams.set(id, stream)
283
+ stream.spoolReady = recovery === undefined
284
+ ? this.#spool.createNew({sessionId: identifier, ownerId: "reverse-client-v3"})
285
+ .then(() => {
286
+ stream.ownsSpool = true
287
+ return this.#spool.writeRecoveryState(identifier, this.#recoveryState(stream))
288
+ })
289
+ : this.#spool.truncateAcked(identifier, recovery.sendAck)
290
+ stream.spoolQueue = stream.spoolReady.catch(() => {})
182
291
  logDiagnostic(this.#options.logger, "virtual_stream.opening", {
183
292
  streamId: id,
184
293
  generation: this.#generation,
185
294
  ...this.#routeIdentityFields(),
186
- recovered: false
295
+ recovered: recovery !== undefined
187
296
  })
188
297
  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)))
298
+ if (recovery === undefined) {
299
+ const opening = this.#openQueue
300
+ .then(() => stream.spoolReady)
301
+ .then(async () => {
302
+ if (this.#streams.get(id) !== stream) return
303
+ stream.openingSent = true
304
+ await this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(identifier, this.#recoveryState(stream)))
305
+ if (this.#streams.get(id) === stream) await this.#sendControl(V3FrameType.OPEN, id, 0, {...this.#routeIdentityFields(), identifier, name, target, initialWindow: this.#initialWindow()})
306
+ })
307
+ this.#openQueue = opening.then(() => undefined, () => undefined)
308
+ void opening.catch((error) => socket._receiveError(asError(error)))
309
+ } else {
310
+ void stream.spoolReady.catch((error) => socket._receiveError(asError(error)))
311
+ }
190
312
  return socket
191
313
  }
192
314
 
193
- /** Reverse established operations are intentionally not recoverable across process/transport loss. */
194
- registerRecoveredConnection() {
195
- throw Object.assign(new Error("Reverse gateway streams cannot be recovered; reconcile the Docker operation before retrying"), {code: "REVERSE_STREAM_RECOVERY_UNSUPPORTED"})
196
- }
197
-
198
315
  status() {
199
316
  /** @type {Record<string, number>} */
200
317
  const streamStates = {}
@@ -207,8 +324,8 @@ export class ReverseGatewayConnection extends EventEmitter {
207
324
  queuedBytes: this.#bufferedBytes,
208
325
  dataQueuedBytes: this.#bufferedBytes,
209
326
  controlQueuedBytes: 0,
210
- spoolBytes: 0,
211
- spoolCleanupFailures: 0,
327
+ spoolBytes: this.#spoolBytes,
328
+ spoolCleanupFailures: this.#spoolCleanupFailures,
212
329
  physicalConnects: this.#physicalConnects,
213
330
  reconnectAttempts: this.#reconnectAttempts,
214
331
  reconnectSuccesses: Math.max(0, this.#physicalConnects - 1),
@@ -239,25 +356,51 @@ export class ReverseGatewayConnection extends EventEmitter {
239
356
  }
240
357
  this.#rejectConnect?.(closedError())
241
358
  this.#settleConnect()
359
+ const cleanup = await Promise.allSettled([...this.#spoolCleanupTasks])
360
+ const failures = cleanup.filter((result) => result.status === "rejected").map((result) => asError(result.reason))
361
+ if (failures.length > 0) throw new AggregateError(failures, "Reverse client spool cleanup failed")
242
362
  }
243
363
 
244
- /** Detach is terminal because ambiguous reverse streams are never durably replayed. */
245
- detach() { return this.close() }
364
+ /** Preserves durable streams while retiring this object for replacement. */
365
+ async detach() {
366
+ if (this.#closing) return
367
+ if (this.#options.connectionId === undefined) throw new Error("detach() requires an explicit stable connectionId")
368
+ if (this.#options.spoolDirectory === undefined) throw new Error("detach() requires an explicit persistent spoolDirectory")
369
+ this.#preserveSpools = true
370
+ this.#closing = true
371
+ if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
372
+ this.#reconnectTimer = undefined
373
+ this.#setState("closed")
374
+ const physical = this.#physical
375
+ physical?.pause()
376
+ physical?.destroy()
377
+ const frameQueue = this.#physicalFrameQueue
378
+ try {
379
+ await frameQueue
380
+ await Promise.all([...this.#streams.values()].map((stream) => this.#persistStreamState(stream)))
381
+ } finally {
382
+ physical?.destroy()
383
+ this.#physical = undefined
384
+ this.#rejectConnect?.(new SocketductSessionLostError("Reverse connection detached for durable recovery", "CONNECTION_DETACHED"))
385
+ this.#settleConnect()
386
+ }
387
+ }
246
388
 
247
389
  _writeStream(/** @type {number} */ streamId, /** @type {Buffer} */ data, /** @type {(error?: Error | null) => void} */ callback) {
248
390
  const stream = this.#streams.get(streamId)
249
- if (stream === undefined || stream.localEnded || stream.state === "closed" || stream.state === "reset") { callback(new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED")); return }
391
+ 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 }
250
392
  stream.pendingWrite = {data, offset: 0, callback}
251
393
  this.#progressPendingWrites()
252
394
  }
253
395
 
254
396
  _endStream(/** @type {number} */ streamId, /** @type {(error?: Error | null) => void} */ callback) {
255
397
  const stream = this.#streams.get(streamId)
256
- if (stream === undefined || stream.localEnded) { callback(); return }
398
+ if (this.#closing || stream === undefined || stream.localEnded) { callback(); return }
257
399
  stream.localEnded = true
258
400
  if (stream.state === "closed") { callback(); return }
401
+ if (stream.opened && stream.state === "open") stream.state = "half-closed"
259
402
  stream.endCallback = callback
260
- void this.#pumpStream(stream).catch((error) => this.#finishEnd(stream, asError(error)))
403
+ void this.#persistStreamState(stream).then(() => this.#pumpStream(stream)).catch((error) => this.#finishEnd(stream, asError(error)))
261
404
  }
262
405
 
263
406
  _destroyStream(/** @type {number} */ streamId, /** @type {Error | null} */ error) {
@@ -275,11 +418,26 @@ export class ReverseGatewayConnection extends EventEmitter {
275
418
  }
276
419
 
277
420
  _resumeReadable(/** @type {number} */ streamId) {
421
+ void streamId
422
+ }
423
+
424
+ _consumeReadable(/** @type {number} */ streamId, /** @type {number} */ bytes) {
278
425
  const stream = this.#streams.get(streamId)
279
- if (stream?.pendingReceiveAck === undefined) return
280
- const sequence = stream.pendingReceiveAck
281
- stream.pendingReceiveAck = undefined
282
- this.#ackReceived(stream, sequence)
426
+ if (stream === undefined || !Number.isSafeInteger(bytes) || bytes <= 0) return
427
+ let remaining = bytes
428
+ let acknowledgedSequence = stream.receiveAck
429
+ while (remaining > 0 && stream.receiveFrames.length > 0) {
430
+ const frame = /** @type {{sequence: number, remaining: number}} */ (stream.receiveFrames[0])
431
+ const consumed = Math.min(remaining, frame.remaining)
432
+ frame.remaining -= consumed
433
+ remaining -= consumed
434
+ if (frame.remaining === 0) {
435
+ acknowledgedSequence = frame.sequence
436
+ stream.receiveFrames.shift()
437
+ }
438
+ }
439
+ if (remaining !== 0) throw new Error("Reverse client readable accounting underflow")
440
+ this.#ackReceived(stream, acknowledgedSequence)
283
441
  }
284
442
 
285
443
  #allocateStreamId() {
@@ -322,6 +480,7 @@ export class ReverseGatewayConnection extends EventEmitter {
322
480
  physical.setKeepAlive(true, DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS)
323
481
  const decoder = new V3FrameDecoder({maxFrameBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4), maxBufferedBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4) * 2})
324
482
  let queue = Promise.resolve()
483
+ this.#physicalFrameQueue = queue
325
484
  let authenticated = false
326
485
  let selected = false
327
486
  /** @type {Error | undefined} */
@@ -329,14 +488,21 @@ export class ReverseGatewayConnection extends EventEmitter {
329
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)
330
489
  timer.unref?.()
331
490
  physical.once("secureConnect", () => {
332
- const body = encodeV3Control({role: "client", token: this.#options.hub.token, versions: [3], connectionId: this.#connectionId, generation, compression: availableCodecs()})
333
- void writeSocket(physical, encodeV3Frame({type: V3FrameType.AUTH, streamId: 0, sequence: generation}, body))
334
- .catch((error) => physical.destroy(asError(error)))
491
+ // The attempted generation must survive a crash after AUTH reaches the
492
+ // hub but before AUTH_OK returns, otherwise a replacement could reuse a
493
+ // fenced generation after wall-clock rollback.
494
+ void Promise.all([...this.#streams.values()].map((stream) => this.#persistStreamState(stream))).then(async () => {
495
+ if (this.#physical !== physical || physical.destroyed || this.#closing) return
496
+ const body = encodeV3Control({role: "client", token: this.#options.hub.token, versions: [3], connectionId: this.#connectionId, generation, compression: availableCodecs()})
497
+ await writeSocket(physical, encodeV3Frame({type: V3FrameType.AUTH, streamId: 0, sequence: generation}, body))
498
+ }).catch((error) => physical.destroy(asError(error)))
335
499
  })
336
500
  physical.on("data", (chunk) => {
501
+ if (this.#closing || this.#physical !== physical) return
337
502
  physical.pause()
338
503
  queue = queue.then(async () => {
339
504
  for (const frame of decoder.push(chunk)) {
505
+ if (this.#closing || this.#physical !== physical) return
340
506
  if (frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.GOAWAY) {
341
507
  const detail = decodeV3Control(frame.payload)
342
508
  throw connectionError(typeof detail.code === "string" ? detail.code : "CONNECTION_REJECTED")
@@ -348,6 +514,10 @@ export class ReverseGatewayConnection extends EventEmitter {
348
514
  authenticated = true
349
515
  this.#codec = ZSTD_CODEC
350
516
  this.#sendWindowLimit = Math.min(this.#initialWindow(), detail.initialWindow)
517
+ for (const stream of this.#streams.values()) {
518
+ stream.sendWindowLimit = Math.min(stream.sendWindowLimit, this.#sendWindowLimit)
519
+ stream.sendWindow = Math.min(stream.sendWindow, stream.sendWindowLimit)
520
+ }
351
521
  await writeSocket(physical, encodeV3Frame({type: V3FrameType.GATEWAY_SELECT, streamId: 0, sequence: generation}, encodeV3Control(this.#routeIdentityFields())))
352
522
  continue
353
523
  }
@@ -356,37 +526,28 @@ export class ReverseGatewayConnection extends EventEmitter {
356
526
  const detail = decodeV3Control(frame.payload)
357
527
  const selection = this.#routeIdentityFields()
358
528
  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")
529
+ if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_SELECTION_FAILED")
359
530
  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
- })
531
+ if (detail.resumed) {
532
+ clearTimeout(timer)
533
+ this.#resuming = true
534
+ await this.#sendResumeManifest(generation)
535
+ } else if ([...this.#streams.values()].some((stream) => stream.openingSent || stream.opened)) {
536
+ for (const stream of [...this.#streams.values()]) stream.socket._receiveError(new SocketductSessionLostError("Reverse gateway hub lost the retained stream", "RESUME_SESSION_LOST"))
537
+ this.#completePhysical(generation, timer)
538
+ } else {
539
+ this.#completePhysical(generation, timer)
378
540
  }
379
- this.#resolveConnect?.()
380
- this.#settleConnect()
381
541
  continue
382
542
  }
383
- await this.#handleFrame(frame)
543
+ await this.#handleFrame(frame, physical)
384
544
  }
385
545
  }).catch((error) => {
386
546
  attemptError = asError(error)
387
547
  if (/** @type {{fatal?: unknown}} */ (attemptError).fatal === true) this.#fatal = true
388
548
  physical.destroy(attemptError)
389
549
  }).finally(() => { if (!physical.destroyed) physical.resume() })
550
+ this.#physicalFrameQueue = queue
390
551
  })
391
552
  physical.once("error", (error) => { attemptError = error; if (isFatalTransportError(error)) this.#fatal = true })
392
553
  physical.once("close", () => {
@@ -395,8 +556,11 @@ export class ReverseGatewayConnection extends EventEmitter {
395
556
  if (this.#physical !== physical) return
396
557
  this.#physical = undefined
397
558
  this.#codec = undefined
559
+ this.#resuming = false
560
+ this.#resumeResults = []
561
+ this.#resumeNextPage = 0
398
562
  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")
563
+ const lost = new SocketductSessionLostError("Reverse gateway physical transport was interrupted", "GATEWAY_DISCONNECTED")
400
564
  if (!this.#closing) {
401
565
  logDiagnostic(this.#options.logger, "transport.disconnected", {
402
566
  generation,
@@ -405,10 +569,15 @@ export class ReverseGatewayConnection extends EventEmitter {
405
569
  ...errorDiagnostics(attemptError ?? lost, "GATEWAY_DISCONNECTED")
406
570
  })
407
571
  }
408
- for (const stream of [...this.#streams.values()]) stream.socket._receiveError(lost)
572
+ for (const stream of this.#streams.values()) {
573
+ stream.sendWindow = stream.sendWindowLimit
574
+ for (const replay of stream.replay) replay.sent = false
575
+ }
409
576
  if (this.#closing) return
410
577
  if (this.#fatal) {
411
578
  const terminalError = attemptError ?? lost
579
+ const terminalCode = /** @type {{code?: unknown}} */ (terminalError).code
580
+ if (terminalCode === "STALE_GENERATION" || terminalCode === "CONNECTION_REPLACED") this.#preserveSpools = true
412
581
  this.#fatalError = terminalError
413
582
  this.#setState("closed", terminalError)
414
583
  logDiagnostic(this.#options.logger, "transport.failed", {
@@ -419,6 +588,7 @@ export class ReverseGatewayConnection extends EventEmitter {
419
588
  })
420
589
  this.#rejectConnect?.(terminalError)
421
590
  this.#settleConnect()
591
+ for (const stream of [...this.#streams.values()]) stream.socket._receiveError(terminalError)
422
592
  return
423
593
  }
424
594
  if (this.#warm || this.#options.continuousReconnect !== false) this.#scheduleReconnect(attemptError)
@@ -426,10 +596,26 @@ export class ReverseGatewayConnection extends EventEmitter {
426
596
  })
427
597
  }
428
598
 
429
- /** @param {import('../protocol/v3-frame.js').V3Frame} frame - Hub frame. */
430
- async #handleFrame(frame) {
599
+ /** @param {import('../protocol/v3-frame.js').V3Frame} frame - Hub frame. @param {import('node:tls').TLSSocket} physical - Owning physical generation. */
600
+ async #handleFrame(frame, physical) {
601
+ if (this.#closing || this.#physical !== physical) return
431
602
  if (frame.type === V3FrameType.PING) { await this.#sendControl(V3FrameType.PONG, 0, frame.sequence, {}); return }
432
603
  if (frame.type === V3FrameType.PONG) return
604
+ if (frame.type === V3FrameType.RESUMED) {
605
+ if (!this.#resuming) throw new Error("Unexpected reverse client RESUMED response")
606
+ const manifest = decodeV3Control(frame.payload)
607
+ if (manifest.page !== this.#resumeNextPage || !Array.isArray(manifest.streams)) throw new Error("Invalid reverse client RESUMED response")
608
+ this.#resumeNextPage += 1
609
+ this.#resumeResults.push(...manifest.streams.filter((item) => typeof item === "object" && item !== null))
610
+ if (manifest.final !== true) return
611
+ for (const item of this.#resumeResults) await this.#applyResumeResult(item)
612
+ if (this.#closing || this.#physical !== physical) return
613
+ this.#resumeResults = []
614
+ this.#resumeNextPage = 0
615
+ this.#resuming = false
616
+ this.#completePhysical(this.#generation)
617
+ return
618
+ }
433
619
  const stream = this.#streams.get(frame.streamId)
434
620
  if (stream === undefined) {
435
621
  if (frame.type !== V3FrameType.CLOSE && frame.type !== V3FrameType.RESET) await this.#sendControl(V3FrameType.RESET, frame.streamId, 0, {code: "UNKNOWN_STREAM"})
@@ -440,13 +626,16 @@ export class ReverseGatewayConnection extends EventEmitter {
440
626
  if (stream.state !== "opening" || frame.sequence !== 0 || detail.identifier !== stream.identifier || !validPeerWindow(detail.initialWindow)) throw new Error("Invalid reverse OPENED response")
441
627
  stream.sendWindowLimit = Math.min(stream.sendWindowLimit, detail.initialWindow)
442
628
  stream.sendWindow = Math.min(stream.sendWindow, stream.sendWindowLimit)
443
- stream.state = "open"
629
+ stream.state = stream.localEnded ? "half-closed" : "open"
630
+ stream.opened = true
444
631
  logDiagnostic(this.#options.logger, "virtual_stream.opened", {
445
632
  streamId: stream.id,
446
633
  generation: this.#generation,
447
634
  ...this.#routeIdentityFields(),
448
635
  recovered: false
449
636
  })
637
+ await this.#persistStreamState(stream)
638
+ if (this.#closing || this.#physical !== physical) return
450
639
  stream.socket._markConnected()
451
640
  this.#progressPendingWrites()
452
641
  await this.#pumpStream(stream)
@@ -455,11 +644,11 @@ export class ReverseGatewayConnection extends EventEmitter {
455
644
  if (frame.type === V3FrameType.DATA) {
456
645
  if (stream.remoteEnded) throw new Error("Reverse client received DATA after END")
457
646
  const data = await decodeV3Data(frame, this.#codec, this.#options.compressionLimits)
647
+ if (this.#closing || this.#physical !== physical) return
458
648
  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")
459
649
  stream.receiveSequence = frame.sequence
460
- const canContinue = stream.socket._receive(data)
461
- if (canContinue) this.#ackReceived(stream, frame.sequence)
462
- else stream.pendingReceiveAck = frame.sequence
650
+ stream.receiveFrames.push({sequence: frame.sequence, remaining: data.length})
651
+ stream.socket._receive(data)
463
652
  return
464
653
  }
465
654
  if (frame.type === V3FrameType.ACK) { this.#acknowledge(stream, frame.sequence); return }
@@ -467,12 +656,15 @@ export class ReverseGatewayConnection extends EventEmitter {
467
656
  if (frame.type === V3FrameType.END) {
468
657
  if (stream.remoteEnded || frame.sequence !== stream.receiveSequence) throw new Error("Invalid reverse client END")
469
658
  stream.remoteEnded = true
659
+ await this.#persistStreamState(stream)
660
+ if (this.#closing || this.#physical !== physical) return
470
661
  stream.socket._receiveEnd()
471
662
  return
472
663
  }
473
664
  if (frame.type === V3FrameType.CLOSE) {
474
665
  if (frame.sequence !== stream.receiveSequence) throw new Error("Invalid reverse client CLOSE")
475
666
  await this.#sendControl(V3FrameType.CLOSE, stream.id, stream.transmittedSequence, {})
667
+ if (this.#closing || this.#physical !== physical) return
476
668
  stream.state = "closed"
477
669
  logDiagnostic(this.#options.logger, "virtual_stream.closed", {
478
670
  streamId: stream.id,
@@ -496,6 +688,105 @@ export class ReverseGatewayConnection extends EventEmitter {
496
688
  throw new Error("Unsupported reverse client frame")
497
689
  }
498
690
 
691
+ /** Sends the client half of the paired reverse-stream resume manifest. */
692
+ async #sendResumeManifest(/** @type {number} */ generation) {
693
+ const activeStreams = [...this.#streams.values()]
694
+ await Promise.all(activeStreams.map((stream) => this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))))
695
+ const streams = activeStreams.filter((stream) => this.#streams.get(stream.id) === stream).map((stream) => ({
696
+ streamId: stream.id,
697
+ identifier: stream.identifier,
698
+ clientToGatewaySequence: stream.sendSequence,
699
+ clientToGatewayAck: stream.sendAck,
700
+ // A replacement cannot inherit the dead VirtualSocket's process-only
701
+ // readable buffer. Ask the surviving gateway to replay above durable ACK.
702
+ gatewayToClientSequence: stream.recoveredObject ? stream.receiveAck : stream.receiveSequence,
703
+ gatewayToClientAck: stream.receiveAck,
704
+ localEnded: stream.localEnded,
705
+ remoteEnded: stream.remoteEnded
706
+ }))
707
+ this.#resumeResults = []
708
+ this.#resumeNextPage = 0
709
+ const pageCount = Math.max(1, Math.ceil(streams.length / MANIFEST_PAGE_STREAMS))
710
+ for (let page = 0; page < pageCount; page += 1) {
711
+ await this.#sendControl(V3FrameType.RESUME, 0, generation, {
712
+ page,
713
+ final: page === pageCount - 1,
714
+ streams: streams.slice(page * MANIFEST_PAGE_STREAMS, (page + 1) * MANIFEST_PAGE_STREAMS)
715
+ })
716
+ }
717
+ }
718
+
719
+ /** @param {unknown} value - Hub resume result. */
720
+ async #applyResumeResult(value) {
721
+ if (typeof value !== "object" || value === null) throw new Error("Invalid reverse client resume result")
722
+ const result = /** @type {{streamId?: unknown, status?: unknown, clientToGatewaySequence?: unknown, clientToGatewayAck?: unknown, gatewayToClientSequence?: unknown, gatewayToClientAck?: unknown, clientEnded?: unknown, gatewayEnded?: unknown, opened?: unknown, code?: unknown}} */ (value)
723
+ if (!Number.isInteger(result.streamId)) throw new Error("Invalid reverse client resume stream ID")
724
+ const stream = this.#streams.get(/** @type {number} */ (result.streamId))
725
+ if (stream === undefined) return
726
+ if (result.status !== "resumed") {
727
+ const code = typeof result.code === "string" ? result.code : "RESUME_REJECTED"
728
+ stream.socket._receiveError(new SocketductSessionLostError("Reverse gateway stream could not be resumed", code))
729
+ return
730
+ }
731
+ const clientToGatewaySequence = /** @type {number} */ (result.clientToGatewaySequence)
732
+ const clientToGatewayAck = /** @type {number} */ (result.clientToGatewayAck)
733
+ const gatewayToClientSequence = /** @type {number} */ (result.gatewayToClientSequence)
734
+ const gatewayToClientAck = /** @type {number} */ (result.gatewayToClientAck)
735
+ const validClientToGatewayBoundary = clientToGatewaySequence === stream.sendAck || stream.replay.some((chunk) => chunk.sequence === clientToGatewaySequence)
736
+ 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")
737
+ this.#acknowledge(stream, clientToGatewayAck)
738
+ stream.transmittedSequence = clientToGatewayAck
739
+ stream.receiveAck = gatewayToClientAck
740
+ stream.sendWindow = Math.max(0, stream.sendWindowLimit - (clientToGatewaySequence - clientToGatewayAck))
741
+ for (const chunk of stream.replay) chunk.sent = chunk.sequence <= clientToGatewaySequence
742
+ stream.endSent = /** @type {boolean} */ (result.clientEnded)
743
+ if (result.gatewayEnded === true) {
744
+ stream.remoteEndPending = stream.recoveredObject || !stream.remoteEnded
745
+ stream.remoteEnded = true
746
+ }
747
+ if (result.opened === true) {
748
+ stream.opened = true
749
+ if (stream.state === "opening") stream.state = stream.localEnded ? "half-closed" : "open"
750
+ }
751
+ stream.openingSent = true
752
+ stream.recoveredObject = false
753
+ await this.#persistStreamState(stream)
754
+ }
755
+
756
+ /** Marks one authenticated/selected physical generation ready. */
757
+ #completePhysical(/** @type {number} */ generation, /** @type {ReturnType<typeof setTimeout> | undefined} */ timer = undefined) {
758
+ if (timer !== undefined) clearTimeout(timer)
759
+ this.#physicalConnects += 1
760
+ this.#reconnectDelay = this.#options.reconnectDelayMs ?? 250
761
+ this.#setState("ready")
762
+ logDiagnostic(this.#options.logger, "transport.connected", {
763
+ generation,
764
+ attempt: this.#physicalAttempts,
765
+ ...this.#routeIdentityFields(),
766
+ physicalConnects: this.#physicalConnects,
767
+ reconnect: this.#physicalConnects > 1
768
+ })
769
+ if (this.#physicalConnects > 1) {
770
+ logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
771
+ generation,
772
+ attempt: this.#reconnectAttempts,
773
+ ...this.#routeIdentityFields(),
774
+ physicalConnects: this.#physicalConnects
775
+ })
776
+ }
777
+ this.#resolveConnect?.()
778
+ this.#settleConnect()
779
+ for (const stream of this.#streams.values()) {
780
+ if (stream.state !== "opening") stream.socket._markConnected()
781
+ if (stream.remoteEndPending) {
782
+ stream.remoteEndPending = false
783
+ stream.socket._receiveEnd()
784
+ }
785
+ void this.#pumpStream(stream).catch((error) => stream.socket._receiveError(asError(error)))
786
+ }
787
+ this.#progressPendingWrites()
788
+ }
789
+
499
790
  /** @param {ReverseClientStream} stream - Stream. */
500
791
  async #pumpStream(stream) {
501
792
  if (stream.pumping || stream.state === "opening" || !this.#streams.has(stream.id) || this.#state !== "ready") return
@@ -516,7 +807,12 @@ export class ReverseGatewayConnection extends EventEmitter {
516
807
  if (stream.localEnded && !stream.endSent && stream.replay.every((chunk) => chunk.sent)) {
517
808
  stream.endSent = true
518
809
  stream.state = "half-closed"
519
- await this.#sendControl(V3FrameType.END, stream.id, stream.sendSequence, {})
810
+ const delivered = await this.#sendControl(V3FrameType.END, stream.id, stream.sendSequence, {})
811
+ if (!delivered) {
812
+ stream.endSent = false
813
+ return
814
+ }
815
+ await this.#persistStreamState(stream)
520
816
  this.#finishEnd(stream)
521
817
  }
522
818
  } finally { stream.pumping = false }
@@ -533,8 +829,7 @@ export class ReverseGatewayConnection extends EventEmitter {
533
829
  if (available <= 0) return
534
830
  const chunk = Buffer.from(pending.data.subarray(pending.offset, Math.min(pending.data.length, pending.offset + available)))
535
831
  try {
536
- if (this.#options.aggregateBudget?.reserveMemory !== undefined) this.#options.aggregateBudget.reserveMemory(chunk.length)
537
- else this.#options.aggregateBudget?.reserveData(chunk.length)
832
+ this.#options.aggregateBudget?.reserveData(chunk.length)
538
833
  } catch (error) {
539
834
  this.#finishWrite(stream, asError(error))
540
835
  return
@@ -544,11 +839,19 @@ export class ReverseGatewayConnection extends EventEmitter {
544
839
  stream.replay.push({sequence: stream.sendSequence, data: chunk, sent: false})
545
840
  stream.bufferedBytes += chunk.length
546
841
  this.#bufferedBytes += chunk.length
842
+ stream.spoolReservedBytes += chunk.length
843
+ this.#spoolBytes += chunk.length
844
+ await this.#queueSpoolOperation(stream, async () => {
845
+ await this.#spool.appendChunk({sessionId: stream.identifier, ownerId: "reverse-client-v3", sequence: stream.sendSequence, data: chunk})
846
+ await this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream))
847
+ })
547
848
  }
548
849
  await this.#pumpStream(stream)
549
850
  if (pending.offset === pending.data.length && stream.pendingWrite === pending) this.#finishWrite(stream)
550
851
  } catch (error) {
551
- this.#finishWrite(stream, asError(error))
852
+ const failure = asError(error)
853
+ this.#finishWrite(stream, failure)
854
+ stream.socket._receiveError(failure)
552
855
  } finally {
553
856
  stream.progressingWrite = false
554
857
  this.#progressPendingWrites()
@@ -601,10 +904,10 @@ export class ReverseGatewayConnection extends EventEmitter {
601
904
  if (!this.#streams.delete(stream.id)) return
602
905
  this.#bufferedBytes = Math.max(0, this.#bufferedBytes - stream.bufferedBytes)
603
906
  this.#options.aggregateBudget?.releaseMemory(stream.bufferedBytes)
604
- if (this.#options.aggregateBudget?.reserveMemory === undefined) this.#options.aggregateBudget?.releaseSpool(stream.bufferedBytes)
605
907
  stream.replay = []
606
908
  stream.bufferedBytes = 0
607
909
  this.#finishWrite(stream, pendingWriteError)
910
+ if (!this.#preserveSpools) this.#trackSpoolCleanup(stream)
608
911
  this.#progressPendingWrites()
609
912
  }
610
913
 
@@ -619,8 +922,15 @@ export class ReverseGatewayConnection extends EventEmitter {
619
922
  stream.bufferedBytes = Math.max(0, stream.bufferedBytes - released)
620
923
  this.#bufferedBytes = Math.max(0, this.#bufferedBytes - released)
621
924
  this.#options.aggregateBudget?.releaseMemory(released)
622
- if (this.#options.aggregateBudget?.reserveMemory === undefined) this.#options.aggregateBudget?.releaseSpool(released)
623
925
  stream.sendWindow = Math.min(stream.sendWindowLimit, stream.sendWindow + delta)
926
+ void this.#queueSpoolOperation(stream, async () => {
927
+ await this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream))
928
+ await this.#spool.truncateAcked(stream.identifier, sequence)
929
+ if (released > stream.spoolReservedBytes) throw new Error("Reverse client spool budget release underflow")
930
+ stream.spoolReservedBytes -= released
931
+ this.#spoolBytes = Math.max(0, this.#spoolBytes - released)
932
+ this.#options.aggregateBudget?.releaseSpool(released)
933
+ }).catch((error) => stream.socket._receiveError(asError(error)))
624
934
  this.#progressPendingWrites()
625
935
  void this.#pumpStream(stream).catch((error) => stream.socket._receiveError(asError(error)))
626
936
  }
@@ -636,7 +946,9 @@ export class ReverseGatewayConnection extends EventEmitter {
636
946
  #ackReceived(stream, sequence) {
637
947
  if (sequence <= stream.receiveAck) return
638
948
  stream.receiveAck = sequence
639
- void this.#sendControl(V3FrameType.ACK, stream.id, sequence, {}).catch(() => {})
949
+ void this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))
950
+ .then(() => this.#sendControl(V3FrameType.ACK, stream.id, sequence, {}).catch(() => {}))
951
+ .catch((error) => stream.socket._receiveError(asError(error)))
640
952
  }
641
953
 
642
954
  /** @param {number} type - Type. @param {number} streamId - Stream. @param {number} sequence - Sequence. @param {Record<string, unknown>} body - Body. */
@@ -674,6 +986,76 @@ export class ReverseGatewayConnection extends EventEmitter {
674
986
  }
675
987
  }
676
988
 
989
+ /** @param {ReverseClientStream} stream - Durable stream state. */
990
+ #recoveryState(stream) {
991
+ return {
992
+ version: 3,
993
+ role: "reverse-client",
994
+ connectionId: this.#connectionId,
995
+ generation: this.#generation,
996
+ ...this.#routeIdentityFields(),
997
+ streamId: stream.id,
998
+ identifier: stream.identifier,
999
+ name: stream.name,
1000
+ target: stream.target,
1001
+ state: stream.state,
1002
+ opened: stream.opened,
1003
+ openingSent: stream.openingSent,
1004
+ sendSequence: stream.sendSequence,
1005
+ sendAck: stream.sendAck,
1006
+ receiveSequence: stream.receiveSequence,
1007
+ receiveAck: stream.receiveAck,
1008
+ localEnded: stream.localEnded,
1009
+ remoteEnded: stream.remoteEnded,
1010
+ endSent: stream.endSent
1011
+ }
1012
+ }
1013
+
1014
+ /** @param {ReverseClientStream} stream - Durable stream state. */
1015
+ async #persistStreamState(stream) {
1016
+ await this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))
1017
+ }
1018
+
1019
+ /** @param {ReverseClientStream} stream - Spool owner. @param {() => Promise<void>} operation - Serialized durable operation. */
1020
+ async #queueSpoolOperation(stream, operation) {
1021
+ const next = stream.spoolQueue.then(async () => {
1022
+ await stream.spoolReady
1023
+ await operation()
1024
+ })
1025
+ stream.spoolQueue = next.catch(() => {})
1026
+ await next
1027
+ }
1028
+
1029
+ /** @param {ReverseClientStream} stream - Terminal stream. */
1030
+ async #removeSpool(stream) {
1031
+ if (stream.spoolRemovalPromise === undefined) {
1032
+ stream.spoolRemovalPromise = this.#queueSpoolOperation(stream, async () => {
1033
+ await this.#spool.remove(stream.identifier)
1034
+ const removed = stream.spoolReservedBytes
1035
+ stream.spoolReservedBytes = 0
1036
+ this.#spoolBytes = Math.max(0, this.#spoolBytes - removed)
1037
+ this.#options.aggregateBudget?.releaseSpool(removed)
1038
+ })
1039
+ }
1040
+ await stream.spoolRemovalPromise
1041
+ }
1042
+
1043
+ /** @param {ReverseClientStream} stream - Terminal stream. */
1044
+ #trackSpoolCleanup(stream) {
1045
+ const removal = stream.ownsSpool
1046
+ ? this.#removeSpool(stream)
1047
+ : stream.spoolReady.then(() => stream.ownsSpool ? this.#removeSpool(stream) : undefined)
1048
+ const task = removal.catch((error) => {
1049
+ if (!stream.spoolCleanupFailureRecorded) {
1050
+ stream.spoolCleanupFailureRecorded = true
1051
+ this.#spoolCleanupFailures += 1
1052
+ }
1053
+ throw error
1054
+ })
1055
+ this.#spoolCleanupTasks.add(task)
1056
+ void task.catch(() => {}).finally(() => this.#spoolCleanupTasks.delete(task))
1057
+ }
1058
+
677
1059
  #settleConnect() { this.#connectPromise = undefined; this.#resolveConnect = undefined; this.#rejectConnect = undefined }
678
1060
  #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
1061
  /** @returns {{gatewayId: string} | {routeClass: string}} Stable physical route identity. */
@@ -705,9 +1087,76 @@ function validateOptions(options) {
705
1087
  positiveInteger(options.maxStreams, "maxStreams", DEFAULT_MAX_STREAMS)
706
1088
  positiveInteger(options.maxStreamBufferBytes, "maxStreamBufferBytes", DEFAULT_STREAM_BUFFER_BYTES)
707
1089
  positiveInteger(options.maxConnectionBufferBytes, "maxConnectionBufferBytes", DEFAULT_CONNECTION_BUFFER_BYTES)
1090
+ if (options.spoolDirectory !== undefined && (typeof options.spoolDirectory !== "string" || options.spoolDirectory.length === 0)) throw new TypeError("spoolDirectory must be a non-empty string")
1091
+ positiveInteger(options.maxAgeMs, "maxAgeMs", 24 * 60 * 60 * 1000)
708
1092
  if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
709
1093
  }
710
1094
 
1095
+ /**
1096
+ * @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.
1098
+ * @returns {ReverseClientRecoveryState} Validated state.
1099
+ */
1100
+ function validateRecoveryState(value, expected) {
1101
+ const state = /** @type {Partial<ReverseClientRecoveryState>} */ (value)
1102
+ const sequences = [state.generation, state.streamId, state.sendSequence, state.sendAck, state.receiveSequence, state.receiveAck]
1103
+ const validSequences = sequences.every((sequence) => Number.isSafeInteger(sequence) && /** @type {number} */ (sequence) >= 0)
1104
+ const validTarget = state.target?.host === expected.target.host && state.target?.port === expected.target.port
1105
+ const expectedState = state.opened === true ? (state.localEnded === true ? "half-closed" : "open") : "opening"
1106
+ const selectorMatches = "gatewayId" in expected.selection
1107
+ ? state.gatewayId === expected.selection.gatewayId && !Object.hasOwn(state, "routeClass")
1108
+ : state.routeClass === expected.selection.routeClass && !Object.hasOwn(state, "gatewayId")
1109
+ if (
1110
+ state.version !== 3
1111
+ || state.role !== "reverse-client"
1112
+ || state.connectionId !== expected.connectionId
1113
+ || state.identifier !== expected.identifier
1114
+ || !validSequences
1115
+ || state.generation === 0
1116
+ || state.generation === Number.MAX_SAFE_INTEGER
1117
+ || state.streamId === 0
1118
+ || /** @type {number} */ (state.streamId) % 2 === 0
1119
+ || /** @type {number} */ (state.streamId) > 0xffff_fffd
1120
+ || typeof state.name !== "string"
1121
+ || state.name.length === 0
1122
+ || state.name.length > 256
1123
+ || !validTarget
1124
+ || !selectorMatches
1125
+ || typeof state.opened !== "boolean"
1126
+ || state.openingSent !== true
1127
+ || typeof state.localEnded !== "boolean"
1128
+ || typeof state.remoteEnded !== "boolean"
1129
+ || typeof state.endSent !== "boolean"
1130
+ || (state.endSent && !state.localEnded)
1131
+ || (state.endSent && state.opened !== true)
1132
+ || (state.remoteEnded && state.opened !== true)
1133
+ || (state.opened !== true && (/** @type {number} */ (state.sendSequence) > 0 || /** @type {number} */ (state.receiveSequence) > 0))
1134
+ || state.state !== expectedState
1135
+ || /** @type {number} */ (state.sendAck) > /** @type {number} */ (state.sendSequence)
1136
+ || /** @type {number} */ (state.receiveAck) > /** @type {number} */ (state.receiveSequence)
1137
+ || /** @type {number} */ (state.receiveSequence) - /** @type {number} */ (state.receiveAck) > expected.initialWindow
1138
+ || (expected.sessionName !== undefined && state.name !== expected.sessionName)
1139
+ ) throw new Error("Invalid reverse client recovery identity, selector, lifecycle, or sequence state")
1140
+ return /** @type {ReverseClientRecoveryState} */ (state)
1141
+ }
1142
+
1143
+ /**
1144
+ * @param {ReverseClientRecoveryState} state - Validated durable state.
1145
+ * @param {import('../spool/spool-file.js').SpoolChunk[]} chunks - Durable chunks.
1146
+ * @param {number} maxBytes - Per-stream byte bound.
1147
+ * @param {number} maxFrameBytes - Per-chunk frame bound.
1148
+ */
1149
+ function validateRecoveredChunks(state, chunks, maxBytes, maxFrameBytes) {
1150
+ let sequence = state.sendAck
1151
+ let bytes = 0
1152
+ for (const chunk of chunks) {
1153
+ bytes += chunk.data.length
1154
+ 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")
1155
+ sequence = chunk.sequence
1156
+ }
1157
+ if (sequence !== state.sendSequence || bytes > maxBytes) throw new Error("Invalid or oversized reverse client recovery replay state")
1158
+ }
1159
+
711
1160
  /** @param {VirtualSocket} socket - Socket. @param {(error: Error | null, socket: VirtualSocket) => void} callback - Callback. */
712
1161
  function installCallback(socket, callback) {
713
1162
  let settled = false