socketduct 0.1.18 → 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.
@@ -70,6 +70,8 @@ const MANIFEST_PAGE_STREAMS = 64
70
70
  * @property {{host: string, port: number}} target
71
71
  * @property {VirtualSocket} socket
72
72
  * @property {'opening' | 'open' | 'half-closed' | 'closed' | 'reset'} state
73
+ * @property {boolean} opened
74
+ * @property {boolean} openingSent
73
75
  * @property {number} sendSequence
74
76
  * @property {number} transmittedSequence
75
77
  * @property {number} sendAck
@@ -77,8 +79,9 @@ const MANIFEST_PAGE_STREAMS = 64
77
79
  * @property {number} sendWindowLimit
78
80
  * @property {number} receiveSequence
79
81
  * @property {number} receiveAck
80
- * @property {number | undefined} pendingReceiveAck
82
+ * @property {Array<{sequence: number, remaining: number}>} receiveFrames
81
83
  * @property {boolean} remoteEnded
84
+ * @property {boolean} remoteEndPending
82
85
  * @property {number} bufferedBytes
83
86
  * @property {ClientChunk[]} replay
84
87
  * @property {AdaptiveCompressor} compressor
@@ -93,6 +96,29 @@ const MANIFEST_PAGE_STREAMS = 64
93
96
  * @property {number} spoolReservedBytes
94
97
  * @property {Promise<void> | undefined} spoolRemovalPromise
95
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
96
122
  */
97
123
 
98
124
  /** Server3/TensorBuzz-facing virtual socket connection to an exact gateway or server-resolved route class. */
