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 {EventEmitter} from "node:events"
3
3
  import {createConnection as netConnect} from "node:net"
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 {
10
13
  asError,
11
14
  createHubSocket,
@@ -25,6 +28,7 @@ import {
25
28
 
26
29
  const DEFAULT_STREAM_BUFFER_BYTES = 4 * 1024 * 1024
27
30
  const DEFAULT_TOTAL_BUFFER_BYTES = 32 * 1024 * 1024
31
+ const MANIFEST_PAGE_STREAMS = 64
28
32
 
29
33
  /**
30
34
  * @typedef {object} OutboundReverseGatewayOptions
@@ -39,6 +43,8 @@ const DEFAULT_TOTAL_BUFFER_BYTES = 32 * 1024 * 1024
39
43
  * @property {number} [maxStreams] - Concurrent local target bound.
40
44
  * @property {number} [maxStreamBufferBytes] - Per-stream unacknowledged target output bound.
41
45
  * @property {number} [maxTotalBufferBytes] - Gateway-wide unacknowledged target output bound.
46
+ * @property {string} [spoolDirectory] - Durable target-output replay spool root.
47
+ * @property {number} [maxAgeMs] - Maximum reverse-stream spool age.
42
48
  * @property {number} [connectTimeoutMs] - TLS/auth/register deadline.
43
49
  * @property {number} [targetConnectTimeoutMs] - Local target connection deadline.
44
50
  * @property {number} [reconnectDelayMs] - Initial reconnect delay.
@@ -56,7 +62,7 @@ const DEFAULT_TOTAL_BUFFER_BYTES = 32 * 1024 * 1024
56
62
  * @property {string} identifier
57
63
  * @property {{host: string, port: number}} target
58
64
  * @property {import('node:net').Socket} targetSocket
59
- * @property {'opening' | 'open' | 'half-closed'} state
65
+ * @property {'opening' | 'open' | 'half-closed' | 'closing'} state
60
66
  * @property {number} receiveSequence
61
67
  * @property {number} receiveAck
62
68
  * @property {boolean} remoteEnded
@@ -73,9 +79,16 @@ const DEFAULT_TOTAL_BUFFER_BYTES = 32 * 1024 * 1024
73
79
  * @property {boolean} targetClosed
74
80
  * @property {string | undefined} targetResetCode
75
81
  * @property {boolean} endSent
82
+ * @property {boolean} closeSent
76
83
  * @property {ReturnType<typeof setTimeout> | undefined} openTimer
77
84
  * @property {boolean} targetOpened
78
85
  * @property {boolean} targetTerminalLogged
86
+ * @property {Promise<void>} openedReady
87
+ * @property {Promise<void>} spoolReady
88
+ * @property {Promise<void>} spoolQueue
89
+ * @property {number} spoolReservedBytes
90
+ * @property {Promise<void> | undefined} spoolRemovalPromise
91
+ * @property {boolean} spoolCleanupFailureRecorded
79
92
  */
80
93
 
81
94
  /** One outbound home member carrying many hub-opened local target streams. */
@@ -90,6 +103,9 @@ export class OutboundReverseGateway extends EventEmitter {
90
103
  /** @type {boolean} */ #closing = false
91
104
  /** @type {boolean} */ #fatal = false
92
105
  /** @type {boolean} */ #referenced = true
106
+ /** @type {boolean} */ #resuming = false
107
+ /** @type {Array<Record<string, unknown>>} */ #resumeResults = []
108
+ /** @type {number} */ #resumeNextPage = 0
93
109
  /** @type {ReturnType<typeof setTimeout> | undefined} */ #reconnectTimer
94
110
  /** @type {Promise<void> | undefined} */ #startPromise
95
111
  /** @type {(() => void) | undefined} */ #resolveStart
@@ -104,12 +120,21 @@ export class OutboundReverseGateway extends EventEmitter {
104
120
  /** @type {number} */ #bufferedBytes = 0
105
121
  /** @type {number} */ #rawDataBytes = 0
106
122
  /** @type {number} */ #wireDataBytes = 0
123
+ /** @type {SessionSpool} */ #spool
124
+ /** @type {number} */ #spoolBytes = 0
125
+ /** @type {number} */ #spoolCleanupFailures = 0
126
+ /** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
107
127
 
108
128
  /** @param {OutboundReverseGatewayOptions} options - Identity, hub, TLS and local policy. */
109
129
  constructor(options) {
110
130
  super()
111
131
  validateOptions(options)
112
132
  this.#options = options
133
+ this.#spool = new SessionSpool({
134
+ rootDir: options.spoolDirectory ?? join(tmpdir(), `socketduct-${typeof process.getuid === "function" ? process.getuid() : "user"}`, "reverse-gateway", `${options.gatewayId}-${options.connectionId}`),
135
+ maxBytes: options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES,
136
+ maxAgeMs: options.maxAgeMs ?? 24 * 60 * 60 * 1000
137
+ })
113
138
  this.#generation = options.initialGeneration ?? Date.now() * 1000
114
139
  this.#reconnectDelay = options.reconnectDelayMs ?? 250
115
140
  this.#sendWindowLimit = this.#initialWindow()
@@ -140,8 +165,8 @@ export class OutboundReverseGateway extends EventEmitter {
140
165
  targetOpens: this.#targetOpens,
141
166
  targetOpenFailures: this.#targetOpenFailures,
142
167
  queuedBytes: this.#bufferedBytes,
143
- spoolBytes: 0,
144
- spoolCleanupFailures: 0,
168
+ spoolBytes: this.#spoolBytes,
169
+ spoolCleanupFailures: this.#spoolCleanupFailures,
145
170
  compression: this.#codec ?? null,
146
171
  rawDataBytes: this.#rawDataBytes,
147
172
  wireDataBytes: this.#wireDataBytes,
@@ -173,6 +198,9 @@ export class OutboundReverseGateway extends EventEmitter {
173
198
  }
174
199
  this.#rejectStart?.(closedError())
175
200
  this.#settleStart()
201
+ const cleanup = await Promise.allSettled([...this.#spoolCleanupTasks])
202
+ const failures = cleanup.filter((result) => result.status === "rejected").map((result) => asError(result.reason))
203
+ if (failures.length > 0) throw new AggregateError(failures, "Reverse gateway spool cleanup failed")
176
204
  }
177
205
 
178
206
  #connect() {
@@ -249,28 +277,21 @@ export class OutboundReverseGateway extends EventEmitter {
249
277
  if (frame.type !== V3FrameType.GATEWAY_REGISTERED) throw fatalError("GATEWAY_REGISTRATION_FAILED")
250
278
  const detail = decodeV3Control(frame.payload)
251
279
  if (detail.gatewayId !== this.#options.gatewayId || detail.generation !== generation) throw fatalError("GATEWAY_REGISTRATION_FAILED")
280
+ if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_REGISTRATION_FAILED")
252
281
  registered = true
253
- clearTimeout(timer)
254
- this.#physicalConnects += 1
255
- this.#reconnectDelay = this.#options.reconnectDelayMs ?? 250
256
- this.#setState("ready")
257
- logDiagnostic(this.#options.logger, "transport.connected", {
258
- generation,
259
- attempt: this.#physicalAttempts,
260
- gatewayId: this.#options.gatewayId,
261
- physicalConnects: this.#physicalConnects,
262
- reconnect: this.#physicalConnects > 1
263
- })
264
- if (this.#physicalConnects > 1) {
265
- logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
266
- generation,
267
- attempt: this.#reconnectAttempts,
268
- gatewayId: this.#options.gatewayId,
269
- physicalConnects: this.#physicalConnects
270
- })
282
+ if (detail.resumed) {
283
+ // A replacement gateway intentionally owns no inherited target
284
+ // sockets. Its empty manifest makes the hub reset every omitted
285
+ // retained bridge instead of ever replaying an ambiguous OPEN.
286
+ clearTimeout(timer)
287
+ this.#resuming = true
288
+ await this.#sendResumeManifest(generation)
289
+ } else if (this.#streams.size > 0) {
290
+ for (const stream of [...this.#streams.values()]) this.#destroyStream(stream, "RESUME_SESSION_LOST")
291
+ this.#completePhysical(generation, timer)
292
+ } else {
293
+ this.#completePhysical(generation, timer)
271
294
  }
272
- this.#resolveStart?.()
273
- this.#settleStart()
274
295
  continue
275
296
  }
276
297
  await this.#handleFrame(frame)
@@ -290,6 +311,9 @@ export class OutboundReverseGateway extends EventEmitter {
290
311
  if (this.#physical !== physical) return
291
312
  this.#physical = undefined
292
313
  this.#codec = undefined
314
+ this.#resuming = false
315
+ this.#resumeResults = []
316
+ this.#resumeNextPage = 0
293
317
  if (!this.#closing && !this.#fatal) this.#setState("reconnecting")
294
318
  if (!this.#closing) {
295
319
  logDiagnostic(this.#options.logger, "transport.disconnected", {
@@ -299,8 +323,11 @@ export class OutboundReverseGateway extends EventEmitter {
299
323
  ...errorDiagnostics(attemptError, "TRANSPORT_CLOSED")
300
324
  })
301
325
  }
302
- for (const stream of [...this.#streams.values()]) this.#destroyStream(stream, "GATEWAY_DISCONNECTED")
303
- this.#lastStreamId = 0
326
+ for (const stream of this.#streams.values()) {
327
+ stream.sendWindow = stream.sendWindowLimit
328
+ for (const replay of stream.replay) replay.sent = false
329
+ stream.closeSent = false
330
+ }
304
331
  if (this.#closing) return
305
332
  if (this.#fatal) {
306
333
  this.#setState("closed")
@@ -313,6 +340,8 @@ export class OutboundReverseGateway extends EventEmitter {
313
340
  })
314
341
  this.#rejectStart?.(failure)
315
342
  this.#settleStart()
343
+ const failureCode = /** @type {{code?: unknown}} */ (failure).code
344
+ for (const stream of [...this.#streams.values()]) this.#destroyStream(stream, typeof failureCode === "string" ? failureCode : "GATEWAY_REGISTRATION_FAILED")
316
345
  return
317
346
  }
318
347
  this.#scheduleReconnect(attemptError)
@@ -324,6 +353,20 @@ export class OutboundReverseGateway extends EventEmitter {
324
353
  async #handleFrame(frame) {
325
354
  if (frame.type === V3FrameType.PING) { await this.#sendControl(V3FrameType.PONG, 0, frame.sequence, {}); return }
326
355
  if (frame.type === V3FrameType.PONG) return
356
+ if (frame.type === V3FrameType.RESUMED) {
357
+ if (!this.#resuming) throw new Error("Unexpected reverse gateway RESUMED response")
358
+ const manifest = decodeV3Control(frame.payload)
359
+ if (manifest.page !== this.#resumeNextPage || !Array.isArray(manifest.streams)) throw new Error("Invalid reverse gateway RESUMED response")
360
+ this.#resumeNextPage += 1
361
+ this.#resumeResults.push(...manifest.streams.filter((item) => typeof item === "object" && item !== null))
362
+ if (manifest.final !== true) return
363
+ for (const item of this.#resumeResults) await this.#applyResumeResult(item)
364
+ this.#resumeResults = []
365
+ this.#resumeNextPage = 0
366
+ this.#resuming = false
367
+ this.#completePhysical(this.#generation)
368
+ return
369
+ }
327
370
  if (frame.type === V3FrameType.OPEN) { await this.#openStream(frame); return }
328
371
  const stream = this.#streams.get(frame.streamId)
329
372
  if (stream === undefined) {
@@ -343,6 +386,12 @@ export class OutboundReverseGateway extends EventEmitter {
343
386
  }
344
387
  stream.receiveSequence = frame.sequence
345
388
  stream.receiveAck = frame.sequence
389
+ try {
390
+ await this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))
391
+ } catch (error) {
392
+ this.#resetStream(stream, spoolErrorCode(error))
393
+ return
394
+ }
346
395
  await this.#sendControl(V3FrameType.ACK, stream.id, frame.sequence, {})
347
396
  return
348
397
  }
@@ -359,6 +408,85 @@ export class OutboundReverseGateway extends EventEmitter {
359
408
  throw new Error("Unsupported reverse gateway stream frame")
360
409
  }
361
410
 
411
+ /** Sends the target-owning half of the paired reverse-stream resume manifest. */
412
+ async #sendResumeManifest(/** @type {number} */ generation) {
413
+ const activeStreams = [...this.#streams.values()]
414
+ await Promise.all(activeStreams.map((stream) => this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))))
415
+ const streams = activeStreams.filter((stream) => this.#streams.get(stream.id) === stream).map((stream) => ({
416
+ streamId: stream.id,
417
+ identifier: stream.identifier,
418
+ clientToGatewaySequence: stream.receiveSequence,
419
+ clientToGatewayAck: stream.receiveAck,
420
+ gatewayToClientSequence: stream.sendSequence,
421
+ gatewayToClientAck: stream.sendAck,
422
+ localEnded: stream.targetEnded,
423
+ remoteEnded: stream.remoteEnded
424
+ }))
425
+ this.#resumeResults = []
426
+ this.#resumeNextPage = 0
427
+ const pageCount = Math.max(1, Math.ceil(streams.length / MANIFEST_PAGE_STREAMS))
428
+ for (let page = 0; page < pageCount; page += 1) {
429
+ await this.#sendControl(V3FrameType.RESUME, 0, generation, {
430
+ page,
431
+ final: page === pageCount - 1,
432
+ streams: streams.slice(page * MANIFEST_PAGE_STREAMS, (page + 1) * MANIFEST_PAGE_STREAMS)
433
+ })
434
+ }
435
+ }
436
+
437
+ /** @param {unknown} value - Hub resume result. */
438
+ async #applyResumeResult(value) {
439
+ if (typeof value !== "object" || value === null) throw new Error("Invalid reverse gateway resume result")
440
+ const result = /** @type {{streamId?: unknown, status?: unknown, clientToGatewaySequence?: unknown, clientToGatewayAck?: unknown, gatewayToClientSequence?: unknown, gatewayToClientAck?: unknown, clientEnded?: unknown, gatewayEnded?: unknown, opened?: unknown, code?: unknown}} */ (value)
441
+ if (!Number.isInteger(result.streamId)) throw new Error("Invalid reverse gateway resume stream ID")
442
+ const stream = this.#streams.get(/** @type {number} */ (result.streamId))
443
+ if (stream === undefined) return
444
+ if (result.status !== "resumed") {
445
+ this.#destroyStream(stream, typeof result.code === "string" ? result.code : "RESUME_REJECTED")
446
+ return
447
+ }
448
+ const clientToGatewaySequence = /** @type {number} */ (result.clientToGatewaySequence)
449
+ const clientToGatewayAck = /** @type {number} */ (result.clientToGatewayAck)
450
+ const gatewayToClientSequence = /** @type {number} */ (result.gatewayToClientSequence)
451
+ const gatewayToClientAck = /** @type {number} */ (result.gatewayToClientAck)
452
+ const validGatewayToClientBoundary = gatewayToClientSequence === stream.sendAck || stream.replay.some((chunk) => chunk.sequence === gatewayToClientSequence)
453
+ if (!Number.isSafeInteger(clientToGatewaySequence) || clientToGatewaySequence !== stream.receiveSequence || !Number.isSafeInteger(clientToGatewayAck) || clientToGatewayAck < stream.receiveAck || clientToGatewayAck > clientToGatewaySequence || !Number.isSafeInteger(gatewayToClientSequence) || gatewayToClientSequence < stream.sendAck || gatewayToClientSequence > stream.sendSequence || !Number.isSafeInteger(gatewayToClientAck) || gatewayToClientAck < stream.sendAck || gatewayToClientAck > gatewayToClientSequence || !validGatewayToClientBoundary || typeof result.clientEnded !== "boolean" || typeof result.gatewayEnded !== "boolean" || typeof result.opened !== "boolean") throw new Error("Invalid reverse gateway resume positions")
454
+ stream.receiveAck = clientToGatewayAck
455
+ this.#acknowledge(stream, gatewayToClientAck)
456
+ stream.sendWindow = Math.max(0, stream.sendWindowLimit - (gatewayToClientSequence - gatewayToClientAck))
457
+ for (const chunk of stream.replay) chunk.sent = chunk.sequence <= gatewayToClientSequence
458
+ stream.remoteEnded = /** @type {boolean} */ (result.clientEnded)
459
+ stream.endSent = /** @type {boolean} */ (result.gatewayEnded)
460
+ stream.closeSent = false
461
+ if (stream.targetOpened && result.opened !== true) await this.#sendControl(V3FrameType.OPENED, stream.id, 0, {identifier: stream.identifier, initialWindow: this.#initialWindow()})
462
+ }
463
+
464
+ /** Marks one authenticated/registered physical generation ready. */
465
+ #completePhysical(/** @type {number} */ generation, /** @type {ReturnType<typeof setTimeout> | undefined} */ timer = undefined) {
466
+ if (timer !== undefined) clearTimeout(timer)
467
+ this.#physicalConnects += 1
468
+ this.#reconnectDelay = this.#options.reconnectDelayMs ?? 250
469
+ this.#setState("ready")
470
+ logDiagnostic(this.#options.logger, "transport.connected", {
471
+ generation,
472
+ attempt: this.#physicalAttempts,
473
+ gatewayId: this.#options.gatewayId,
474
+ physicalConnects: this.#physicalConnects,
475
+ reconnect: this.#physicalConnects > 1
476
+ })
477
+ if (this.#physicalConnects > 1) {
478
+ logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
479
+ generation,
480
+ attempt: this.#reconnectAttempts,
481
+ gatewayId: this.#options.gatewayId,
482
+ physicalConnects: this.#physicalConnects
483
+ })
484
+ }
485
+ this.#resolveStart?.()
486
+ this.#settleStart()
487
+ for (const stream of this.#streams.values()) void this.#pumpStream(stream).catch(() => this.#resetStream(stream, "TARGET_READ_FAILED"))
488
+ }
489
+
362
490
  /** @param {import('../protocol/v3-frame.js').V3Frame} frame - Hub OPEN. */
363
491
  async #openStream(frame) {
364
492
  if (frame.sequence !== 0 || frame.streamId % 2 !== 0 || frame.streamId <= this.#lastStreamId) throw new Error("Invalid reverse gateway stream ID")
@@ -423,18 +551,28 @@ export class OutboundReverseGateway extends EventEmitter {
423
551
  targetClosed: false,
424
552
  targetResetCode: undefined,
425
553
  endSent: false,
554
+ closeSent: false,
426
555
  openTimer: undefined,
427
556
  targetOpened: false,
428
- targetTerminalLogged: false
557
+ targetTerminalLogged: false,
558
+ openedReady: Promise.resolve(),
559
+ spoolReady: Promise.resolve(),
560
+ spoolQueue: Promise.resolve(),
561
+ spoolReservedBytes: 0,
562
+ spoolRemovalPromise: undefined,
563
+ spoolCleanupFailureRecorded: false
429
564
  }
430
565
  this.#streams.set(stream.id, stream)
566
+ stream.spoolReady = this.#spool.createNew({sessionId: stream.identifier, ownerId: "reverse-gateway-v3"})
567
+ .then(() => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))
568
+ stream.spoolQueue = stream.spoolReady.catch(() => {})
569
+ void stream.spoolReady.catch((error) => this.#resetStream(stream, spoolErrorCode(error)))
431
570
  stream.openTimer = setTimeout(() => this.#resetStream(stream, "TARGET_OPEN_TIMEOUT"), this.#options.targetConnectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
432
571
  stream.openTimer.unref?.()
433
572
  targetSocket.once("connect", () => {
434
573
  if (!this.#streams.has(stream.id)) return
435
574
  if (stream.openTimer !== undefined) clearTimeout(stream.openTimer)
436
575
  stream.openTimer = undefined
437
- stream.state = "open"
438
576
  stream.targetOpened = true
439
577
  this.#targetOpens += 1
440
578
  logDiagnostic(this.#options.logger, "reverse_gateway.target_opened", {
@@ -442,7 +580,14 @@ export class OutboundReverseGateway extends EventEmitter {
442
580
  generation: this.#generation,
443
581
  gatewayId: this.#options.gatewayId
444
582
  })
445
- void this.#sendControl(V3FrameType.OPENED, stream.id, 0, {identifier: stream.identifier, initialWindow: this.#initialWindow()})
583
+ stream.openedReady = stream.spoolReady.then(async () => {
584
+ if (!this.#streams.has(stream.id)) return
585
+ await this.#sendControl(V3FrameType.OPENED, stream.id, 0, {identifier: stream.identifier, initialWindow: this.#initialWindow()})
586
+ if (!this.#streams.has(stream.id)) return
587
+ stream.state = "open"
588
+ await this.#pumpStream(stream)
589
+ })
590
+ void stream.openedReady.catch((error) => this.#resetStream(stream, spoolErrorCode(error)))
446
591
  })
447
592
  targetSocket.on("data", (chunk) => {
448
593
  targetSocket.pause()
@@ -457,8 +602,8 @@ export class OutboundReverseGateway extends EventEmitter {
457
602
  })
458
603
  targetSocket.once("error", () => {
459
604
  if (!this.#streams.has(stream.id)) return
460
- const code = stream.state === "opening" ? "TARGET_OPEN_FAILED" : "TARGET_CLOSED"
461
- this.#targetOpenFailures += stream.state === "opening" ? 1 : 0
605
+ const code = stream.targetOpened ? "TARGET_CLOSED" : "TARGET_OPEN_FAILED"
606
+ this.#targetOpenFailures += stream.targetOpened ? 0 : 1
462
607
  this.#logTargetTerminal(stream, code)
463
608
  stream.targetQueue = stream.targetQueue.then(async () => {
464
609
  if (!this.#streams.has(stream.id)) return
@@ -478,7 +623,8 @@ export class OutboundReverseGateway extends EventEmitter {
478
623
 
479
624
  /** @param {GatewayStream} stream - Stream. @param {Buffer} chunk - Target output. */
480
625
  async #retainTargetData(stream, chunk) {
481
- if (!this.#streams.has(stream.id) || stream.state === "opening") return
626
+ await stream.openedReady
627
+ if (!this.#streams.has(stream.id)) return
482
628
  for (let offset = 0; offset < chunk.length; offset += this.#maxFrameBytes()) {
483
629
  const data = Buffer.from(chunk.subarray(offset, Math.min(chunk.length, offset + this.#maxFrameBytes())))
484
630
  if (stream.bufferedBytes + data.length > (this.#options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES) || this.#bufferedBytes + data.length > (this.#options.maxTotalBufferBytes ?? DEFAULT_TOTAL_BUFFER_BYTES)) {
@@ -489,13 +635,19 @@ export class OutboundReverseGateway extends EventEmitter {
489
635
  stream.replay.push({sequence: stream.sendSequence, data, sent: false})
490
636
  stream.bufferedBytes += data.length
491
637
  this.#bufferedBytes += data.length
638
+ stream.spoolReservedBytes += data.length
639
+ this.#spoolBytes += data.length
640
+ await this.#queueSpoolOperation(stream, async () => {
641
+ await this.#spool.appendChunk({sessionId: stream.identifier, ownerId: "reverse-gateway-v3", sequence: stream.sendSequence, data})
642
+ await this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream))
643
+ })
492
644
  }
493
645
  await this.#pumpStream(stream)
494
646
  }
495
647
 
496
648
  /** @param {GatewayStream} stream - Stream. */
497
649
  async #pumpStream(stream) {
498
- if (stream.pumping || !this.#streams.has(stream.id) || this.#state !== "ready") return
650
+ if (stream.pumping || stream.state === "opening" || !this.#streams.has(stream.id) || this.#state !== "ready") return
499
651
  stream.pumping = true
500
652
  try {
501
653
  for (const chunk of stream.replay) {
@@ -513,18 +665,22 @@ export class OutboundReverseGateway extends EventEmitter {
513
665
  if (!stream.replay.some((chunk) => !chunk.sent)) {
514
666
  if (stream.targetResetCode !== undefined) {
515
667
  const code = stream.targetResetCode
668
+ if (!await this.#sendControl(V3FrameType.RESET, stream.id, 0, {code})) return
516
669
  stream.targetResetCode = undefined
517
- await this.#sendControl(V3FrameType.RESET, stream.id, 0, {code})
518
670
  this.#destroyStream(stream, code)
519
671
  return
520
672
  }
521
673
  if (stream.targetEnded && !stream.endSent) {
522
674
  stream.endSent = true
523
- await this.#sendControl(V3FrameType.END, stream.id, stream.sendSequence, {})
675
+ if (!await this.#sendControl(V3FrameType.END, stream.id, stream.sendSequence, {})) {
676
+ stream.endSent = false
677
+ return
678
+ }
524
679
  }
525
- if (stream.targetClosed && (!stream.targetEnded || stream.endSent)) {
526
- await this.#sendControl(V3FrameType.CLOSE, stream.id, stream.sendSequence, {})
527
- this.#destroyStream(stream)
680
+ if (stream.targetClosed && !stream.closeSent && (!stream.targetEnded || stream.endSent)) {
681
+ if (!await this.#sendControl(V3FrameType.CLOSE, stream.id, stream.sendSequence, {})) return
682
+ stream.closeSent = true
683
+ stream.state = "closing"
528
684
  }
529
685
  }
530
686
  } finally {
@@ -544,6 +700,13 @@ export class OutboundReverseGateway extends EventEmitter {
544
700
  stream.bufferedBytes = Math.max(0, stream.bufferedBytes - released)
545
701
  this.#bufferedBytes = Math.max(0, this.#bufferedBytes - released)
546
702
  stream.sendWindow = Math.min(stream.sendWindowLimit, stream.sendWindow + delta)
703
+ void this.#queueSpoolOperation(stream, async () => {
704
+ await this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream))
705
+ await this.#spool.truncateAcked(stream.identifier, sequence)
706
+ if (released > stream.spoolReservedBytes) throw new Error("Reverse gateway spool budget release underflow")
707
+ stream.spoolReservedBytes -= released
708
+ this.#spoolBytes = Math.max(0, this.#spoolBytes - released)
709
+ }).catch((error) => this.#resetStream(stream, spoolErrorCode(error)))
547
710
  void this.#pumpStream(stream).catch(() => this.#resetStream(stream, "TARGET_READ_FAILED"))
548
711
  }
549
712
 
@@ -571,6 +734,7 @@ export class OutboundReverseGateway extends EventEmitter {
571
734
  stream.replay = []
572
735
  stream.bufferedBytes = 0
573
736
  stream.targetSocket.destroy()
737
+ this.#trackSpoolCleanup(stream)
574
738
  }
575
739
 
576
740
  /** @param {GatewayStream} stream - Stream. @param {string} code - Terminal classification. */
@@ -609,6 +773,61 @@ export class OutboundReverseGateway extends EventEmitter {
609
773
  if (!this.#referenced) this.#reconnectTimer.unref?.()
610
774
  }
611
775
 
776
+ /** @param {GatewayStream} stream - Durable target-side stream state. */
777
+ #recoveryState(stream) {
778
+ return {
779
+ version: 3,
780
+ role: "reverse-gateway",
781
+ gatewayId: this.#options.gatewayId,
782
+ connectionId: this.#options.connectionId,
783
+ streamId: stream.id,
784
+ identifier: stream.identifier,
785
+ target: stream.target,
786
+ receiveSequence: stream.receiveSequence,
787
+ receiveAck: stream.receiveAck,
788
+ sendSequence: stream.sendSequence,
789
+ sendAck: stream.sendAck,
790
+ remoteEnded: stream.remoteEnded,
791
+ targetEnded: stream.targetEnded
792
+ }
793
+ }
794
+
795
+ /** @param {GatewayStream} stream - Spool owner. @param {() => Promise<void>} operation - Serialized durable operation. */
796
+ async #queueSpoolOperation(stream, operation) {
797
+ const next = stream.spoolQueue.then(async () => {
798
+ await stream.spoolReady
799
+ await operation()
800
+ })
801
+ stream.spoolQueue = next.catch(() => {})
802
+ await next
803
+ }
804
+
805
+ /** @param {GatewayStream} stream - Terminal stream. */
806
+ async #removeSpool(stream) {
807
+ if (stream.spoolRemovalPromise === undefined) {
808
+ stream.spoolRemovalPromise = this.#queueSpoolOperation(stream, async () => {
809
+ await this.#spool.remove(stream.identifier)
810
+ const removed = stream.spoolReservedBytes
811
+ stream.spoolReservedBytes = 0
812
+ this.#spoolBytes = Math.max(0, this.#spoolBytes - removed)
813
+ })
814
+ }
815
+ await stream.spoolRemovalPromise
816
+ }
817
+
818
+ /** @param {GatewayStream} stream - Terminal stream. */
819
+ #trackSpoolCleanup(stream) {
820
+ const task = this.#removeSpool(stream).catch((error) => {
821
+ if (!stream.spoolCleanupFailureRecorded) {
822
+ stream.spoolCleanupFailureRecorded = true
823
+ this.#spoolCleanupFailures += 1
824
+ }
825
+ throw error
826
+ })
827
+ this.#spoolCleanupTasks.add(task)
828
+ void task.catch(() => {}).finally(() => this.#spoolCleanupTasks.delete(task))
829
+ }
830
+
612
831
  #settleStart() { this.#resolveStart = undefined; this.#rejectStart = undefined }
613
832
  #setState(/** @type {'configured' | 'connecting' | 'registering' | 'ready' | 'reconnecting' | 'closed'} */ state) { if (this.#state === state) return; this.#state = state; this.emit("stateChange", state) }
614
833
  #initialWindow() { return this.#options.initialWindowBytes ?? DEFAULT_WINDOW }
@@ -630,6 +849,8 @@ function validateOptions(options) {
630
849
  positiveInteger(options.maxStreams, "maxStreams", DEFAULT_MAX_STREAMS)
631
850
  positiveInteger(options.maxStreamBufferBytes, "maxStreamBufferBytes", DEFAULT_STREAM_BUFFER_BYTES)
632
851
  positiveInteger(options.maxTotalBufferBytes, "maxTotalBufferBytes", DEFAULT_TOTAL_BUFFER_BYTES)
852
+ if (options.spoolDirectory !== undefined && (typeof options.spoolDirectory !== "string" || options.spoolDirectory.length === 0)) throw new TypeError("spoolDirectory must be a non-empty string")
853
+ positiveInteger(options.maxAgeMs, "maxAgeMs", 24 * 60 * 60 * 1000)
633
854
  if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
634
855
  }
635
856
 
@@ -645,7 +866,7 @@ function fatalError(/** @type {string} */ code) {
645
866
  }
646
867
 
647
868
  function connectionError(/** @type {string} */ code) {
648
- if (code === "HUB_SHUTDOWN") return Object.assign(new Error("Reverse gateway hub is temporarily unavailable"), {code, fatal: false})
869
+ if (code === "HUB_SHUTDOWN" || code === "CLIENT_DISCONNECTED" || code === "GATEWAY_DISCONNECTED") return Object.assign(new Error("Reverse gateway hub is temporarily unavailable"), {code, fatal: false})
649
870
  return fatalError(code)
650
871
  }
651
872
 
@@ -654,6 +875,12 @@ function validPeerWindow(value) { return Number.isSafeInteger(value) && /** @typ
654
875
 
655
876
  function closedError() { return Object.assign(new Error("Outbound reverse gateway is closed"), {code: "SOCKETDUCT_GATEWAY_CLOSED"}) }
656
877
 
878
+ /** @param {unknown} error - Durable spool failure. */
879
+ function spoolErrorCode(error) {
880
+ const code = /** @type {{code?: unknown}} */ (error).code
881
+ return typeof code === "string" ? code : "SPOOL_IO_FAILED"
882
+ }
883
+
657
884
  function isFatalTransportError(/** @type {Error} */ error) {
658
885
  const code = /** @type {{code?: unknown}} */ (error).code
659
886
  return typeof code === "string" && (code.startsWith("ERR_TLS_") || code.startsWith("ERR_SSL_") || code.includes("CERT") || code.includes("VERIFY") || code.includes("SELF_SIGNED") || code.includes("ISSUER"))