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