socketduct 0.1.18 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/changelog.d/20260925143000-reverse-client-process-recovery.md +1 -0
- package/docs/architecture.md +11 -9
- package/docs/protocol.md +16 -3
- package/docs/reverse-gateway.md +41 -9
- package/docs/running.md +2 -2
- package/docs/security.md +9 -2
- package/docs/session-lifecycle.md +14 -3
- package/docs/socket-api.md +54 -20
- package/package.json +1 -1
- package/src/client/multiplexed-transport-set.js +12 -5
- package/src/client/virtual-socket.js +42 -0
- package/src/reverse/client.js +301 -71
- package/src/reverse/gateway.js +10 -10
- 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 +421 -1
- 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
|
@@ -70,6 +70,8 @@ const MANIFEST_PAGE_STREAMS = 64
|
|
|
70
70
|
* @property {{host: string, port: number}} target
|
|
71
71
|
* @property {VirtualSocket} socket
|
|
72
72
|
* @property {'opening' | 'open' | 'half-closed' | 'closed' | 'reset'} state
|
|
73
|
+
* @property {boolean} opened
|
|
74
|
+
* @property {boolean} openingSent
|
|
73
75
|
* @property {number} sendSequence
|
|
74
76
|
* @property {number} transmittedSequence
|
|
75
77
|
* @property {number} sendAck
|
|
@@ -77,8 +79,9 @@ const MANIFEST_PAGE_STREAMS = 64
|
|
|
77
79
|
* @property {number} sendWindowLimit
|
|
78
80
|
* @property {number} receiveSequence
|
|
79
81
|
* @property {number} receiveAck
|
|
80
|
-
* @property {number
|
|
82
|
+
* @property {Array<{sequence: number, remaining: number}>} receiveFrames
|
|
81
83
|
* @property {boolean} remoteEnded
|
|
84
|
+
* @property {boolean} remoteEndPending
|
|
82
85
|
* @property {number} bufferedBytes
|
|
83
86
|
* @property {ClientChunk[]} replay
|
|
84
87
|
* @property {AdaptiveCompressor} compressor
|
|
@@ -93,6 +96,29 @@ const MANIFEST_PAGE_STREAMS = 64
|
|
|
93
96
|
* @property {number} spoolReservedBytes
|
|
94
97
|
* @property {Promise<void> | undefined} spoolRemovalPromise
|
|
95
98
|
* @property {boolean} spoolCleanupFailureRecorded
|
|
99
|
+
* @property {boolean} ownsSpool
|
|
100
|
+
* @property {boolean} recoveredObject
|
|
101
|
+
* @typedef {object} ReverseClientRecoveryState
|
|
102
|
+
* @property {number} version
|
|
103
|
+
* @property {'reverse-client'} role
|
|
104
|
+
* @property {string} connectionId
|
|
105
|
+
* @property {number} generation
|
|
106
|
+
* @property {string} [gatewayId]
|
|
107
|
+
* @property {string} [routeClass]
|
|
108
|
+
* @property {number} streamId
|
|
109
|
+
* @property {string} identifier
|
|
110
|
+
* @property {string} name
|
|
111
|
+
* @property {{host: string, port: number}} target
|
|
112
|
+
* @property {'opening' | 'open' | 'half-closed'} state
|
|
113
|
+
* @property {boolean} opened
|
|
114
|
+
* @property {true} openingSent
|
|
115
|
+
* @property {number} sendSequence
|
|
116
|
+
* @property {number} sendAck
|
|
117
|
+
* @property {number} receiveSequence
|
|
118
|
+
* @property {number} receiveAck
|
|
119
|
+
* @property {boolean} localEnded
|
|
120
|
+
* @property {boolean} remoteEnded
|
|
121
|
+
* @property {boolean} endSent
|
|
96
122
|
*/
|
|
97
123
|
|
|
98
124
|
/** Server3/TensorBuzz-facing virtual socket connection to an exact gateway or server-resolved route class. */
|
|
@@ -106,6 +132,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
106
132
|
/** @type {number} */ #generation
|
|
107
133
|
/** @type {string | undefined} */ #codec
|
|
108
134
|
/** @type {boolean} */ #closing = false
|
|
135
|
+
/** @type {boolean} */ #preserveSpools = false
|
|
109
136
|
/** @type {boolean} */ #fatal = false
|
|
110
137
|
/** @type {Error | undefined} */ #fatalError
|
|
111
138
|
/** @type {boolean} */ #referenced = true
|
|
@@ -127,6 +154,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
127
154
|
/** @type {number} */ #wireDataBytes = 0
|
|
128
155
|
/** @type {number} */ #lastProgressedStreamId = 0
|
|
129
156
|
/** @type {Promise<void>} */ #openQueue = Promise.resolve()
|
|
157
|
+
/** @type {Promise<void>} */ #physicalFrameQueue = Promise.resolve()
|
|
130
158
|
/** @type {SessionSpool} */ #spool
|
|
131
159
|
/** @type {number} */ #spoolBytes = 0
|
|
132
160
|
/** @type {number} */ #spoolCleanupFailures = 0
|
|
@@ -163,15 +191,45 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
163
191
|
|
|
164
192
|
/** Allocates one stream on the already-warm gateway transport. */
|
|
165
193
|
createConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback = undefined) {
|
|
194
|
+
if (options?.recover === true) throw new TypeError("Durable recovery must use registerRecoveredConnection()")
|
|
195
|
+
return this.#createConnection(options, callback, false)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Registers one durable stream without starting a physical connection. */
|
|
199
|
+
registerRecoveredConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback = undefined) {
|
|
200
|
+
if (options?.recover !== true) throw new TypeError("registerRecoveredConnection requires recover: true")
|
|
201
|
+
if (this.#options.connectionId === undefined) throw new TypeError("registerRecoveredConnection requires an explicit stable connectionId")
|
|
202
|
+
if (this.#options.spoolDirectory === undefined) throw new TypeError("registerRecoveredConnection requires an explicit persistent spoolDirectory")
|
|
203
|
+
if (this.#physicalAttempts > 0) throw new Error("Recovered streams must be registered before any physical connection attempt")
|
|
204
|
+
return this.#createConnection(options, callback, true)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Creates a fresh or synchronously recovered reverse stream. */
|
|
208
|
+
#createConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback, /** @type {boolean} */ recovering) {
|
|
166
209
|
if (this.#closing) throw closedError()
|
|
167
|
-
if (this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
|
|
210
|
+
if (!recovering && this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
|
|
168
211
|
if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum reverse virtual stream count exceeded")
|
|
169
212
|
const target = parseTarget(options?.target ?? {host: options?.host, port: options?.port})
|
|
170
213
|
if (target === undefined) throw new TypeError("Reverse virtual connection target host and port are required")
|
|
171
|
-
const identifier = options.identifier ?? randomBytes(24).toString("base64url")
|
|
214
|
+
const identifier = recovering ? options.identifier : (options.identifier ?? randomBytes(24).toString("base64url"))
|
|
172
215
|
if (typeof identifier !== "string" || !STABLE_IDENTIFIER.test(identifier)) throw new TypeError("Reverse virtual stream identifier must be 8-128 URL-safe characters")
|
|
173
|
-
const
|
|
174
|
-
|
|
216
|
+
const recovery = recovering
|
|
217
|
+
? validateRecoveryState(this.#spool.readRecoveryStateSync(identifier, "reverse-client-v3"), {
|
|
218
|
+
connectionId: this.#connectionId,
|
|
219
|
+
identifier,
|
|
220
|
+
target,
|
|
221
|
+
selection: this.#routeIdentityFields(),
|
|
222
|
+
initialWindow: this.#initialWindow(),
|
|
223
|
+
...(options.sessionName === undefined ? {} : {sessionName: options.sessionName})
|
|
224
|
+
})
|
|
225
|
+
: undefined
|
|
226
|
+
const chunks = recovery === undefined ? [] : this.#spool.loadChunksSync(identifier, "reverse-client-v3", recovery.sendAck, recovery.sendSequence)
|
|
227
|
+
if (recovery !== undefined) validateRecoveredChunks(recovery, chunks, this.#options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES, this.#maxFrameBytes())
|
|
228
|
+
const bufferedBytes = chunks.reduce((sum, chunk) => sum + chunk.data.length, 0)
|
|
229
|
+
if (this.#bufferedBytes + bufferedBytes > (this.#options.maxConnectionBufferBytes ?? DEFAULT_CONNECTION_BUFFER_BYTES)) throw new Error("Recovered reverse data exceeds the connection buffer limit")
|
|
230
|
+
const id = recovery?.streamId ?? this.#allocateStreamId()
|
|
231
|
+
if (this.#streams.has(id)) throw new Error(`Reverse v3 stream ID ${id} is already active`)
|
|
232
|
+
const name = options.sessionName ?? recovery?.name ?? `socketduct-reverse-${id}`
|
|
175
233
|
if (typeof name !== "string" || name.length === 0 || name.length > 256) throw new TypeError("Reverse virtual stream name must be 1-256 characters")
|
|
176
234
|
const socket = new VirtualSocket(/** @type {import('../client/multiplexed-connection.js').MultiplexedSocketductConnection} */ (/** @type {unknown} */ (this)), id, {
|
|
177
235
|
...target,
|
|
@@ -182,55 +240,78 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
182
240
|
/** @type {ReverseClientStream} */
|
|
183
241
|
const stream = {
|
|
184
242
|
id, identifier, name, target, socket,
|
|
185
|
-
state: "opening",
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
243
|
+
state: recovery?.state ?? "opening",
|
|
244
|
+
opened: recovery?.opened ?? false,
|
|
245
|
+
openingSent: recovery?.openingSent ?? false,
|
|
246
|
+
sendSequence: recovery?.sendSequence ?? 0,
|
|
247
|
+
transmittedSequence: recovery?.sendAck ?? 0,
|
|
248
|
+
sendAck: recovery?.sendAck ?? 0,
|
|
189
249
|
sendWindow: this.#sendWindowLimit,
|
|
190
250
|
sendWindowLimit: this.#sendWindowLimit,
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
251
|
+
// Bytes buffered only in the dead VirtualSocket were not durably
|
|
252
|
+
// delivered. Resume inbound delivery from the persisted ACK boundary.
|
|
253
|
+
receiveSequence: recovery?.receiveAck ?? 0,
|
|
254
|
+
receiveAck: recovery?.receiveAck ?? 0,
|
|
255
|
+
receiveFrames: [],
|
|
256
|
+
remoteEnded: recovery?.remoteEnded ?? false,
|
|
257
|
+
remoteEndPending: recovery?.remoteEnded ?? false,
|
|
258
|
+
bufferedBytes,
|
|
259
|
+
replay: chunks.map((chunk) => ({...chunk, sent: false})),
|
|
197
260
|
compressor: new AdaptiveCompressor(),
|
|
198
261
|
pumping: false,
|
|
199
262
|
pendingWrite: undefined,
|
|
200
263
|
progressingWrite: false,
|
|
201
|
-
localEnded: false,
|
|
202
|
-
endSent: false,
|
|
264
|
+
localEnded: recovery?.localEnded ?? false,
|
|
265
|
+
endSent: recovery?.endSent ?? false,
|
|
203
266
|
endCallback: undefined,
|
|
204
267
|
spoolReady: Promise.resolve(),
|
|
205
268
|
spoolQueue: Promise.resolve(),
|
|
206
|
-
spoolReservedBytes:
|
|
269
|
+
spoolReservedBytes: bufferedBytes,
|
|
207
270
|
spoolRemovalPromise: undefined,
|
|
208
|
-
spoolCleanupFailureRecorded: false
|
|
271
|
+
spoolCleanupFailureRecorded: false,
|
|
272
|
+
ownsSpool: recovery !== undefined,
|
|
273
|
+
recoveredObject: recovery !== undefined
|
|
274
|
+
}
|
|
275
|
+
if (recovery !== undefined) {
|
|
276
|
+
this.#options.aggregateBudget?.reserveData(bufferedBytes)
|
|
277
|
+
this.#bufferedBytes += bufferedBytes
|
|
278
|
+
this.#spoolBytes += bufferedBytes
|
|
279
|
+
this.#nextStreamId = Math.max(this.#nextStreamId, id + 2)
|
|
280
|
+
this.#generation = Math.max(this.#generation, recovery.generation)
|
|
209
281
|
}
|
|
210
282
|
this.#streams.set(id, stream)
|
|
211
|
-
stream.spoolReady =
|
|
212
|
-
|
|
283
|
+
stream.spoolReady = recovery === undefined
|
|
284
|
+
? this.#spool.createNew({sessionId: identifier, ownerId: "reverse-client-v3"})
|
|
285
|
+
.then(() => {
|
|
286
|
+
stream.ownsSpool = true
|
|
287
|
+
return this.#spool.writeRecoveryState(identifier, this.#recoveryState(stream))
|
|
288
|
+
})
|
|
289
|
+
: this.#spool.truncateAcked(identifier, recovery.sendAck)
|
|
213
290
|
stream.spoolQueue = stream.spoolReady.catch(() => {})
|
|
214
291
|
logDiagnostic(this.#options.logger, "virtual_stream.opening", {
|
|
215
292
|
streamId: id,
|
|
216
293
|
generation: this.#generation,
|
|
217
294
|
...this.#routeIdentityFields(),
|
|
218
|
-
recovered:
|
|
295
|
+
recovered: recovery !== undefined
|
|
219
296
|
})
|
|
220
297
|
if (callback !== undefined) installCallback(socket, callback)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
298
|
+
if (recovery === undefined) {
|
|
299
|
+
const opening = this.#openQueue
|
|
300
|
+
.then(() => stream.spoolReady)
|
|
301
|
+
.then(async () => {
|
|
302
|
+
if (this.#streams.get(id) !== stream) return
|
|
303
|
+
stream.openingSent = true
|
|
304
|
+
await this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(identifier, this.#recoveryState(stream)))
|
|
305
|
+
if (this.#streams.get(id) === stream) await this.#sendControl(V3FrameType.OPEN, id, 0, {...this.#routeIdentityFields(), identifier, name, target, initialWindow: this.#initialWindow()})
|
|
306
|
+
})
|
|
307
|
+
this.#openQueue = opening.then(() => undefined, () => undefined)
|
|
308
|
+
void opening.catch((error) => socket._receiveError(asError(error)))
|
|
309
|
+
} else {
|
|
310
|
+
void stream.spoolReady.catch((error) => socket._receiveError(asError(error)))
|
|
311
|
+
}
|
|
226
312
|
return socket
|
|
227
313
|
}
|
|
228
314
|
|
|
229
|
-
/** Process replacement cannot inherit the live reverse target/app socket pair. */
|
|
230
|
-
registerRecoveredConnection() {
|
|
231
|
-
throw Object.assign(new Error("Reverse gateway process recovery is unsupported; live streams resume only within the original process"), {code: "REVERSE_STREAM_RECOVERY_UNSUPPORTED"})
|
|
232
|
-
}
|
|
233
|
-
|
|
234
315
|
status() {
|
|
235
316
|
/** @type {Record<string, number>} */
|
|
236
317
|
const streamStates = {}
|
|
@@ -280,23 +361,46 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
280
361
|
if (failures.length > 0) throw new AggregateError(failures, "Reverse client spool cleanup failed")
|
|
281
362
|
}
|
|
282
363
|
|
|
283
|
-
/**
|
|
284
|
-
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
|
+
}
|
|
285
388
|
|
|
286
389
|
_writeStream(/** @type {number} */ streamId, /** @type {Buffer} */ data, /** @type {(error?: Error | null) => void} */ callback) {
|
|
287
390
|
const stream = this.#streams.get(streamId)
|
|
288
|
-
if (stream === undefined || stream.localEnded || stream.state === "closed" || stream.state === "reset") { callback(new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED")); return }
|
|
391
|
+
if (this.#closing || stream === undefined || stream.localEnded || stream.state === "closed" || stream.state === "reset") { callback(new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED")); return }
|
|
289
392
|
stream.pendingWrite = {data, offset: 0, callback}
|
|
290
393
|
this.#progressPendingWrites()
|
|
291
394
|
}
|
|
292
395
|
|
|
293
396
|
_endStream(/** @type {number} */ streamId, /** @type {(error?: Error | null) => void} */ callback) {
|
|
294
397
|
const stream = this.#streams.get(streamId)
|
|
295
|
-
if (stream === undefined || stream.localEnded) { callback(); return }
|
|
398
|
+
if (this.#closing || stream === undefined || stream.localEnded) { callback(); return }
|
|
296
399
|
stream.localEnded = true
|
|
297
400
|
if (stream.state === "closed") { callback(); return }
|
|
401
|
+
if (stream.opened && stream.state === "open") stream.state = "half-closed"
|
|
298
402
|
stream.endCallback = callback
|
|
299
|
-
void this.#pumpStream(stream).catch((error) => this.#finishEnd(stream, asError(error)))
|
|
403
|
+
void this.#persistStreamState(stream).then(() => this.#pumpStream(stream)).catch((error) => this.#finishEnd(stream, asError(error)))
|
|
300
404
|
}
|
|
301
405
|
|
|
302
406
|
_destroyStream(/** @type {number} */ streamId, /** @type {Error | null} */ error) {
|
|
@@ -314,11 +418,26 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
314
418
|
}
|
|
315
419
|
|
|
316
420
|
_resumeReadable(/** @type {number} */ streamId) {
|
|
421
|
+
void streamId
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
_consumeReadable(/** @type {number} */ streamId, /** @type {number} */ bytes) {
|
|
317
425
|
const stream = this.#streams.get(streamId)
|
|
318
|
-
if (stream
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
426
|
+
if (stream === undefined || !Number.isSafeInteger(bytes) || bytes <= 0) return
|
|
427
|
+
let remaining = bytes
|
|
428
|
+
let acknowledgedSequence = stream.receiveAck
|
|
429
|
+
while (remaining > 0 && stream.receiveFrames.length > 0) {
|
|
430
|
+
const frame = /** @type {{sequence: number, remaining: number}} */ (stream.receiveFrames[0])
|
|
431
|
+
const consumed = Math.min(remaining, frame.remaining)
|
|
432
|
+
frame.remaining -= consumed
|
|
433
|
+
remaining -= consumed
|
|
434
|
+
if (frame.remaining === 0) {
|
|
435
|
+
acknowledgedSequence = frame.sequence
|
|
436
|
+
stream.receiveFrames.shift()
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (remaining !== 0) throw new Error("Reverse client readable accounting underflow")
|
|
440
|
+
this.#ackReceived(stream, acknowledgedSequence)
|
|
322
441
|
}
|
|
323
442
|
|
|
324
443
|
#allocateStreamId() {
|
|
@@ -361,6 +480,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
361
480
|
physical.setKeepAlive(true, DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS)
|
|
362
481
|
const decoder = new V3FrameDecoder({maxFrameBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4), maxBufferedBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4) * 2})
|
|
363
482
|
let queue = Promise.resolve()
|
|
483
|
+
this.#physicalFrameQueue = queue
|
|
364
484
|
let authenticated = false
|
|
365
485
|
let selected = false
|
|
366
486
|
/** @type {Error | undefined} */
|
|
@@ -368,14 +488,21 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
368
488
|
const timer = setTimeout(() => physical.destroy(Object.assign(new Error("Reverse client TLS/auth timeout"), {code: "SOCKETDUCT_AUTH_TIMEOUT"})), this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
|
|
369
489
|
timer.unref?.()
|
|
370
490
|
physical.once("secureConnect", () => {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
491
|
+
// The attempted generation must survive a crash after AUTH reaches the
|
|
492
|
+
// hub but before AUTH_OK returns, otherwise a replacement could reuse a
|
|
493
|
+
// fenced generation after wall-clock rollback.
|
|
494
|
+
void Promise.all([...this.#streams.values()].map((stream) => this.#persistStreamState(stream))).then(async () => {
|
|
495
|
+
if (this.#physical !== physical || physical.destroyed || this.#closing) return
|
|
496
|
+
const body = encodeV3Control({role: "client", token: this.#options.hub.token, versions: [3], connectionId: this.#connectionId, generation, compression: availableCodecs()})
|
|
497
|
+
await writeSocket(physical, encodeV3Frame({type: V3FrameType.AUTH, streamId: 0, sequence: generation}, body))
|
|
498
|
+
}).catch((error) => physical.destroy(asError(error)))
|
|
374
499
|
})
|
|
375
500
|
physical.on("data", (chunk) => {
|
|
501
|
+
if (this.#closing || this.#physical !== physical) return
|
|
376
502
|
physical.pause()
|
|
377
503
|
queue = queue.then(async () => {
|
|
378
504
|
for (const frame of decoder.push(chunk)) {
|
|
505
|
+
if (this.#closing || this.#physical !== physical) return
|
|
379
506
|
if (frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.GOAWAY) {
|
|
380
507
|
const detail = decodeV3Control(frame.payload)
|
|
381
508
|
throw connectionError(typeof detail.code === "string" ? detail.code : "CONNECTION_REJECTED")
|
|
@@ -387,6 +514,10 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
387
514
|
authenticated = true
|
|
388
515
|
this.#codec = ZSTD_CODEC
|
|
389
516
|
this.#sendWindowLimit = Math.min(this.#initialWindow(), detail.initialWindow)
|
|
517
|
+
for (const stream of this.#streams.values()) {
|
|
518
|
+
stream.sendWindowLimit = Math.min(stream.sendWindowLimit, this.#sendWindowLimit)
|
|
519
|
+
stream.sendWindow = Math.min(stream.sendWindow, stream.sendWindowLimit)
|
|
520
|
+
}
|
|
390
521
|
await writeSocket(physical, encodeV3Frame({type: V3FrameType.GATEWAY_SELECT, streamId: 0, sequence: generation}, encodeV3Control(this.#routeIdentityFields())))
|
|
391
522
|
continue
|
|
392
523
|
}
|
|
@@ -397,28 +528,26 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
397
528
|
if (detail.generation !== generation || ("gatewayId" in selection ? detail.gatewayId !== selection.gatewayId || Object.hasOwn(detail, "routeClass") : detail.routeClass !== selection.routeClass || Object.hasOwn(detail, "gatewayId"))) throw fatalError("GATEWAY_SELECTION_FAILED")
|
|
398
529
|
if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_SELECTION_FAILED")
|
|
399
530
|
selected = true
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
this.#resuming = true
|
|
408
|
-
await this.#sendResumeManifest(generation)
|
|
409
|
-
}
|
|
531
|
+
if (detail.resumed) {
|
|
532
|
+
clearTimeout(timer)
|
|
533
|
+
this.#resuming = true
|
|
534
|
+
await this.#sendResumeManifest(generation)
|
|
535
|
+
} else if ([...this.#streams.values()].some((stream) => stream.openingSent || stream.opened)) {
|
|
536
|
+
for (const stream of [...this.#streams.values()]) stream.socket._receiveError(new SocketductSessionLostError("Reverse gateway hub lost the retained stream", "RESUME_SESSION_LOST"))
|
|
537
|
+
this.#completePhysical(generation, timer)
|
|
410
538
|
} else {
|
|
411
539
|
this.#completePhysical(generation, timer)
|
|
412
540
|
}
|
|
413
541
|
continue
|
|
414
542
|
}
|
|
415
|
-
await this.#handleFrame(frame)
|
|
543
|
+
await this.#handleFrame(frame, physical)
|
|
416
544
|
}
|
|
417
545
|
}).catch((error) => {
|
|
418
546
|
attemptError = asError(error)
|
|
419
547
|
if (/** @type {{fatal?: unknown}} */ (attemptError).fatal === true) this.#fatal = true
|
|
420
548
|
physical.destroy(attemptError)
|
|
421
549
|
}).finally(() => { if (!physical.destroyed) physical.resume() })
|
|
550
|
+
this.#physicalFrameQueue = queue
|
|
422
551
|
})
|
|
423
552
|
physical.once("error", (error) => { attemptError = error; if (isFatalTransportError(error)) this.#fatal = true })
|
|
424
553
|
physical.once("close", () => {
|
|
@@ -447,6 +576,8 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
447
576
|
if (this.#closing) return
|
|
448
577
|
if (this.#fatal) {
|
|
449
578
|
const terminalError = attemptError ?? lost
|
|
579
|
+
const terminalCode = /** @type {{code?: unknown}} */ (terminalError).code
|
|
580
|
+
if (terminalCode === "STALE_GENERATION" || terminalCode === "CONNECTION_REPLACED") this.#preserveSpools = true
|
|
450
581
|
this.#fatalError = terminalError
|
|
451
582
|
this.#setState("closed", terminalError)
|
|
452
583
|
logDiagnostic(this.#options.logger, "transport.failed", {
|
|
@@ -465,8 +596,9 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
465
596
|
})
|
|
466
597
|
}
|
|
467
598
|
|
|
468
|
-
/** @param {import('../protocol/v3-frame.js').V3Frame} frame - Hub frame. */
|
|
469
|
-
async #handleFrame(frame) {
|
|
599
|
+
/** @param {import('../protocol/v3-frame.js').V3Frame} frame - Hub frame. @param {import('node:tls').TLSSocket} physical - Owning physical generation. */
|
|
600
|
+
async #handleFrame(frame, physical) {
|
|
601
|
+
if (this.#closing || this.#physical !== physical) return
|
|
470
602
|
if (frame.type === V3FrameType.PING) { await this.#sendControl(V3FrameType.PONG, 0, frame.sequence, {}); return }
|
|
471
603
|
if (frame.type === V3FrameType.PONG) return
|
|
472
604
|
if (frame.type === V3FrameType.RESUMED) {
|
|
@@ -476,7 +608,8 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
476
608
|
this.#resumeNextPage += 1
|
|
477
609
|
this.#resumeResults.push(...manifest.streams.filter((item) => typeof item === "object" && item !== null))
|
|
478
610
|
if (manifest.final !== true) return
|
|
479
|
-
for (const item of this.#resumeResults) this.#applyResumeResult(item)
|
|
611
|
+
for (const item of this.#resumeResults) await this.#applyResumeResult(item)
|
|
612
|
+
if (this.#closing || this.#physical !== physical) return
|
|
480
613
|
this.#resumeResults = []
|
|
481
614
|
this.#resumeNextPage = 0
|
|
482
615
|
this.#resuming = false
|
|
@@ -493,13 +626,16 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
493
626
|
if (stream.state !== "opening" || frame.sequence !== 0 || detail.identifier !== stream.identifier || !validPeerWindow(detail.initialWindow)) throw new Error("Invalid reverse OPENED response")
|
|
494
627
|
stream.sendWindowLimit = Math.min(stream.sendWindowLimit, detail.initialWindow)
|
|
495
628
|
stream.sendWindow = Math.min(stream.sendWindow, stream.sendWindowLimit)
|
|
496
|
-
stream.state = "open"
|
|
629
|
+
stream.state = stream.localEnded ? "half-closed" : "open"
|
|
630
|
+
stream.opened = true
|
|
497
631
|
logDiagnostic(this.#options.logger, "virtual_stream.opened", {
|
|
498
632
|
streamId: stream.id,
|
|
499
633
|
generation: this.#generation,
|
|
500
634
|
...this.#routeIdentityFields(),
|
|
501
635
|
recovered: false
|
|
502
636
|
})
|
|
637
|
+
await this.#persistStreamState(stream)
|
|
638
|
+
if (this.#closing || this.#physical !== physical) return
|
|
503
639
|
stream.socket._markConnected()
|
|
504
640
|
this.#progressPendingWrites()
|
|
505
641
|
await this.#pumpStream(stream)
|
|
@@ -508,11 +644,11 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
508
644
|
if (frame.type === V3FrameType.DATA) {
|
|
509
645
|
if (stream.remoteEnded) throw new Error("Reverse client received DATA after END")
|
|
510
646
|
const data = await decodeV3Data(frame, this.#codec, this.#options.compressionLimits)
|
|
647
|
+
if (this.#closing || this.#physical !== physical) return
|
|
511
648
|
if (data.length === 0 || data.length > this.#maxFrameBytes() || frame.sequence !== stream.receiveSequence + data.length || frame.sequence - stream.receiveAck > this.#initialWindow()) throw new Error("Invalid reverse client DATA sequence or window")
|
|
512
649
|
stream.receiveSequence = frame.sequence
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
else stream.pendingReceiveAck = frame.sequence
|
|
650
|
+
stream.receiveFrames.push({sequence: frame.sequence, remaining: data.length})
|
|
651
|
+
stream.socket._receive(data)
|
|
516
652
|
return
|
|
517
653
|
}
|
|
518
654
|
if (frame.type === V3FrameType.ACK) { this.#acknowledge(stream, frame.sequence); return }
|
|
@@ -520,12 +656,15 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
520
656
|
if (frame.type === V3FrameType.END) {
|
|
521
657
|
if (stream.remoteEnded || frame.sequence !== stream.receiveSequence) throw new Error("Invalid reverse client END")
|
|
522
658
|
stream.remoteEnded = true
|
|
659
|
+
await this.#persistStreamState(stream)
|
|
660
|
+
if (this.#closing || this.#physical !== physical) return
|
|
523
661
|
stream.socket._receiveEnd()
|
|
524
662
|
return
|
|
525
663
|
}
|
|
526
664
|
if (frame.type === V3FrameType.CLOSE) {
|
|
527
665
|
if (frame.sequence !== stream.receiveSequence) throw new Error("Invalid reverse client CLOSE")
|
|
528
666
|
await this.#sendControl(V3FrameType.CLOSE, stream.id, stream.transmittedSequence, {})
|
|
667
|
+
if (this.#closing || this.#physical !== physical) return
|
|
529
668
|
stream.state = "closed"
|
|
530
669
|
logDiagnostic(this.#options.logger, "virtual_stream.closed", {
|
|
531
670
|
streamId: stream.id,
|
|
@@ -558,7 +697,9 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
558
697
|
identifier: stream.identifier,
|
|
559
698
|
clientToGatewaySequence: stream.sendSequence,
|
|
560
699
|
clientToGatewayAck: stream.sendAck,
|
|
561
|
-
|
|
700
|
+
// A replacement cannot inherit the dead VirtualSocket's process-only
|
|
701
|
+
// readable buffer. Ask the surviving gateway to replay above durable ACK.
|
|
702
|
+
gatewayToClientSequence: stream.recoveredObject ? stream.receiveAck : stream.receiveSequence,
|
|
562
703
|
gatewayToClientAck: stream.receiveAck,
|
|
563
704
|
localEnded: stream.localEnded,
|
|
564
705
|
remoteEnded: stream.remoteEnded
|
|
@@ -576,7 +717,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
576
717
|
}
|
|
577
718
|
|
|
578
719
|
/** @param {unknown} value - Hub resume result. */
|
|
579
|
-
#applyResumeResult(value) {
|
|
720
|
+
async #applyResumeResult(value) {
|
|
580
721
|
if (typeof value !== "object" || value === null) throw new Error("Invalid reverse client resume result")
|
|
581
722
|
const result = /** @type {{streamId?: unknown, status?: unknown, clientToGatewaySequence?: unknown, clientToGatewayAck?: unknown, gatewayToClientSequence?: unknown, gatewayToClientAck?: unknown, clientEnded?: unknown, gatewayEnded?: unknown, opened?: unknown, code?: unknown}} */ (value)
|
|
582
723
|
if (!Number.isInteger(result.streamId)) throw new Error("Invalid reverse client resume stream ID")
|
|
@@ -596,15 +737,20 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
596
737
|
this.#acknowledge(stream, clientToGatewayAck)
|
|
597
738
|
stream.transmittedSequence = clientToGatewayAck
|
|
598
739
|
stream.receiveAck = gatewayToClientAck
|
|
599
|
-
stream.pendingReceiveAck = stream.pendingReceiveAck !== undefined && stream.pendingReceiveAck > stream.receiveAck ? stream.pendingReceiveAck : undefined
|
|
600
740
|
stream.sendWindow = Math.max(0, stream.sendWindowLimit - (clientToGatewaySequence - clientToGatewayAck))
|
|
601
741
|
for (const chunk of stream.replay) chunk.sent = chunk.sequence <= clientToGatewaySequence
|
|
602
742
|
stream.endSent = /** @type {boolean} */ (result.clientEnded)
|
|
603
|
-
if (result.gatewayEnded === true
|
|
743
|
+
if (result.gatewayEnded === true) {
|
|
744
|
+
stream.remoteEndPending = stream.recoveredObject || !stream.remoteEnded
|
|
604
745
|
stream.remoteEnded = true
|
|
605
|
-
stream.socket._receiveEnd()
|
|
606
746
|
}
|
|
607
|
-
if (result.opened === true
|
|
747
|
+
if (result.opened === true) {
|
|
748
|
+
stream.opened = true
|
|
749
|
+
if (stream.state === "opening") stream.state = stream.localEnded ? "half-closed" : "open"
|
|
750
|
+
}
|
|
751
|
+
stream.openingSent = true
|
|
752
|
+
stream.recoveredObject = false
|
|
753
|
+
await this.#persistStreamState(stream)
|
|
608
754
|
}
|
|
609
755
|
|
|
610
756
|
/** Marks one authenticated/selected physical generation ready. */
|
|
@@ -632,6 +778,10 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
632
778
|
this.#settleConnect()
|
|
633
779
|
for (const stream of this.#streams.values()) {
|
|
634
780
|
if (stream.state !== "opening") stream.socket._markConnected()
|
|
781
|
+
if (stream.remoteEndPending) {
|
|
782
|
+
stream.remoteEndPending = false
|
|
783
|
+
stream.socket._receiveEnd()
|
|
784
|
+
}
|
|
635
785
|
void this.#pumpStream(stream).catch((error) => stream.socket._receiveError(asError(error)))
|
|
636
786
|
}
|
|
637
787
|
this.#progressPendingWrites()
|
|
@@ -662,6 +812,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
662
812
|
stream.endSent = false
|
|
663
813
|
return
|
|
664
814
|
}
|
|
815
|
+
await this.#persistStreamState(stream)
|
|
665
816
|
this.#finishEnd(stream)
|
|
666
817
|
}
|
|
667
818
|
} finally { stream.pumping = false }
|
|
@@ -756,7 +907,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
756
907
|
stream.replay = []
|
|
757
908
|
stream.bufferedBytes = 0
|
|
758
909
|
this.#finishWrite(stream, pendingWriteError)
|
|
759
|
-
this.#trackSpoolCleanup(stream)
|
|
910
|
+
if (!this.#preserveSpools) this.#trackSpoolCleanup(stream)
|
|
760
911
|
this.#progressPendingWrites()
|
|
761
912
|
}
|
|
762
913
|
|
|
@@ -841,19 +992,30 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
841
992
|
version: 3,
|
|
842
993
|
role: "reverse-client",
|
|
843
994
|
connectionId: this.#connectionId,
|
|
995
|
+
generation: this.#generation,
|
|
996
|
+
...this.#routeIdentityFields(),
|
|
844
997
|
streamId: stream.id,
|
|
845
998
|
identifier: stream.identifier,
|
|
846
999
|
name: stream.name,
|
|
847
1000
|
target: stream.target,
|
|
1001
|
+
state: stream.state,
|
|
1002
|
+
opened: stream.opened,
|
|
1003
|
+
openingSent: stream.openingSent,
|
|
848
1004
|
sendSequence: stream.sendSequence,
|
|
849
1005
|
sendAck: stream.sendAck,
|
|
850
1006
|
receiveSequence: stream.receiveSequence,
|
|
851
1007
|
receiveAck: stream.receiveAck,
|
|
852
1008
|
localEnded: stream.localEnded,
|
|
853
|
-
remoteEnded: stream.remoteEnded
|
|
1009
|
+
remoteEnded: stream.remoteEnded,
|
|
1010
|
+
endSent: stream.endSent
|
|
854
1011
|
}
|
|
855
1012
|
}
|
|
856
1013
|
|
|
1014
|
+
/** @param {ReverseClientStream} stream - Durable stream state. */
|
|
1015
|
+
async #persistStreamState(stream) {
|
|
1016
|
+
await this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))
|
|
1017
|
+
}
|
|
1018
|
+
|
|
857
1019
|
/** @param {ReverseClientStream} stream - Spool owner. @param {() => Promise<void>} operation - Serialized durable operation. */
|
|
858
1020
|
async #queueSpoolOperation(stream, operation) {
|
|
859
1021
|
const next = stream.spoolQueue.then(async () => {
|
|
@@ -880,7 +1042,10 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
880
1042
|
|
|
881
1043
|
/** @param {ReverseClientStream} stream - Terminal stream. */
|
|
882
1044
|
#trackSpoolCleanup(stream) {
|
|
883
|
-
const
|
|
1045
|
+
const removal = stream.ownsSpool
|
|
1046
|
+
? this.#removeSpool(stream)
|
|
1047
|
+
: stream.spoolReady.then(() => stream.ownsSpool ? this.#removeSpool(stream) : undefined)
|
|
1048
|
+
const task = removal.catch((error) => {
|
|
884
1049
|
if (!stream.spoolCleanupFailureRecorded) {
|
|
885
1050
|
stream.spoolCleanupFailureRecorded = true
|
|
886
1051
|
this.#spoolCleanupFailures += 1
|
|
@@ -927,6 +1092,71 @@ function validateOptions(options) {
|
|
|
927
1092
|
if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
|
|
928
1093
|
}
|
|
929
1094
|
|
|
1095
|
+
/**
|
|
1096
|
+
* @param {Record<string, unknown>} value - Parsed durable state.
|
|
1097
|
+
* @param {{connectionId: string, identifier: string, target: {host: string, port: number}, selection: {gatewayId: string} | {routeClass: string}, initialWindow: number, sessionName?: string}} expected - Caller-owned recovery identity.
|
|
1098
|
+
* @returns {ReverseClientRecoveryState} Validated state.
|
|
1099
|
+
*/
|
|
1100
|
+
function validateRecoveryState(value, expected) {
|
|
1101
|
+
const state = /** @type {Partial<ReverseClientRecoveryState>} */ (value)
|
|
1102
|
+
const sequences = [state.generation, state.streamId, state.sendSequence, state.sendAck, state.receiveSequence, state.receiveAck]
|
|
1103
|
+
const validSequences = sequences.every((sequence) => Number.isSafeInteger(sequence) && /** @type {number} */ (sequence) >= 0)
|
|
1104
|
+
const validTarget = state.target?.host === expected.target.host && state.target?.port === expected.target.port
|
|
1105
|
+
const expectedState = state.opened === true ? (state.localEnded === true ? "half-closed" : "open") : "opening"
|
|
1106
|
+
const selectorMatches = "gatewayId" in expected.selection
|
|
1107
|
+
? state.gatewayId === expected.selection.gatewayId && !Object.hasOwn(state, "routeClass")
|
|
1108
|
+
: state.routeClass === expected.selection.routeClass && !Object.hasOwn(state, "gatewayId")
|
|
1109
|
+
if (
|
|
1110
|
+
state.version !== 3
|
|
1111
|
+
|| state.role !== "reverse-client"
|
|
1112
|
+
|| state.connectionId !== expected.connectionId
|
|
1113
|
+
|| state.identifier !== expected.identifier
|
|
1114
|
+
|| !validSequences
|
|
1115
|
+
|| state.generation === 0
|
|
1116
|
+
|| state.generation === Number.MAX_SAFE_INTEGER
|
|
1117
|
+
|| state.streamId === 0
|
|
1118
|
+
|| /** @type {number} */ (state.streamId) % 2 === 0
|
|
1119
|
+
|| /** @type {number} */ (state.streamId) > 0xffff_fffd
|
|
1120
|
+
|| typeof state.name !== "string"
|
|
1121
|
+
|| state.name.length === 0
|
|
1122
|
+
|| state.name.length > 256
|
|
1123
|
+
|| !validTarget
|
|
1124
|
+
|| !selectorMatches
|
|
1125
|
+
|| typeof state.opened !== "boolean"
|
|
1126
|
+
|| state.openingSent !== true
|
|
1127
|
+
|| typeof state.localEnded !== "boolean"
|
|
1128
|
+
|| typeof state.remoteEnded !== "boolean"
|
|
1129
|
+
|| typeof state.endSent !== "boolean"
|
|
1130
|
+
|| (state.endSent && !state.localEnded)
|
|
1131
|
+
|| (state.endSent && state.opened !== true)
|
|
1132
|
+
|| (state.remoteEnded && state.opened !== true)
|
|
1133
|
+
|| (state.opened !== true && (/** @type {number} */ (state.sendSequence) > 0 || /** @type {number} */ (state.receiveSequence) > 0))
|
|
1134
|
+
|| state.state !== expectedState
|
|
1135
|
+
|| /** @type {number} */ (state.sendAck) > /** @type {number} */ (state.sendSequence)
|
|
1136
|
+
|| /** @type {number} */ (state.receiveAck) > /** @type {number} */ (state.receiveSequence)
|
|
1137
|
+
|| /** @type {number} */ (state.receiveSequence) - /** @type {number} */ (state.receiveAck) > expected.initialWindow
|
|
1138
|
+
|| (expected.sessionName !== undefined && state.name !== expected.sessionName)
|
|
1139
|
+
) throw new Error("Invalid reverse client recovery identity, selector, lifecycle, or sequence state")
|
|
1140
|
+
return /** @type {ReverseClientRecoveryState} */ (state)
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* @param {ReverseClientRecoveryState} state - Validated durable state.
|
|
1145
|
+
* @param {import('../spool/spool-file.js').SpoolChunk[]} chunks - Durable chunks.
|
|
1146
|
+
* @param {number} maxBytes - Per-stream byte bound.
|
|
1147
|
+
* @param {number} maxFrameBytes - Per-chunk frame bound.
|
|
1148
|
+
*/
|
|
1149
|
+
function validateRecoveredChunks(state, chunks, maxBytes, maxFrameBytes) {
|
|
1150
|
+
let sequence = state.sendAck
|
|
1151
|
+
let bytes = 0
|
|
1152
|
+
for (const chunk of chunks) {
|
|
1153
|
+
bytes += chunk.data.length
|
|
1154
|
+
if (chunk.data.length === 0 || chunk.data.length > maxFrameBytes || !Number.isSafeInteger(sequence + chunk.data.length) || chunk.sequence !== sequence + chunk.data.length || chunk.sequence > state.sendSequence) throw new Error("Invalid reverse client recovery chunk sequence")
|
|
1155
|
+
sequence = chunk.sequence
|
|
1156
|
+
}
|
|
1157
|
+
if (sequence !== state.sendSequence || bytes > maxBytes) throw new Error("Invalid or oversized reverse client recovery replay state")
|
|
1158
|
+
}
|
|
1159
|
+
|
|
930
1160
|
/** @param {VirtualSocket} socket - Socket. @param {(error: Error | null, socket: VirtualSocket) => void} callback - Callback. */
|
|
931
1161
|
function installCallback(socket, callback) {
|
|
932
1162
|
let settled = false
|