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