@@ -106,6 +132,7 @@ export class ReverseGatewayConnection extends EventEmitter {
106
132
  /** @type {number} */ #generation
107
133
  /** @type {string | undefined} */ #codec
108
134
  /** @type {boolean} */ #closing = false
135
+ /** @type {boolean} */ #preserveSpools = false
109
136
  /** @type {boolean} */ #fatal = false
110
137
  /** @type {Error | undefined} */ #fatalError
111
138
  /** @type {boolean} */ #referenced = true
@@ -127,6 +154,7 @@ export class ReverseGatewayConnection extends EventEmitter {
127
154
  /** @type {number} */ #wireDataBytes = 0
128
155
  /** @type {number} */ #lastProgressedStreamId = 0
129
156
  /** @type {Promise<void>} */ #openQueue = Promise.resolve()
157
+ /** @type {Promise<void>} */ #physicalFrameQueue = Promise.resolve()
130
158
  /** @type {SessionSpool} */ #spool
131
159
  /** @type {number} */ #spoolBytes = 0
132
160
  /** @type {number} */ #spoolCleanupFailures = 0
@@ -163,15 +191,45 @@ export class ReverseGatewayConnection extends EventEmitter {
163
191
 
164
192
  /** Allocates one stream on the already-warm gateway transport. */
165
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) {
166
209
  if (this.#closing) throw closedError()
167
- if (this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
210
+ if (!recovering && this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
168
211
  if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum reverse virtual stream count exceeded")
169
212
  const target = parseTarget(options?.target ?? {host: options?.host, port: options?.port})
170
213
  if (target === undefined) throw new TypeError("Reverse virtual connection target host and port are required")
171
- const identifier = options.identifier ?? randomBytes(24).toString("base64url")
214
+ const identifier = recovering ? options.identifier : (options.identifier ?? randomBytes(24).toString("base64url"))
172
215
  if (typeof identifier !== "string" || !STABLE_IDENTIFIER.test(identifier)) throw new TypeError("Reverse virtual stream identifier must be 8-128 URL-safe characters")
173
- const id = this.#allocateStreamId()
174
- const name = options.sessionName ?? `socketduct-reverse-${id}`
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}`
175
233
  if (typeof name !== "string" || name.length === 0 || name.length > 256) throw new TypeError("Reverse virtual stream name must be 1-256 characters")
176
234
  const socket = new VirtualSocket(/** @type {import('../client/multiplexed-connection.js').MultiplexedSocketductConnection} */ (/** @type {unknown} */ (this)), id, {
177
235
  ...target,
@@ -182,55 +240,78 @@ export class ReverseGatewayConnection extends EventEmitter {
182
240
  /** @type {ReverseClientStream} */
183
241
  const stream = {
184
242
  id, identifier, name, target, socket,
185
- state: "opening",
186
- sendSequence: 0,
187
- transmittedSequence: 0,
188
- 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,
189
249
  sendWindow: this.#sendWindowLimit,
190
250
  sendWindowLimit: this.#sendWindowLimit,
191
- receiveSequence: 0,
192
- receiveAck: 0,
193
- pendingReceiveAck: undefined,
194
- remoteEnded: false,
195
- bufferedBytes: 0,
196
- 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})),
197
260
  compressor: new AdaptiveCompressor(),
198
261
  pumping: false,
199
262
  pendingWrite: undefined,
200
263
  progressingWrite: false,
201
- localEnded: false,
202
- endSent: false,
264
+ localEnded: recovery?.localEnded ?? false,
265
+ endSent: recovery?.endSent ?? false,
203
266
  endCallback: undefined,
204
267
  spoolReady: Promise.resolve(),
205
268
  spoolQueue: Promise.resolve(),
206
- spoolReservedBytes: 0,
269
+ spoolReservedBytes: bufferedBytes,
207
270
  spoolRemovalPromise: undefined,
208
- spoolCleanupFailureRecorded: false
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)
209
281
  }
210
282
  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)))
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)
213
290
  stream.spoolQueue = stream.spoolReady.catch(() => {})
214
291
  logDiagnostic(this.#options.logger, "virtual_stream.opening", {
215
292
  streamId: id,
216
293
  generation: this.#generation,
217
294
  ...this.#routeIdentityFields(),
218
- recovered: false
295
+ recovered: recovery !== undefined
219
296
  })
220
297
  if (callback !== undefined) installCallback(socket, callback)
221
- const opening = this.#openQueue
222
- .then(() => stream.spoolReady)
223
- .then(() => this.#streams.get(id) === stream ? this.#sendControl(V3FrameType.OPEN, id, 0, {...this.#routeIdentityFields(), identifier, name, target, initialWindow: this.#initialWindow()}) : undefined)
224
- this.#openQueue = opening.then(() => undefined, () => undefined)
225
- void opening.catch((error) => socket._receiveError(asError(error)))
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
+ }
226
312
  return socket
227
313
  }
228
314
 
229
- /** Process replacement cannot inherit the live reverse target/app socket pair. */
230
- registerRecoveredConnection() {
231
- throw Object.assign(new Error("Reverse gateway process recovery is unsupported; live streams resume only within the original process"), {code: "REVERSE_STREAM_RECOVERY_UNSUPPORTED"})
232
- }
233
-
234
315
  status() {
235
316
  /** @type {Record<string, number>} */
236
317
  const streamStates = {}
@@ -280,23 +361,46 @@ export class ReverseGatewayConnection extends EventEmitter {
280
361
  if (failures.length > 0) throw new AggregateError(failures, "Reverse client spool cleanup failed")
281
362
  }
282
363
 
283
- /** Orderly object detach is terminal; in-object physical reconnect uses paired resume. */
284
- 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
+ }
285
388
 
286
389
  _writeStream(/** @type {number} */ streamId, /** @type {Buffer} */ data, /** @type {(error?: Error | null) => void} */ callback) {
287
390
  const stream = this.#streams.get(streamId)
288
- if (stream === undefined || stream.localEnded || stream.state === "closed" || stream.state === "reset") { callback(new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED")); return }
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 }
289
392
  stream.pendingWrite = {data, offset: 0, callback}
290
393
  this.#progressPendingWrites()
291
394
  }
292
395
 
293
396
  _endStream(/** @type {number} */ streamId, /** @type {(error?: Error | null) => void} */ callback) {
294
397
  const stream = this.#streams.get(streamId)
295
- if (stream === undefined || stream.localEnded) { callback(); return }
398
+ if (this.#closing || stream === undefined || stream.localEnded) { callback(); return }
296
399
  stream.localEnded = true
297
400
  if (stream.state === "closed") { callback(); return }
401
+ if (stream.opened && stream.state === "open") stream.state = "half-closed"
298
402
  stream.endCallback = callback
299
- 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)))
300
404
  }
301
405
 
302
406
  _destroyStream(/** @type {number} */ streamId, /** @type {Error | null} */ error) {
@@ -314,11 +418,26 @@ export class ReverseGatewayConnection extends EventEmitter {
314
418
  }
315
419
 
316
420
  _resumeReadable(/** @type {number} */ streamId) {
421
+ void streamId
422
+ }
423
+
424
+ _consumeReadable(/** @type {number} */ streamId, /** @type {number} */ bytes) {
317
425
  const stream = this.#streams.get(streamId)
318
- if (stream?.pendingReceiveAck === undefined) return
319
- const sequence = stream.pendingReceiveAck
320
- stream.pendingReceiveAck = undefined
321
- this.#ackReceived(stream, sequence)
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)
322
441
  }
323
442
 
324
443
  #allocateStreamId() {
@@ -361,6 +480,7 @@ export class ReverseGatewayConnection extends EventEmitter {
361
480
  physical.setKeepAlive(true, DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS)
362
481
  const decoder = new V3FrameDecoder({maxFrameBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4), maxBufferedBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4) * 2})
363
482
  let queue = Promise.resolve()
483
+ this.#physicalFrameQueue = queue
364
484
  let authenticated = false
365
485
  let selected = false
366
486
  /** @type {Error | undefined} */
@@ -368,14 +488,21 @@ export class ReverseGatewayConnection extends EventEmitter {
368
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)
369
489
  timer.unref?.()
370
490
  physical.once("secureConnect", () => {
371
- const body = encodeV3Control({role: "client", token: this.#options.hub.token, versions: [3], connectionId: this.#connectionId, generation, compression: availableCodecs()})
372
- void writeSocket(physical, encodeV3Frame({type: V3FrameType.AUTH, streamId: 0, sequence: generation}, body))
373
- .catch((error) => physical.destroy(asError(error)))
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)))
374
499
  })
375
500
  physical.on("data", (chunk) => {
501
+ if (this.#closing || this.#physical !== physical) return
376
502
  physical.pause()
377
503
  queue = queue.then(async () => {
378
504
  for (const frame of decoder.push(chunk)) {
505
+ if (this.#closing || this.#physical !== physical) return
379
506
  if (frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.GOAWAY) {
380
507
  const detail = decodeV3Control(frame.payload)
381
508
  throw connectionError(typeof detail.code === "string" ? detail.code : "CONNECTION_REJECTED")
@@ -387,6 +514,10 @@ export class ReverseGatewayConnection extends EventEmitter {
387
514
  authenticated = true
388
515
  this.#codec = ZSTD_CODEC
389
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
+ }
390
521
  await writeSocket(physical, encodeV3Frame({type: V3FrameType.GATEWAY_SELECT, streamId: 0, sequence: generation}, encodeV3Control(this.#routeIdentityFields())))
391
522
  continue
392
523
  }
@@ -397,28 +528,26 @@ export class ReverseGatewayConnection extends EventEmitter {
397
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")
398
529
  if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_SELECTION_FAILED")
399
530
  selected = true
400
- const reconnecting = this.#physicalConnects > 0
401
- if (reconnecting && this.#streams.size > 0) {
402
- if (!detail.resumed) {
403
- for (const stream of [...this.#streams.values()]) stream.socket._receiveError(new SocketductSessionLostError("Reverse gateway hub lost the retained stream", "RESUME_SESSION_LOST"))
404
- this.#completePhysical(generation, timer)
405
- } else {
406
- clearTimeout(timer)
407
- this.#resuming = true
408
- await this.#sendResumeManifest(generation)
409
- }
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)
410
538
  } else {
411
539
  this.#completePhysical(generation, timer)
412
540
  }
413
541
  continue
414
542
  }
415
- await this.#handleFrame(frame)
543
+ await this.#handleFrame(frame, physical)
416
544
  }
417
545
  }).catch((error) => {
418
546
  attemptError = asError(error)
419
547
  if (/** @type {{fatal?: unknown}} */ (attemptError).fatal === true) this.#fatal = true
420
548
  physical.destroy(attemptError)
421
549
  }).finally(() => { if (!physical.destroyed) physical.resume() })
550
+ this.#physicalFrameQueue = queue
422
551
  })
423
552
  physical.once("error", (error) => { attemptError = error; if (isFatalTransportError(error)) this.#fatal = true })
424
553
  physical.once("close", () => {
@@ -447,6 +576,8 @@ export class ReverseGatewayConnection extends EventEmitter {
447
576
  if (this.#closing) return
448
577
  if (this.#fatal) {
449
578
  const terminalError = attemptError ?? lost
579
+ const terminalCode = /** @type {{code?: unknown}} */ (terminalError).code
580
+ if (terminalCode === "STALE_GENERATION" || terminalCode === "CONNECTION_REPLACED") this.#preserveSpools = true
450
581
  this.#fatalError = terminalError
451
582
  this.#setState("closed", terminalError)
452
583
  logDiagnostic(this.#options.logger, "transport.failed", {
@@ -465,8 +596,9 @@ export class ReverseGatewayConnection extends EventEmitter {
465
596
  })
466
597
  }
467
598
 
468
- /** @param {import('../protocol/v3-frame.js').V3Frame} frame - Hub frame. */
469
- 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
470
602
  if (frame.type === V3FrameType.PING) { await this.#sendControl(V3FrameType.PONG, 0, frame.sequence, {}); return }
471
603
  if (frame.type === V3FrameType.PONG) return
472
604
  if (frame.type === V3FrameType.RESUMED) {
@@ -476,7 +608,8 @@ export class ReverseGatewayConnection extends EventEmitter {
476
608
  this.#resumeNextPage += 1
477
609
  this.#resumeResults.push(...manifest.streams.filter((item) => typeof item === "object" && item !== null))
478
610
  if (manifest.final !== true) return
479
- for (const item of this.#resumeResults) this.#applyResumeResult(item)
611
+ for (const item of this.#resumeResults) await this.#applyResumeResult(item)
612
+ if (this.#closing || this.#physical !== physical) return
480
613
  this.#resumeResults = []
481
614
  this.#resumeNextPage = 0
482
615
  this.#resuming = false
@@ -493,13 +626,16 @@ export class ReverseGatewayConnection extends EventEmitter {
493
626
  if (stream.state !== "opening" || frame.sequence !== 0 || detail.identifier !== stream.identifier || !validPeerWindow(detail.initialWindow)) throw new Error("Invalid reverse OPENED response")
494
627
  stream.sendWindowLimit = Math.min(stream.sendWindowLimit, detail.initialWindow)
495
628
  stream.sendWindow = Math.min(stream.sendWindow, stream.sendWindowLimit)
496
- stream.state = "open"
629
+ stream.state = stream.localEnded ? "half-closed" : "open"
630
+ stream.opened = true
497
631
  logDiagnostic(this.#options.logger, "virtual_stream.opened", {
498
632
  streamId: stream.id,
499
633
  generation: this.#generation,
500
634
  ...this.#routeIdentityFields(),
501
635
  recovered: false
502
636
  })
637
+ await this.#persistStreamState(stream)
638
+ if (this.#closing || this.#physical !== physical) return
503
639
  stream.socket._markConnected()
504
640
  this.#progressPendingWrites()
505
641
  await this.#pumpStream(stream)
@@ -508,11 +644,11 @@ export class ReverseGatewayConnection extends EventEmitter {
508
644
  if (frame.type === V3FrameType.DATA) {
509
645
  if (stream.remoteEnded) throw new Error("Reverse client received DATA after END")
510
646
  const data = await decodeV3Data(frame, this.#codec, this.#options.compressionLimits)
647
+ if (this.#closing || this.#physical !== physical) return
511
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")
512
649
  stream.receiveSequence = frame.sequence
513
- const canContinue = stream.socket._receive(data)
514
- if (canContinue) this.#ackReceived(stream, frame.sequence)
515
- else stream.pendingReceiveAck = frame.sequence
650
+ stream.receiveFrames.push({sequence: frame.sequence, remaining: data.length})
651
+ stream.socket._receive(data)
516
652
  return
517
653
  }
518
654
  if (frame.type === V3FrameType.ACK) { this.#acknowledge(stream, frame.sequence); return }
@@ -520,12 +656,15 @@ export class ReverseGatewayConnection extends EventEmitter {
520
656
  if (frame.type === V3FrameType.END) {
521
657
  if (stream.remoteEnded || frame.sequence !== stream.receiveSequence) throw new Error("Invalid reverse client END")
522
658
  stream.remoteEnded = true
659
+ await this.#persistStreamState(stream)
660
+ if (this.#closing || this.#physical !== physical) return
523
661
  stream.socket._receiveEnd()
524
662
  return
525
663
  }
526
664
  if (frame.type === V3FrameType.CLOSE) {
527
665
  if (frame.sequence !== stream.receiveSequence) throw new Error("Invalid reverse client CLOSE")
528
666
  await this.#sendControl(V3FrameType.CLOSE, stream.id, stream.transmittedSequence, {})
667
+ if (this.#closing || this.#physical !== physical) return
529
668
  stream.state = "closed"
530
669
  logDiagnostic(this.#options.logger, "virtual_stream.closed", {
531
670
  streamId: stream.id,
@@ -558,7 +697,9 @@ export class ReverseGatewayConnection extends EventEmitter {
558
697
  identifier: stream.identifier,
559
698
  clientToGatewaySequence: stream.sendSequence,
560
699
  clientToGatewayAck: stream.sendAck,
561
- gatewayToClientSequence: stream.receiveSequence,
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,
562
703
  gatewayToClientAck: stream.receiveAck,
563
704
  localEnded: stream.localEnded,
564
705
  remoteEnded: stream.remoteEnded
@@ -576,7 +717,7 @@ export class ReverseGatewayConnection extends EventEmitter {
576
717
  }
577
718
 
578
719
  /** @param {unknown} value - Hub resume result. */
579
- #applyResumeResult(value) {
720
+ async #applyResumeResult(value) {
580
721
  if (typeof value !== "object" || value === null) throw new Error("Invalid reverse client resume result")
581
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)
582
723
  if (!Number.isInteger(result.streamId)) throw new Error("Invalid reverse client resume stream ID")
@@ -596,15 +737,20 @@ export class ReverseGatewayConnection extends EventEmitter {
596
737
  this.#acknowledge(stream, clientToGatewayAck)
597
738
  stream.transmittedSequence = clientToGatewayAck
598
739
  stream.receiveAck = gatewayToClientAck
599
- stream.pendingReceiveAck = stream.pendingReceiveAck !== undefined && stream.pendingReceiveAck > stream.receiveAck ? stream.pendingReceiveAck : undefined
600
740
  stream.sendWindow = Math.max(0, stream.sendWindowLimit - (clientToGatewaySequence - clientToGatewayAck))
601
741
  for (const chunk of stream.replay) chunk.sent = chunk.sequence <= clientToGatewaySequence
602
742
  stream.endSent = /** @type {boolean} */ (result.clientEnded)
603
- if (result.gatewayEnded === true && !stream.remoteEnded) {
743
+ if (result.gatewayEnded === true) {
744
+ stream.remoteEndPending = stream.recoveredObject || !stream.remoteEnded
604
745
  stream.remoteEnded = true
605
- stream.socket._receiveEnd()
606
746
  }
607
- if (result.opened === true && stream.state === "opening") stream.state = "open"
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)
608
754
  }
609
755
 
610
756
  /** Marks one authenticated/selected physical generation ready. */
@@ -632,6 +778,10 @@ export class ReverseGatewayConnection extends EventEmitter {
632
778
  this.#settleConnect()
633
779
  for (const stream of this.#streams.values()) {
634
780
  if (stream.state !== "opening") stream.socket._markConnected()
781
+ if (stream.remoteEndPending) {
782
+ stream.remoteEndPending = false
783
+ stream.socket._receiveEnd()
784
+ }
635
785
  void this.#pumpStream(stream).catch((error) => stream.socket._receiveError(asError(error)))
636
786
  }
637
787
  this.#progressPendingWrites()
@@ -662,6 +812,7 @@ export class ReverseGatewayConnection extends EventEmitter {
662
812
  stream.endSent = false
663
813
  return
664
814
  }
815
+ await this.#persistStreamState(stream)
665
816
  this.#finishEnd(stream)
666
817
  }
667
818
  } finally { stream.pumping = false }
@@ -756,7 +907,7 @@ export class ReverseGatewayConnection extends EventEmitter {
756
907
  stream.replay = []
757
908
  stream.bufferedBytes = 0
758
909
  this.#finishWrite(stream, pendingWriteError)
759
- this.#trackSpoolCleanup(stream)
910
+ if (!this.#preserveSpools) this.#trackSpoolCleanup(stream)
760
911
  this.#progressPendingWrites()
761
912
  }
762
913
 
@@ -841,19 +992,30 @@ export class ReverseGatewayConnection extends EventEmitter {
841
992
  version: 3,
842
993
  role: "reverse-client",
843
994
  connectionId: this.#connectionId,
995
+ generation: this.#generation,
996
+ ...this.#routeIdentityFields(),
844
997
  streamId: stream.id,
845
998
  identifier: stream.identifier,
846
999
  name: stream.name,
847
1000
  target: stream.target,
1001
+ state: stream.state,
1002
+ opened: stream.opened,
1003
+ openingSent: stream.openingSent,
848
1004
  sendSequence: stream.sendSequence,
849
1005
  sendAck: stream.sendAck,
850
1006
  receiveSequence: stream.receiveSequence,
851
1007
  receiveAck: stream.receiveAck,
852
1008
  localEnded: stream.localEnded,
853
- remoteEnded: stream.remoteEnded
1009
+ remoteEnded: stream.remoteEnded,
1010
+ endSent: stream.endSent
854
1011
  }
855
1012
  }
856
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
+
857
1019
  /** @param {ReverseClientStream} stream - Spool owner. @param {() => Promise<void>} operation - Serialized durable operation. */
858
1020
  async #queueSpoolOperation(stream, operation) {
859
1021
  const next = stream.spoolQueue.then(async () => {
@@ -880,7 +1042,10 @@ export class ReverseGatewayConnection extends EventEmitter {
880
1042
 
881
1043
  /** @param {ReverseClientStream} stream - Terminal stream. */
882
1044
  #trackSpoolCleanup(stream) {
883
- const task = this.#removeSpool(stream).catch((error) => {
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) => {
884
1049
  if (!stream.spoolCleanupFailureRecorded) {
885
1050
  stream.spoolCleanupFailureRecorded = true
886
1051
  this.#spoolCleanupFailures += 1
@@ -927,6 +1092,71 @@ function validateOptions(options) {
927
1092
  if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
928
1093
  }
929
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
+
930
1160
  /** @param {VirtualSocket} socket - Socket. @param {(error: Error | null, socket: VirtualSocket) => void} callback - Callback. */
931
1161
  function installCallback(socket, callback) {
932
1162
  let settled = false