socketduct 0.1.18 → 0.1.20
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/.env.example +17 -5
- package/README.md +5 -5
- package/changelog.d/20260925143000-reverse-client-process-recovery.md +1 -0
- package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
- package/docs/architecture.md +11 -9
- package/docs/docker.md +5 -4
- package/docs/logging.md +131 -159
- package/docs/protocol.md +43 -7
- package/docs/reverse-gateway.md +75 -9
- package/docs/running.md +16 -13
- package/docs/security.md +16 -5
- package/docs/session-lifecycle.md +14 -3
- package/docs/socket-api.md +54 -20
- package/package.json +5 -1
- package/scripts/verify-zstd-interoperability.mjs +41 -0
- package/src/cli/socketduct.js +143 -0
- package/src/client/multiplexed-transport-set.js +12 -5
- package/src/client/virtual-socket.js +52 -0
- package/src/config/environment.js +56 -7
- package/src/index.js +64 -26
- package/src/logging/compressed-log-reader.js +256 -0
- package/src/logging/compressed-log-worker.js +430 -0
- package/src/logging/compressed-log-writer.js +538 -0
- package/src/logging/log-batch.js +72 -0
- package/src/logging/log-retention.js +241 -0
- package/src/logging/log-segment-index.js +176 -0
- package/src/logging/structured-logger.js +136 -30
- package/src/logging/zstd-frame-codec.js +73 -0
- package/src/reverse/client.js +372 -82
- package/src/reverse/disconnect-attribution.js +191 -0
- package/src/reverse/gateway.js +68 -18
- package/src/reverse/hub.js +215 -32
- package/src/reverse/runtime.js +5 -3
- package/src/spool/spool-file.js +136 -7
- package/tensorbuzz.yml +4 -2
- package/test/e2e/logger-shutdown-process.test.js +86 -0
- package/test/e2e/reverse-client-recovery-process.test.js +170 -0
- package/test/e2e/reverse-gateway-process.test.js +15 -14
- package/test/fixtures/reverse-client-recovery.mjs +123 -0
- package/test/integration/logging-cli.test.js +168 -0
- package/test/integration/logging-compressed-recovery.test.js +186 -0
- package/test/integration/reverse-gateway.test.js +597 -1
- package/test/integration/reverse-route-class.test.js +6 -1
- package/test/unit/client/multiplexed-transport-set.test.js +12 -1
- package/test/unit/config/docker-gateway-files.test.js +2 -0
- package/test/unit/config/environment.test.js +54 -14
- package/test/unit/index.test.js +3 -0
- package/test/unit/logging/compressed-log-worker.test.js +29 -0
- package/test/unit/logging/compressed-log-writer.test.js +183 -0
- package/test/unit/logging/log-retention.test.js +86 -0
- package/test/unit/logging/log-segment-index.test.js +104 -0
- package/test/unit/logging/structured-logger.test.js +14 -1
- package/test/unit/logging/zstd-frame-codec.test.js +24 -0
- package/test/unit/reverse/disconnect-attribution.test.js +82 -0
- package/test/unit/spool/spool-file.test.js +133 -0
package/src/reverse/client.js
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
STABLE_IDENTIFIER,
|
|
28
28
|
writeSocket
|
|
29
29
|
} from "./common.js"
|
|
30
|
+
import {attachDisconnectAttribution, createDisconnectAttribution, parseDisconnectAttribution, safeSocketCode} from "./disconnect-attribution.js"
|
|
30
31
|
|
|
31
32
|
const DEFAULT_STREAM_BUFFER_BYTES = 4 * 1024 * 1024
|
|
32
33
|
const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
|
|
@@ -70,6 +71,8 @@ const MANIFEST_PAGE_STREAMS = 64
|
|
|
70
71
|
* @property {{host: string, port: number}} target
|
|
71
72
|
* @property {VirtualSocket} socket
|
|
72
73
|
* @property {'opening' | 'open' | 'half-closed' | 'closed' | 'reset'} state
|
|
74
|
+
* @property {boolean} opened
|
|
75
|
+
* @property {boolean} openingSent
|
|
73
76
|
* @property {number} sendSequence
|
|
74
77
|
* @property {number} transmittedSequence
|
|
75
78
|
* @property {number} sendAck
|
|
@@ -77,8 +80,9 @@ const MANIFEST_PAGE_STREAMS = 64
|
|
|
77
80
|
* @property {number} sendWindowLimit
|
|
78
81
|
* @property {number} receiveSequence
|
|
79
82
|
* @property {number} receiveAck
|
|
80
|
-
* @property {number
|
|
83
|
+
* @property {Array<{sequence: number, remaining: number}>} receiveFrames
|
|
81
84
|
* @property {boolean} remoteEnded
|
|
85
|
+
* @property {boolean} remoteEndPending
|
|
82
86
|
* @property {number} bufferedBytes
|
|
83
87
|
* @property {ClientChunk[]} replay
|
|
84
88
|
* @property {AdaptiveCompressor} compressor
|
|
@@ -93,6 +97,29 @@ const MANIFEST_PAGE_STREAMS = 64
|
|
|
93
97
|
* @property {number} spoolReservedBytes
|
|
94
98
|
* @property {Promise<void> | undefined} spoolRemovalPromise
|
|
95
99
|
* @property {boolean} spoolCleanupFailureRecorded
|
|
100
|
+
* @property {boolean} ownsSpool
|
|
101
|
+
* @property {boolean} recoveredObject
|
|
102
|
+
* @typedef {object} ReverseClientRecoveryState
|
|
103
|
+
* @property {number} version
|
|
104
|
+
* @property {'reverse-client'} role
|
|
105
|
+
* @property {string} connectionId
|
|
106
|
+
* @property {number} generation
|
|
107
|
+
* @property {string} [gatewayId]
|
|
108
|
+
* @property {string} [routeClass]
|
|
109
|
+
* @property {number} streamId
|
|
110
|
+
* @property {string} identifier
|
|
111
|
+
* @property {string} name
|
|
112
|
+
* @property {{host: string, port: number}} target
|
|
113
|
+
* @property {'opening' | 'open' | 'half-closed'} state
|
|
114
|
+
* @property {boolean} opened
|
|
115
|
+
* @property {true} openingSent
|
|
116
|
+
* @property {number} sendSequence
|
|
117
|
+
* @property {number} sendAck
|
|
118
|
+
* @property {number} receiveSequence
|
|
119
|
+
* @property {number} receiveAck
|
|
120
|
+
* @property {boolean} localEnded
|
|
121
|
+
* @property {boolean} remoteEnded
|
|
122
|
+
* @property {boolean} endSent
|
|
96
123
|
*/
|
|
97
124
|
|
|
98
125
|
/** Server3/TensorBuzz-facing virtual socket connection to an exact gateway or server-resolved route class. */
|
|
@@ -106,6 +133,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
106
133
|
/** @type {number} */ #generation
|
|
107
134
|
/** @type {string | undefined} */ #codec
|
|
108
135
|
/** @type {boolean} */ #closing = false
|
|
136
|
+
/** @type {boolean} */ #preserveSpools = false
|
|
109
137
|
/** @type {boolean} */ #fatal = false
|
|
110
138
|
/** @type {Error | undefined} */ #fatalError
|
|
111
139
|
/** @type {boolean} */ #referenced = true
|
|
@@ -127,10 +155,12 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
127
155
|
/** @type {number} */ #wireDataBytes = 0
|
|
128
156
|
/** @type {number} */ #lastProgressedStreamId = 0
|
|
129
157
|
/** @type {Promise<void>} */ #openQueue = Promise.resolve()
|
|
158
|
+
/** @type {Promise<void>} */ #physicalFrameQueue = Promise.resolve()
|
|
130
159
|
/** @type {SessionSpool} */ #spool
|
|
131
160
|
/** @type {number} */ #spoolBytes = 0
|
|
132
161
|
/** @type {number} */ #spoolCleanupFailures = 0
|
|
133
162
|
/** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
|
|
163
|
+
/** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */ #disconnectAttribution
|
|
134
164
|
|
|
135
165
|
/** @param {ReverseGatewayConnectionOptions} options - Hub route and safety policy. */
|
|
136
166
|
constructor(options) {
|
|
@@ -163,15 +193,45 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
163
193
|
|
|
164
194
|
/** Allocates one stream on the already-warm gateway transport. */
|
|
165
195
|
createConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback = undefined) {
|
|
196
|
+
if (options?.recover === true) throw new TypeError("Durable recovery must use registerRecoveredConnection()")
|
|
197
|
+
return this.#createConnection(options, callback, false)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Registers one durable stream without starting a physical connection. */
|
|
201
|
+
registerRecoveredConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback = undefined) {
|
|
202
|
+
if (options?.recover !== true) throw new TypeError("registerRecoveredConnection requires recover: true")
|
|
203
|
+
if (this.#options.connectionId === undefined) throw new TypeError("registerRecoveredConnection requires an explicit stable connectionId")
|
|
204
|
+
if (this.#options.spoolDirectory === undefined) throw new TypeError("registerRecoveredConnection requires an explicit persistent spoolDirectory")
|
|
205
|
+
if (this.#physicalAttempts > 0) throw new Error("Recovered streams must be registered before any physical connection attempt")
|
|
206
|
+
return this.#createConnection(options, callback, true)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Creates a fresh or synchronously recovered reverse stream. */
|
|
210
|
+
#createConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback, /** @type {boolean} */ recovering) {
|
|
166
211
|
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"})
|
|
212
|
+
if (!recovering && this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
|
|
168
213
|
if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum reverse virtual stream count exceeded")
|
|
169
214
|
const target = parseTarget(options?.target ?? {host: options?.host, port: options?.port})
|
|
170
215
|
if (target === undefined) throw new TypeError("Reverse virtual connection target host and port are required")
|
|
171
|
-
const identifier = options.identifier ?? randomBytes(24).toString("base64url")
|
|
216
|
+
const identifier = recovering ? options.identifier : (options.identifier ?? randomBytes(24).toString("base64url"))
|
|
172
217
|
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
|
-
|
|
218
|
+
const recovery = recovering
|
|
219
|
+
? validateRecoveryState(this.#spool.readRecoveryStateSync(identifier, "reverse-client-v3"), {
|
|
220
|
+
connectionId: this.#connectionId,
|
|
221
|
+
identifier,
|
|
222
|
+
target,
|
|
223
|
+
selection: this.#routeIdentityFields(),
|
|
224
|
+
initialWindow: this.#initialWindow(),
|
|
225
|
+
...(options.sessionName === undefined ? {} : {sessionName: options.sessionName})
|
|
226
|
+
})
|
|
227
|
+
: undefined
|
|
228
|
+
const chunks = recovery === undefined ? [] : this.#spool.loadChunksSync(identifier, "reverse-client-v3", recovery.sendAck, recovery.sendSequence)
|
|
229
|
+
if (recovery !== undefined) validateRecoveredChunks(recovery, chunks, this.#options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES, this.#maxFrameBytes())
|
|
230
|
+
const bufferedBytes = chunks.reduce((sum, chunk) => sum + chunk.data.length, 0)
|
|
231
|
+
if (this.#bufferedBytes + bufferedBytes > (this.#options.maxConnectionBufferBytes ?? DEFAULT_CONNECTION_BUFFER_BYTES)) throw new Error("Recovered reverse data exceeds the connection buffer limit")
|
|
232
|
+
const id = recovery?.streamId ?? this.#allocateStreamId()
|
|
233
|
+
if (this.#streams.has(id)) throw new Error(`Reverse v3 stream ID ${id} is already active`)
|
|
234
|
+
const name = options.sessionName ?? recovery?.name ?? `socketduct-reverse-${id}`
|
|
175
235
|
if (typeof name !== "string" || name.length === 0 || name.length > 256) throw new TypeError("Reverse virtual stream name must be 1-256 characters")
|
|
176
236
|
const socket = new VirtualSocket(/** @type {import('../client/multiplexed-connection.js').MultiplexedSocketductConnection} */ (/** @type {unknown} */ (this)), id, {
|
|
177
237
|
...target,
|
|
@@ -182,55 +242,78 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
182
242
|
/** @type {ReverseClientStream} */
|
|
183
243
|
const stream = {
|
|
184
244
|
id, identifier, name, target, socket,
|
|
185
|
-
state: "opening",
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
245
|
+
state: recovery?.state ?? "opening",
|
|
246
|
+
opened: recovery?.opened ?? false,
|
|
247
|
+
openingSent: recovery?.openingSent ?? false,
|
|
248
|
+
sendSequence: recovery?.sendSequence ?? 0,
|
|
249
|
+
transmittedSequence: recovery?.sendAck ?? 0,
|
|
250
|
+
sendAck: recovery?.sendAck ?? 0,
|
|
189
251
|
sendWindow: this.#sendWindowLimit,
|
|
190
252
|
sendWindowLimit: this.#sendWindowLimit,
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
253
|
+
// Bytes buffered only in the dead VirtualSocket were not durably
|
|
254
|
+
// delivered. Resume inbound delivery from the persisted ACK boundary.
|
|
255
|
+
receiveSequence: recovery?.receiveAck ?? 0,
|
|
256
|
+
receiveAck: recovery?.receiveAck ?? 0,
|
|
257
|
+
receiveFrames: [],
|
|
258
|
+
remoteEnded: recovery?.remoteEnded ?? false,
|
|
259
|
+
remoteEndPending: recovery?.remoteEnded ?? false,
|
|
260
|
+
bufferedBytes,
|
|
261
|
+
replay: chunks.map((chunk) => ({...chunk, sent: false})),
|
|
197
262
|
compressor: new AdaptiveCompressor(),
|
|
198
263
|
pumping: false,
|
|
199
264
|
pendingWrite: undefined,
|
|
200
265
|
progressingWrite: false,
|
|
201
|
-
localEnded: false,
|
|
202
|
-
endSent: false,
|
|
266
|
+
localEnded: recovery?.localEnded ?? false,
|
|
267
|
+
endSent: recovery?.endSent ?? false,
|
|
203
268
|
endCallback: undefined,
|
|
204
269
|
spoolReady: Promise.resolve(),
|
|
205
270
|
spoolQueue: Promise.resolve(),
|
|
206
|
-
spoolReservedBytes:
|
|
271
|
+
spoolReservedBytes: bufferedBytes,
|
|
207
272
|
spoolRemovalPromise: undefined,
|
|
208
|
-
spoolCleanupFailureRecorded: false
|
|
273
|
+
spoolCleanupFailureRecorded: false,
|
|
274
|
+
ownsSpool: recovery !== undefined,
|
|
275
|
+
recoveredObject: recovery !== undefined
|
|
276
|
+
}
|
|
277
|
+
if (recovery !== undefined) {
|
|
278
|
+
this.#options.aggregateBudget?.reserveData(bufferedBytes)
|
|
279
|
+
this.#bufferedBytes += bufferedBytes
|
|
280
|
+
this.#spoolBytes += bufferedBytes
|
|
281
|
+
this.#nextStreamId = Math.max(this.#nextStreamId, id + 2)
|
|
282
|
+
this.#generation = Math.max(this.#generation, recovery.generation)
|
|
209
283
|
}
|
|
210
284
|
this.#streams.set(id, stream)
|
|
211
|
-
stream.spoolReady =
|
|
212
|
-
|
|
285
|
+
stream.spoolReady = recovery === undefined
|
|
286
|
+
? this.#spool.createNew({sessionId: identifier, ownerId: "reverse-client-v3"})
|
|
287
|
+
.then(() => {
|
|
288
|
+
stream.ownsSpool = true
|
|
289
|
+
return this.#spool.writeRecoveryState(identifier, this.#recoveryState(stream))
|
|
290
|
+
})
|
|
291
|
+
: this.#spool.truncateAcked(identifier, recovery.sendAck)
|
|
213
292
|
stream.spoolQueue = stream.spoolReady.catch(() => {})
|
|
214
293
|
logDiagnostic(this.#options.logger, "virtual_stream.opening", {
|
|
215
294
|
streamId: id,
|
|
216
295
|
generation: this.#generation,
|
|
217
296
|
...this.#routeIdentityFields(),
|
|
218
|
-
recovered:
|
|
297
|
+
recovered: recovery !== undefined
|
|
219
298
|
})
|
|
220
299
|
if (callback !== undefined) installCallback(socket, callback)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
300
|
+
if (recovery === undefined) {
|
|
301
|
+
const opening = this.#openQueue
|
|
302
|
+
.then(() => stream.spoolReady)
|
|
303
|
+
.then(async () => {
|
|
304
|
+
if (this.#streams.get(id) !== stream) return
|
|
305
|
+
stream.openingSent = true
|
|
306
|
+
await this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(identifier, this.#recoveryState(stream)))
|
|
307
|
+
if (this.#streams.get(id) === stream) await this.#sendControl(V3FrameType.OPEN, id, 0, {...this.#routeIdentityFields(), identifier, name, target, initialWindow: this.#initialWindow()})
|
|
308
|
+
})
|
|
309
|
+
this.#openQueue = opening.then(() => undefined, () => undefined)
|
|
310
|
+
void opening.catch((error) => socket._receiveError(asError(error)))
|
|
311
|
+
} else {
|
|
312
|
+
void stream.spoolReady.catch((error) => socket._receiveError(asError(error)))
|
|
313
|
+
}
|
|
226
314
|
return socket
|
|
227
315
|
}
|
|
228
316
|
|
|
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
317
|
status() {
|
|
235
318
|
/** @type {Record<string, number>} */
|
|
236
319
|
const streamStates = {}
|
|
@@ -280,23 +363,46 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
280
363
|
if (failures.length > 0) throw new AggregateError(failures, "Reverse client spool cleanup failed")
|
|
281
364
|
}
|
|
282
365
|
|
|
283
|
-
/**
|
|
284
|
-
detach() {
|
|
366
|
+
/** Preserves durable streams while retiring this object for replacement. */
|
|
367
|
+
async detach() {
|
|
368
|
+
if (this.#closing) return
|
|
369
|
+
if (this.#options.connectionId === undefined) throw new Error("detach() requires an explicit stable connectionId")
|
|
370
|
+
if (this.#options.spoolDirectory === undefined) throw new Error("detach() requires an explicit persistent spoolDirectory")
|
|
371
|
+
this.#preserveSpools = true
|
|
372
|
+
this.#closing = true
|
|
373
|
+
if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
|
|
374
|
+
this.#reconnectTimer = undefined
|
|
375
|
+
this.#setState("closed")
|
|
376
|
+
const physical = this.#physical
|
|
377
|
+
physical?.pause()
|
|
378
|
+
physical?.destroy()
|
|
379
|
+
const frameQueue = this.#physicalFrameQueue
|
|
380
|
+
try {
|
|
381
|
+
await frameQueue
|
|
382
|
+
await Promise.all([...this.#streams.values()].map((stream) => this.#persistStreamState(stream)))
|
|
383
|
+
} finally {
|
|
384
|
+
physical?.destroy()
|
|
385
|
+
this.#physical = undefined
|
|
386
|
+
this.#rejectConnect?.(new SocketductSessionLostError("Reverse connection detached for durable recovery", "CONNECTION_DETACHED"))
|
|
387
|
+
this.#settleConnect()
|
|
388
|
+
}
|
|
389
|
+
}
|
|
285
390
|
|
|
286
391
|
_writeStream(/** @type {number} */ streamId, /** @type {Buffer} */ data, /** @type {(error?: Error | null) => void} */ callback) {
|
|
287
392
|
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 }
|
|
393
|
+
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
394
|
stream.pendingWrite = {data, offset: 0, callback}
|
|
290
395
|
this.#progressPendingWrites()
|
|
291
396
|
}
|
|
292
397
|
|
|
293
398
|
_endStream(/** @type {number} */ streamId, /** @type {(error?: Error | null) => void} */ callback) {
|
|
294
399
|
const stream = this.#streams.get(streamId)
|
|
295
|
-
if (stream === undefined || stream.localEnded) { callback(); return }
|
|
400
|
+
if (this.#closing || stream === undefined || stream.localEnded) { callback(); return }
|
|
296
401
|
stream.localEnded = true
|
|
297
402
|
if (stream.state === "closed") { callback(); return }
|
|
403
|
+
if (stream.opened && stream.state === "open") stream.state = "half-closed"
|
|
298
404
|
stream.endCallback = callback
|
|
299
|
-
void this.#pumpStream(stream).catch((error) => this.#finishEnd(stream, asError(error)))
|
|
405
|
+
void this.#persistStreamState(stream).then(() => this.#pumpStream(stream)).catch((error) => this.#finishEnd(stream, asError(error)))
|
|
300
406
|
}
|
|
301
407
|
|
|
302
408
|
_destroyStream(/** @type {number} */ streamId, /** @type {Error | null} */ error) {
|
|
@@ -307,18 +413,34 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
307
413
|
streamId,
|
|
308
414
|
generation: this.#generation,
|
|
309
415
|
...this.#routeIdentityFields(),
|
|
310
|
-
...(error === null ? {} : errorDiagnostics(error))
|
|
416
|
+
...(error === null ? {} : errorDiagnostics(error)),
|
|
417
|
+
...(error === null ? {} : publicDisconnectAttribution(error))
|
|
311
418
|
})
|
|
312
419
|
this.#retireStream(stream, error ?? new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED"))
|
|
313
420
|
if (!wasClosed && !this.#closing && this.#state === "ready") void this.#sendControl(V3FrameType.RESET, streamId, 0, {code: error instanceof SocketductStreamError ? error.code : "CLIENT_CANCELLED"}).catch(() => {})
|
|
314
421
|
}
|
|
315
422
|
|
|
316
423
|
_resumeReadable(/** @type {number} */ streamId) {
|
|
424
|
+
void streamId
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
_consumeReadable(/** @type {number} */ streamId, /** @type {number} */ bytes) {
|
|
317
428
|
const stream = this.#streams.get(streamId)
|
|
318
|
-
if (stream
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
429
|
+
if (stream === undefined || !Number.isSafeInteger(bytes) || bytes <= 0) return
|
|
430
|
+
let remaining = bytes
|
|
431
|
+
let acknowledgedSequence = stream.receiveAck
|
|
432
|
+
while (remaining > 0 && stream.receiveFrames.length > 0) {
|
|
433
|
+
const frame = /** @type {{sequence: number, remaining: number}} */ (stream.receiveFrames[0])
|
|
434
|
+
const consumed = Math.min(remaining, frame.remaining)
|
|
435
|
+
frame.remaining -= consumed
|
|
436
|
+
remaining -= consumed
|
|
437
|
+
if (frame.remaining === 0) {
|
|
438
|
+
acknowledgedSequence = frame.sequence
|
|
439
|
+
stream.receiveFrames.shift()
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (remaining !== 0) throw new Error("Reverse client readable accounting underflow")
|
|
443
|
+
this.#ackReceived(stream, acknowledgedSequence)
|
|
322
444
|
}
|
|
323
445
|
|
|
324
446
|
#allocateStreamId() {
|
|
@@ -361,32 +483,56 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
361
483
|
physical.setKeepAlive(true, DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS)
|
|
362
484
|
const decoder = new V3FrameDecoder({maxFrameBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4), maxBufferedBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4) * 2})
|
|
363
485
|
let queue = Promise.resolve()
|
|
486
|
+
this.#physicalFrameQueue = queue
|
|
364
487
|
let authenticated = false
|
|
365
488
|
let selected = false
|
|
489
|
+
/** @type {import('./disconnect-attribution.js').DisconnectReason | undefined} */
|
|
490
|
+
let attemptDisconnectReason
|
|
491
|
+
/** @type {string | undefined} */
|
|
492
|
+
let attemptSafeSocketCode
|
|
493
|
+
/** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */
|
|
494
|
+
let attemptAttribution
|
|
366
495
|
/** @type {Error | undefined} */
|
|
367
496
|
let attemptError
|
|
368
|
-
const timer = setTimeout(() =>
|
|
497
|
+
const timer = setTimeout(() => {
|
|
498
|
+
attemptDisconnectReason ??= "heartbeat_timeout"
|
|
499
|
+
physical.destroy(Object.assign(new Error("Reverse client TLS/auth timeout"), {code: "SOCKETDUCT_AUTH_TIMEOUT"}))
|
|
500
|
+
}, this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
|
|
369
501
|
timer.unref?.()
|
|
370
502
|
physical.once("secureConnect", () => {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
503
|
+
// The attempted generation must survive a crash after AUTH reaches the
|
|
504
|
+
// hub but before AUTH_OK returns, otherwise a replacement could reuse a
|
|
505
|
+
// fenced generation after wall-clock rollback.
|
|
506
|
+
void Promise.all([...this.#streams.values()].map((stream) => this.#persistStreamState(stream))).then(async () => {
|
|
507
|
+
if (this.#physical !== physical || physical.destroyed || this.#closing) return
|
|
508
|
+
const body = encodeV3Control({role: "client", token: this.#options.hub.token, versions: [3], connectionId: this.#connectionId, generation, compression: availableCodecs()})
|
|
509
|
+
await writeSocket(physical, encodeV3Frame({type: V3FrameType.AUTH, streamId: 0, sequence: generation}, body))
|
|
510
|
+
}).catch((error) => physical.destroy(asError(error)))
|
|
374
511
|
})
|
|
375
512
|
physical.on("data", (chunk) => {
|
|
513
|
+
if (this.#closing || this.#physical !== physical) return
|
|
376
514
|
physical.pause()
|
|
377
515
|
queue = queue.then(async () => {
|
|
378
516
|
for (const frame of decoder.push(chunk)) {
|
|
517
|
+
if (this.#closing || this.#physical !== physical) return
|
|
379
518
|
if (frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.GOAWAY) {
|
|
380
519
|
const detail = decodeV3Control(frame.payload)
|
|
381
|
-
|
|
520
|
+
if (frame.type === V3FrameType.GOAWAY) attemptDisconnectReason ??= "peer_goaway"
|
|
521
|
+
attemptAttribution = validHubAttribution(detail) ?? attemptAttribution
|
|
522
|
+
throw connectionError(typeof detail.code === "string" ? detail.code : "CONNECTION_REJECTED", attemptAttribution)
|
|
382
523
|
}
|
|
383
524
|
if (!authenticated) {
|
|
384
525
|
if (frame.type !== V3FrameType.AUTH_OK) throw fatalError("AUTH_NEGOTIATION_FAILED")
|
|
385
526
|
const detail = decodeV3Control(frame.payload)
|
|
386
527
|
if (detail.version !== 3 || detail.role !== "client" || detail.connectionId !== this.#connectionId || detail.generation !== generation || detail.compression !== ZSTD_CODEC || !validPeerWindow(detail.initialWindow)) throw fatalError("AUTH_NEGOTIATION_FAILED")
|
|
387
528
|
authenticated = true
|
|
529
|
+
this.#disconnectAttribution = undefined
|
|
388
530
|
this.#codec = ZSTD_CODEC
|
|
389
531
|
this.#sendWindowLimit = Math.min(this.#initialWindow(), detail.initialWindow)
|
|
532
|
+
for (const stream of this.#streams.values()) {
|
|
533
|
+
stream.sendWindowLimit = Math.min(stream.sendWindowLimit, this.#sendWindowLimit)
|
|
534
|
+
stream.sendWindow = Math.min(stream.sendWindow, stream.sendWindowLimit)
|
|
535
|
+
}
|
|
390
536
|
await writeSocket(physical, encodeV3Frame({type: V3FrameType.GATEWAY_SELECT, streamId: 0, sequence: generation}, encodeV3Control(this.#routeIdentityFields())))
|
|
391
537
|
continue
|
|
392
538
|
}
|
|
@@ -397,30 +543,35 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
397
543
|
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
544
|
if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_SELECTION_FAILED")
|
|
399
545
|
selected = true
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
this.#resuming = true
|
|
408
|
-
await this.#sendResumeManifest(generation)
|
|
409
|
-
}
|
|
546
|
+
if (detail.resumed) {
|
|
547
|
+
clearTimeout(timer)
|
|
548
|
+
this.#resuming = true
|
|
549
|
+
await this.#sendResumeManifest(generation)
|
|
550
|
+
} else if ([...this.#streams.values()].some((stream) => stream.openingSent || stream.opened)) {
|
|
551
|
+
for (const stream of [...this.#streams.values()]) stream.socket._receiveError(new SocketductSessionLostError("Reverse gateway hub lost the retained stream", "RESUME_SESSION_LOST"))
|
|
552
|
+
this.#completePhysical(generation, timer)
|
|
410
553
|
} else {
|
|
411
554
|
this.#completePhysical(generation, timer)
|
|
412
555
|
}
|
|
413
556
|
continue
|
|
414
557
|
}
|
|
415
|
-
await this.#handleFrame(frame)
|
|
558
|
+
await this.#handleFrame(frame, physical)
|
|
416
559
|
}
|
|
417
560
|
}).catch((error) => {
|
|
418
561
|
attemptError = asError(error)
|
|
562
|
+
attemptDisconnectReason ??= "protocol_violation"
|
|
419
563
|
if (/** @type {{fatal?: unknown}} */ (attemptError).fatal === true) this.#fatal = true
|
|
420
564
|
physical.destroy(attemptError)
|
|
421
565
|
}).finally(() => { if (!physical.destroyed) physical.resume() })
|
|
566
|
+
this.#physicalFrameQueue = queue
|
|
567
|
+
})
|
|
568
|
+
physical.once("end", () => { attemptDisconnectReason ??= "remote_eof" })
|
|
569
|
+
physical.once("error", (error) => {
|
|
570
|
+
attemptError = error
|
|
571
|
+
attemptDisconnectReason ??= "remote_socket_error"
|
|
572
|
+
attemptSafeSocketCode ??= safeSocketCode(error)
|
|
573
|
+
if (isFatalTransportError(error)) this.#fatal = true
|
|
422
574
|
})
|
|
423
|
-
physical.once("error", (error) => { attemptError = error; if (isFatalTransportError(error)) this.#fatal = true })
|
|
424
575
|
physical.once("close", () => {
|
|
425
576
|
clearTimeout(timer)
|
|
426
577
|
void queue.finally(() => {
|
|
@@ -432,11 +583,28 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
432
583
|
this.#resumeNextPage = 0
|
|
433
584
|
if (!this.#closing && !this.#fatal) this.#setState("reconnecting")
|
|
434
585
|
const lost = new SocketductSessionLostError("Reverse gateway physical transport was interrupted", "GATEWAY_DISCONNECTED")
|
|
586
|
+
const attribution = attemptAttribution ?? this.#disconnectAttribution ?? createDisconnectAttribution({
|
|
587
|
+
disconnectReason: attemptDisconnectReason ?? "unknown_transport_close",
|
|
588
|
+
incidentOrigin: "reverse_client",
|
|
589
|
+
physicalRole: "client",
|
|
590
|
+
clientConnectionGeneration: generation,
|
|
591
|
+
fanOutCount: this.#streams.size,
|
|
592
|
+
resumeSuccessCount: 0,
|
|
593
|
+
terminalStreamCount: 0
|
|
594
|
+
})
|
|
595
|
+
this.#disconnectAttribution = attribution
|
|
435
596
|
if (!this.#closing) {
|
|
597
|
+
logDiagnostic(this.#options.logger, "reverse_client.physical_detached", {
|
|
598
|
+
...this.#routeIdentityFields(),
|
|
599
|
+
...attribution,
|
|
600
|
+
...(attemptSafeSocketCode === undefined ? {} : {safeSocketCode: attemptSafeSocketCode})
|
|
601
|
+
})
|
|
436
602
|
logDiagnostic(this.#options.logger, "transport.disconnected", {
|
|
437
|
-
generation,
|
|
603
|
+
clientConnectionGeneration: generation,
|
|
438
604
|
...this.#routeIdentityFields(),
|
|
439
605
|
virtualStreams: this.#streams.size,
|
|
606
|
+
...attribution,
|
|
607
|
+
...(attemptSafeSocketCode === undefined ? {} : {safeSocketCode: attemptSafeSocketCode}),
|
|
440
608
|
...errorDiagnostics(attemptError ?? lost, "GATEWAY_DISCONNECTED")
|
|
441
609
|
})
|
|
442
610
|
}
|
|
@@ -447,6 +615,8 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
447
615
|
if (this.#closing) return
|
|
448
616
|
if (this.#fatal) {
|
|
449
617
|
const terminalError = attemptError ?? lost
|
|
618
|
+
const terminalCode = /** @type {{code?: unknown}} */ (terminalError).code
|
|
619
|
+
if (terminalCode === "STALE_GENERATION" || terminalCode === "CONNECTION_REPLACED") this.#preserveSpools = true
|
|
450
620
|
this.#fatalError = terminalError
|
|
451
621
|
this.#setState("closed", terminalError)
|
|
452
622
|
logDiagnostic(this.#options.logger, "transport.failed", {
|
|
@@ -465,8 +635,9 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
465
635
|
})
|
|
466
636
|
}
|
|
467
637
|
|
|
468
|
-
/** @param {import('../protocol/v3-frame.js').V3Frame} frame - Hub frame. */
|
|
469
|
-
async #handleFrame(frame) {
|
|
638
|
+
/** @param {import('../protocol/v3-frame.js').V3Frame} frame - Hub frame. @param {import('node:tls').TLSSocket} physical - Owning physical generation. */
|
|
639
|
+
async #handleFrame(frame, physical) {
|
|
640
|
+
if (this.#closing || this.#physical !== physical) return
|
|
470
641
|
if (frame.type === V3FrameType.PING) { await this.#sendControl(V3FrameType.PONG, 0, frame.sequence, {}); return }
|
|
471
642
|
if (frame.type === V3FrameType.PONG) return
|
|
472
643
|
if (frame.type === V3FrameType.RESUMED) {
|
|
@@ -476,7 +647,8 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
476
647
|
this.#resumeNextPage += 1
|
|
477
648
|
this.#resumeResults.push(...manifest.streams.filter((item) => typeof item === "object" && item !== null))
|
|
478
649
|
if (manifest.final !== true) return
|
|
479
|
-
for (const item of this.#resumeResults) this.#applyResumeResult(item)
|
|
650
|
+
for (const item of this.#resumeResults) await this.#applyResumeResult(item)
|
|
651
|
+
if (this.#closing || this.#physical !== physical) return
|
|
480
652
|
this.#resumeResults = []
|
|
481
653
|
this.#resumeNextPage = 0
|
|
482
654
|
this.#resuming = false
|
|
@@ -493,13 +665,16 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
493
665
|
if (stream.state !== "opening" || frame.sequence !== 0 || detail.identifier !== stream.identifier || !validPeerWindow(detail.initialWindow)) throw new Error("Invalid reverse OPENED response")
|
|
494
666
|
stream.sendWindowLimit = Math.min(stream.sendWindowLimit, detail.initialWindow)
|
|
495
667
|
stream.sendWindow = Math.min(stream.sendWindow, stream.sendWindowLimit)
|
|
496
|
-
stream.state = "open"
|
|
668
|
+
stream.state = stream.localEnded ? "half-closed" : "open"
|
|
669
|
+
stream.opened = true
|
|
497
670
|
logDiagnostic(this.#options.logger, "virtual_stream.opened", {
|
|
498
671
|
streamId: stream.id,
|
|
499
672
|
generation: this.#generation,
|
|
500
673
|
...this.#routeIdentityFields(),
|
|
501
674
|
recovered: false
|
|
502
675
|
})
|
|
676
|
+
await this.#persistStreamState(stream)
|
|
677
|
+
if (this.#closing || this.#physical !== physical) return
|
|
503
678
|
stream.socket._markConnected()
|
|
504
679
|
this.#progressPendingWrites()
|
|
505
680
|
await this.#pumpStream(stream)
|
|
@@ -508,11 +683,11 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
508
683
|
if (frame.type === V3FrameType.DATA) {
|
|
509
684
|
if (stream.remoteEnded) throw new Error("Reverse client received DATA after END")
|
|
510
685
|
const data = await decodeV3Data(frame, this.#codec, this.#options.compressionLimits)
|
|
686
|
+
if (this.#closing || this.#physical !== physical) return
|
|
511
687
|
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
688
|
stream.receiveSequence = frame.sequence
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
else stream.pendingReceiveAck = frame.sequence
|
|
689
|
+
stream.receiveFrames.push({sequence: frame.sequence, remaining: data.length})
|
|
690
|
+
stream.socket._receive(data)
|
|
516
691
|
return
|
|
517
692
|
}
|
|
518
693
|
if (frame.type === V3FrameType.ACK) { this.#acknowledge(stream, frame.sequence); return }
|
|
@@ -520,12 +695,15 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
520
695
|
if (frame.type === V3FrameType.END) {
|
|
521
696
|
if (stream.remoteEnded || frame.sequence !== stream.receiveSequence) throw new Error("Invalid reverse client END")
|
|
522
697
|
stream.remoteEnded = true
|
|
698
|
+
await this.#persistStreamState(stream)
|
|
699
|
+
if (this.#closing || this.#physical !== physical) return
|
|
523
700
|
stream.socket._receiveEnd()
|
|
524
701
|
return
|
|
525
702
|
}
|
|
526
703
|
if (frame.type === V3FrameType.CLOSE) {
|
|
527
704
|
if (frame.sequence !== stream.receiveSequence) throw new Error("Invalid reverse client CLOSE")
|
|
528
705
|
await this.#sendControl(V3FrameType.CLOSE, stream.id, stream.transmittedSequence, {})
|
|
706
|
+
if (this.#closing || this.#physical !== physical) return
|
|
529
707
|
stream.state = "closed"
|
|
530
708
|
logDiagnostic(this.#options.logger, "virtual_stream.closed", {
|
|
531
709
|
streamId: stream.id,
|
|
@@ -542,7 +720,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
542
720
|
const detail = decodeV3Control(frame.payload)
|
|
543
721
|
const code = typeof detail.code === "string" ? detail.code : "STREAM_RESET"
|
|
544
722
|
stream.state = "reset"
|
|
545
|
-
const error = new SocketductStreamError("Reverse gateway stream was reset", code)
|
|
723
|
+
const error = attachDisconnectAttribution(new SocketductStreamError("Reverse gateway stream was reset", code), validHubAttribution(detail) ?? (code === "GATEWAY_DISCONNECTED" ? this.#disconnectAttribution : undefined))
|
|
546
724
|
stream.socket._receiveError(error)
|
|
547
725
|
return
|
|
548
726
|
}
|
|
@@ -558,7 +736,9 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
558
736
|
identifier: stream.identifier,
|
|
559
737
|
clientToGatewaySequence: stream.sendSequence,
|
|
560
738
|
clientToGatewayAck: stream.sendAck,
|
|
561
|
-
|
|
739
|
+
// A replacement cannot inherit the dead VirtualSocket's process-only
|
|
740
|
+
// readable buffer. Ask the surviving gateway to replay above durable ACK.
|
|
741
|
+
gatewayToClientSequence: stream.recoveredObject ? stream.receiveAck : stream.receiveSequence,
|
|
562
742
|
gatewayToClientAck: stream.receiveAck,
|
|
563
743
|
localEnded: stream.localEnded,
|
|
564
744
|
remoteEnded: stream.remoteEnded
|
|
@@ -576,7 +756,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
576
756
|
}
|
|
577
757
|
|
|
578
758
|
/** @param {unknown} value - Hub resume result. */
|
|
579
|
-
#applyResumeResult(value) {
|
|
759
|
+
async #applyResumeResult(value) {
|
|
580
760
|
if (typeof value !== "object" || value === null) throw new Error("Invalid reverse client resume result")
|
|
581
761
|
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
762
|
if (!Number.isInteger(result.streamId)) throw new Error("Invalid reverse client resume stream ID")
|
|
@@ -584,7 +764,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
584
764
|
if (stream === undefined) return
|
|
585
765
|
if (result.status !== "resumed") {
|
|
586
766
|
const code = typeof result.code === "string" ? result.code : "RESUME_REJECTED"
|
|
587
|
-
stream.socket._receiveError(new SocketductSessionLostError("Reverse gateway stream could not be resumed", code))
|
|
767
|
+
stream.socket._receiveError(attachDisconnectAttribution(new SocketductSessionLostError("Reverse gateway stream could not be resumed", code), validHubAttribution(result) ?? this.#disconnectAttribution))
|
|
588
768
|
return
|
|
589
769
|
}
|
|
590
770
|
const clientToGatewaySequence = /** @type {number} */ (result.clientToGatewaySequence)
|
|
@@ -596,15 +776,20 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
596
776
|
this.#acknowledge(stream, clientToGatewayAck)
|
|
597
777
|
stream.transmittedSequence = clientToGatewayAck
|
|
598
778
|
stream.receiveAck = gatewayToClientAck
|
|
599
|
-
stream.pendingReceiveAck = stream.pendingReceiveAck !== undefined && stream.pendingReceiveAck > stream.receiveAck ? stream.pendingReceiveAck : undefined
|
|
600
779
|
stream.sendWindow = Math.max(0, stream.sendWindowLimit - (clientToGatewaySequence - clientToGatewayAck))
|
|
601
780
|
for (const chunk of stream.replay) chunk.sent = chunk.sequence <= clientToGatewaySequence
|
|
602
781
|
stream.endSent = /** @type {boolean} */ (result.clientEnded)
|
|
603
|
-
if (result.gatewayEnded === true
|
|
782
|
+
if (result.gatewayEnded === true) {
|
|
783
|
+
stream.remoteEndPending = stream.recoveredObject || !stream.remoteEnded
|
|
604
784
|
stream.remoteEnded = true
|
|
605
|
-
stream.socket._receiveEnd()
|
|
606
785
|
}
|
|
607
|
-
if (result.opened === true
|
|
786
|
+
if (result.opened === true) {
|
|
787
|
+
stream.opened = true
|
|
788
|
+
if (stream.state === "opening") stream.state = stream.localEnded ? "half-closed" : "open"
|
|
789
|
+
}
|
|
790
|
+
stream.openingSent = true
|
|
791
|
+
stream.recoveredObject = false
|
|
792
|
+
await this.#persistStreamState(stream)
|
|
608
793
|
}
|
|
609
794
|
|
|
610
795
|
/** Marks one authenticated/selected physical generation ready. */
|
|
@@ -632,6 +817,10 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
632
817
|
this.#settleConnect()
|
|
633
818
|
for (const stream of this.#streams.values()) {
|
|
634
819
|
if (stream.state !== "opening") stream.socket._markConnected()
|
|
820
|
+
if (stream.remoteEndPending) {
|
|
821
|
+
stream.remoteEndPending = false
|
|
822
|
+
stream.socket._receiveEnd()
|
|
823
|
+
}
|
|
635
824
|
void this.#pumpStream(stream).catch((error) => stream.socket._receiveError(asError(error)))
|
|
636
825
|
}
|
|
637
826
|
this.#progressPendingWrites()
|
|
@@ -662,6 +851,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
662
851
|
stream.endSent = false
|
|
663
852
|
return
|
|
664
853
|
}
|
|
854
|
+
await this.#persistStreamState(stream)
|
|
665
855
|
this.#finishEnd(stream)
|
|
666
856
|
}
|
|
667
857
|
} finally { stream.pumping = false }
|
|
@@ -756,7 +946,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
756
946
|
stream.replay = []
|
|
757
947
|
stream.bufferedBytes = 0
|
|
758
948
|
this.#finishWrite(stream, pendingWriteError)
|
|
759
|
-
this.#trackSpoolCleanup(stream)
|
|
949
|
+
if (!this.#preserveSpools) this.#trackSpoolCleanup(stream)
|
|
760
950
|
this.#progressPendingWrites()
|
|
761
951
|
}
|
|
762
952
|
|
|
@@ -803,7 +993,19 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
803
993
|
/** @param {number} type - Type. @param {number} streamId - Stream. @param {number} sequence - Sequence. @param {Record<string, unknown>} body - Body. */
|
|
804
994
|
async #sendControl(type, streamId, sequence, body) {
|
|
805
995
|
const physical = this.#physical
|
|
806
|
-
if (physical === undefined || physical.destroyed)
|
|
996
|
+
if (physical === undefined || physical.destroyed) {
|
|
997
|
+
this.#disconnectAttribution ??= createDisconnectAttribution({
|
|
998
|
+
disconnectReason: "send_after_transport_loss",
|
|
999
|
+
incidentOrigin: "reverse_client",
|
|
1000
|
+
physicalRole: "client",
|
|
1001
|
+
clientConnectionGeneration: this.#generation,
|
|
1002
|
+
fanOutCount: this.#streams.size,
|
|
1003
|
+
resumeSuccessCount: 0,
|
|
1004
|
+
terminalStreamCount: 0
|
|
1005
|
+
})
|
|
1006
|
+
logDiagnostic(this.#options.logger, "reverse_client.send_after_transport_loss", {...this.#routeIdentityFields(), ...this.#disconnectAttribution})
|
|
1007
|
+
throw attachDisconnectAttribution(new SocketductSessionLostError("Reverse gateway transport is unavailable", "GATEWAY_DISCONNECTED"), this.#disconnectAttribution)
|
|
1008
|
+
}
|
|
807
1009
|
return await writeSocket(physical, encodeV3Frame({type, streamId, sequence}, encodeV3Control(body)))
|
|
808
1010
|
}
|
|
809
1011
|
|
|
@@ -841,19 +1043,30 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
841
1043
|
version: 3,
|
|
842
1044
|
role: "reverse-client",
|
|
843
1045
|
connectionId: this.#connectionId,
|
|
1046
|
+
generation: this.#generation,
|
|
1047
|
+
...this.#routeIdentityFields(),
|
|
844
1048
|
streamId: stream.id,
|
|
845
1049
|
identifier: stream.identifier,
|
|
846
1050
|
name: stream.name,
|
|
847
1051
|
target: stream.target,
|
|
1052
|
+
state: stream.state,
|
|
1053
|
+
opened: stream.opened,
|
|
1054
|
+
openingSent: stream.openingSent,
|
|
848
1055
|
sendSequence: stream.sendSequence,
|
|
849
1056
|
sendAck: stream.sendAck,
|
|
850
1057
|
receiveSequence: stream.receiveSequence,
|
|
851
1058
|
receiveAck: stream.receiveAck,
|
|
852
1059
|
localEnded: stream.localEnded,
|
|
853
|
-
remoteEnded: stream.remoteEnded
|
|
1060
|
+
remoteEnded: stream.remoteEnded,
|
|
1061
|
+
endSent: stream.endSent
|
|
854
1062
|
}
|
|
855
1063
|
}
|
|
856
1064
|
|
|
1065
|
+
/** @param {ReverseClientStream} stream - Durable stream state. */
|
|
1066
|
+
async #persistStreamState(stream) {
|
|
1067
|
+
await this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))
|
|
1068
|
+
}
|
|
1069
|
+
|
|
857
1070
|
/** @param {ReverseClientStream} stream - Spool owner. @param {() => Promise<void>} operation - Serialized durable operation. */
|
|
858
1071
|
async #queueSpoolOperation(stream, operation) {
|
|
859
1072
|
const next = stream.spoolQueue.then(async () => {
|
|
@@ -880,7 +1093,10 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
880
1093
|
|
|
881
1094
|
/** @param {ReverseClientStream} stream - Terminal stream. */
|
|
882
1095
|
#trackSpoolCleanup(stream) {
|
|
883
|
-
const
|
|
1096
|
+
const removal = stream.ownsSpool
|
|
1097
|
+
? this.#removeSpool(stream)
|
|
1098
|
+
: stream.spoolReady.then(() => stream.ownsSpool ? this.#removeSpool(stream) : undefined)
|
|
1099
|
+
const task = removal.catch((error) => {
|
|
884
1100
|
if (!stream.spoolCleanupFailureRecorded) {
|
|
885
1101
|
stream.spoolCleanupFailureRecorded = true
|
|
886
1102
|
this.#spoolCleanupFailures += 1
|
|
@@ -927,6 +1143,71 @@ function validateOptions(options) {
|
|
|
927
1143
|
if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
|
|
928
1144
|
}
|
|
929
1145
|
|
|
1146
|
+
/**
|
|
1147
|
+
* @param {Record<string, unknown>} value - Parsed durable state.
|
|
1148
|
+
* @param {{connectionId: string, identifier: string, target: {host: string, port: number}, selection: {gatewayId: string} | {routeClass: string}, initialWindow: number, sessionName?: string}} expected - Caller-owned recovery identity.
|
|
1149
|
+
* @returns {ReverseClientRecoveryState} Validated state.
|
|
1150
|
+
*/
|
|
1151
|
+
function validateRecoveryState(value, expected) {
|
|
1152
|
+
const state = /** @type {Partial<ReverseClientRecoveryState>} */ (value)
|
|
1153
|
+
const sequences = [state.generation, state.streamId, state.sendSequence, state.sendAck, state.receiveSequence, state.receiveAck]
|
|
1154
|
+
const validSequences = sequences.every((sequence) => Number.isSafeInteger(sequence) && /** @type {number} */ (sequence) >= 0)
|
|
1155
|
+
const validTarget = state.target?.host === expected.target.host && state.target?.port === expected.target.port
|
|
1156
|
+
const expectedState = state.opened === true ? (state.localEnded === true ? "half-closed" : "open") : "opening"
|
|
1157
|
+
const selectorMatches = "gatewayId" in expected.selection
|
|
1158
|
+
? state.gatewayId === expected.selection.gatewayId && !Object.hasOwn(state, "routeClass")
|
|
1159
|
+
: state.routeClass === expected.selection.routeClass && !Object.hasOwn(state, "gatewayId")
|
|
1160
|
+
if (
|
|
1161
|
+
state.version !== 3
|
|
1162
|
+
|| state.role !== "reverse-client"
|
|
1163
|
+
|| state.connectionId !== expected.connectionId
|
|
1164
|
+
|| state.identifier !== expected.identifier
|
|
1165
|
+
|| !validSequences
|
|
1166
|
+
|| state.generation === 0
|
|
1167
|
+
|| state.generation === Number.MAX_SAFE_INTEGER
|
|
1168
|
+
|| state.streamId === 0
|
|
1169
|
+
|| /** @type {number} */ (state.streamId) % 2 === 0
|
|
1170
|
+
|| /** @type {number} */ (state.streamId) > 0xffff_fffd
|
|
1171
|
+
|| typeof state.name !== "string"
|
|
1172
|
+
|| state.name.length === 0
|
|
1173
|
+
|| state.name.length > 256
|
|
1174
|
+
|| !validTarget
|
|
1175
|
+
|| !selectorMatches
|
|
1176
|
+
|| typeof state.opened !== "boolean"
|
|
1177
|
+
|| state.openingSent !== true
|
|
1178
|
+
|| typeof state.localEnded !== "boolean"
|
|
1179
|
+
|| typeof state.remoteEnded !== "boolean"
|
|
1180
|
+
|| typeof state.endSent !== "boolean"
|
|
1181
|
+
|| (state.endSent && !state.localEnded)
|
|
1182
|
+
|| (state.endSent && state.opened !== true)
|
|
1183
|
+
|| (state.remoteEnded && state.opened !== true)
|
|
1184
|
+
|| (state.opened !== true && (/** @type {number} */ (state.sendSequence) > 0 || /** @type {number} */ (state.receiveSequence) > 0))
|
|
1185
|
+
|| state.state !== expectedState
|
|
1186
|
+
|| /** @type {number} */ (state.sendAck) > /** @type {number} */ (state.sendSequence)
|
|
1187
|
+
|| /** @type {number} */ (state.receiveAck) > /** @type {number} */ (state.receiveSequence)
|
|
1188
|
+
|| /** @type {number} */ (state.receiveSequence) - /** @type {number} */ (state.receiveAck) > expected.initialWindow
|
|
1189
|
+
|| (expected.sessionName !== undefined && state.name !== expected.sessionName)
|
|
1190
|
+
) throw new Error("Invalid reverse client recovery identity, selector, lifecycle, or sequence state")
|
|
1191
|
+
return /** @type {ReverseClientRecoveryState} */ (state)
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* @param {ReverseClientRecoveryState} state - Validated durable state.
|
|
1196
|
+
* @param {import('../spool/spool-file.js').SpoolChunk[]} chunks - Durable chunks.
|
|
1197
|
+
* @param {number} maxBytes - Per-stream byte bound.
|
|
1198
|
+
* @param {number} maxFrameBytes - Per-chunk frame bound.
|
|
1199
|
+
*/
|
|
1200
|
+
function validateRecoveredChunks(state, chunks, maxBytes, maxFrameBytes) {
|
|
1201
|
+
let sequence = state.sendAck
|
|
1202
|
+
let bytes = 0
|
|
1203
|
+
for (const chunk of chunks) {
|
|
1204
|
+
bytes += chunk.data.length
|
|
1205
|
+
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")
|
|
1206
|
+
sequence = chunk.sequence
|
|
1207
|
+
}
|
|
1208
|
+
if (sequence !== state.sendSequence || bytes > maxBytes) throw new Error("Invalid or oversized reverse client recovery replay state")
|
|
1209
|
+
}
|
|
1210
|
+
|
|
930
1211
|
/** @param {VirtualSocket} socket - Socket. @param {(error: Error | null, socket: VirtualSocket) => void} callback - Callback. */
|
|
931
1212
|
function installCallback(socket, callback) {
|
|
932
1213
|
let settled = false
|
|
@@ -938,9 +1219,18 @@ function installCallback(socket, callback) {
|
|
|
938
1219
|
}
|
|
939
1220
|
|
|
940
1221
|
function fatalError(/** @type {string} */ code) { return Object.assign(new Error("Reverse gateway client was rejected by the hub"), {code, fatal: true}) }
|
|
941
|
-
function connectionError(/** @type {string} */ code) {
|
|
942
|
-
if (code === "HUB_SHUTDOWN" || code === "GATEWAY_UNAVAILABLE" || code === "GATEWAY_DISCONNECTED" || code === "GATEWAY_REPLACED") return Object.assign(new Error("Selected reverse gateway is temporarily unavailable"), {code, fatal: false})
|
|
943
|
-
return fatalError(code)
|
|
1222
|
+
function connectionError(/** @type {string} */ code, /** @type {unknown} */ attribution = undefined) {
|
|
1223
|
+
if (code === "HUB_SHUTDOWN" || code === "GATEWAY_UNAVAILABLE" || code === "GATEWAY_DISCONNECTED" || code === "GATEWAY_REPLACED") return attachDisconnectAttribution(Object.assign(new Error("Selected reverse gateway is temporarily unavailable"), {code, fatal: false}), attribution)
|
|
1224
|
+
return attachDisconnectAttribution(fatalError(code), attribution)
|
|
1225
|
+
}
|
|
1226
|
+
/** Accepts attribution only from the authenticated hub transport. */
|
|
1227
|
+
function validHubAttribution(/** @type {unknown} */ value) {
|
|
1228
|
+
const attribution = parseDisconnectAttribution(value)
|
|
1229
|
+
return attribution?.incidentOrigin === "reverse_hub" ? attribution : undefined
|
|
1230
|
+
}
|
|
1231
|
+
/** Extracts only the closed public attribution contract from an error. */
|
|
1232
|
+
function publicDisconnectAttribution(/** @type {unknown} */ error) {
|
|
1233
|
+
return parseDisconnectAttribution(error) ?? {}
|
|
944
1234
|
}
|
|
945
1235
|
/** @param {unknown} value - Advertised peer receive window. @returns {value is number} Whether valid. */
|
|
946
1236
|
function validPeerWindow(value) { return Number.isSafeInteger(value) && /** @type {number} */ (value) > 0 }
|