socketduct 0.1.19 → 0.1.21
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 +3 -3
- package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
- package/changelog.d/20260925180000-manual-ack-durable-admission.md +1 -0
- package/docs/docker.md +5 -4
- package/docs/logging.md +131 -159
- package/docs/protocol.md +48 -7
- package/docs/reverse-gateway.md +83 -8
- package/docs/running.md +17 -13
- package/docs/security.md +7 -3
- package/docs/session-lifecycle.md +22 -0
- package/docs/socket-api.md +22 -17
- package/package.json +5 -1
- package/scripts/verify-zstd-interoperability.mjs +41 -0
- package/src/cli/socketduct.js +143 -0
- package/src/client/deferred-virtual-socket.js +42 -1
- package/src/client/multiplexed-connection.js +17 -0
- package/src/client/multiplexed-transport-set.js +43 -8
- package/src/client/multiplexed.js +1 -1
- package/src/client/virtual-socket.js +119 -4
- package/src/config/environment.js +56 -7
- package/src/index.js +65 -27
- 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 +245 -34
- package/src/reverse/disconnect-attribution.js +191 -0
- package/src/reverse/gateway.js +68 -9
- package/src/reverse/hub.js +215 -32
- package/src/reverse/runtime.js +5 -3
- package/src/spool/spool-file.js +92 -6
- package/tensorbuzz.yml +2 -1
- package/test/e2e/logger-shutdown-process.test.js +86 -0
- package/test/e2e/reverse-client-recovery-process.test.js +3 -0
- package/test/e2e/reverse-gateway-process.test.js +15 -14
- package/test/fixtures/reverse-client-recovery.mjs +14 -2
- package/test/integration/logging-cli.test.js +168 -0
- package/test/integration/logging-compressed-recovery.test.js +186 -0
- package/test/integration/multiplexed-transport-set.test.js +1 -5
- package/test/integration/reverse-gateway.test.js +334 -1
- package/test/integration/reverse-route-class.test.js +6 -1
- package/test/unit/client/multiplexed-transport-set.test.js +50 -3
- package/test/unit/client/virtual-socket.test.js +123 -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 +5 -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 +58 -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
|
|
@@ -62,6 +63,7 @@ const MANIFEST_PAGE_STREAMS = 64
|
|
|
62
63
|
* @typedef {object} PendingClientWrite
|
|
63
64
|
* @property {Buffer} data
|
|
64
65
|
* @property {number} offset
|
|
66
|
+
* @property {boolean} durable
|
|
65
67
|
* @property {(error?: Error | null) => void} callback
|
|
66
68
|
* @typedef {object} ReverseClientStream
|
|
67
69
|
* @property {number} id
|
|
@@ -79,6 +81,9 @@ const MANIFEST_PAGE_STREAMS = 64
|
|
|
79
81
|
* @property {number} sendWindowLimit
|
|
80
82
|
* @property {number} receiveSequence
|
|
81
83
|
* @property {number} receiveAck
|
|
84
|
+
* @property {number} receiveDelivered
|
|
85
|
+
* @property {number} pendingReceiveAckBytes
|
|
86
|
+
* @property {boolean} manualReceiveAck
|
|
82
87
|
* @property {Array<{sequence: number, remaining: number}>} receiveFrames
|
|
83
88
|
* @property {boolean} remoteEnded
|
|
84
89
|
* @property {boolean} remoteEndPending
|
|
@@ -86,8 +91,9 @@ const MANIFEST_PAGE_STREAMS = 64
|
|
|
86
91
|
* @property {ClientChunk[]} replay
|
|
87
92
|
* @property {AdaptiveCompressor} compressor
|
|
88
93
|
* @property {boolean} pumping
|
|
89
|
-
* @property {PendingClientWrite
|
|
94
|
+
* @property {PendingClientWrite[]} pendingWrites
|
|
90
95
|
* @property {boolean} progressingWrite
|
|
96
|
+
* @property {Promise<void> | undefined} writeTask
|
|
91
97
|
* @property {boolean} localEnded
|
|
92
98
|
* @property {boolean} endSent
|
|
93
99
|
* @property {((error?: Error | null) => void) | undefined} endCallback
|
|
@@ -98,6 +104,7 @@ const MANIFEST_PAGE_STREAMS = 64
|
|
|
98
104
|
* @property {boolean} spoolCleanupFailureRecorded
|
|
99
105
|
* @property {boolean} ownsSpool
|
|
100
106
|
* @property {boolean} recoveredObject
|
|
107
|
+
* @property {Promise<number>} manualAckQueue
|
|
101
108
|
* @typedef {object} ReverseClientRecoveryState
|
|
102
109
|
* @property {number} version
|
|
103
110
|
* @property {'reverse-client'} role
|
|
@@ -119,6 +126,7 @@ const MANIFEST_PAGE_STREAMS = 64
|
|
|
119
126
|
* @property {boolean} localEnded
|
|
120
127
|
* @property {boolean} remoteEnded
|
|
121
128
|
* @property {boolean} endSent
|
|
129
|
+
* @property {boolean} [manualReceiveAck]
|
|
122
130
|
*/
|
|
123
131
|
|
|
124
132
|
/** Server3/TensorBuzz-facing virtual socket connection to an exact gateway or server-resolved route class. */
|
|
@@ -132,6 +140,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
132
140
|
/** @type {number} */ #generation
|
|
133
141
|
/** @type {string | undefined} */ #codec
|
|
134
142
|
/** @type {boolean} */ #closing = false
|
|
143
|
+
/** @type {boolean} */ #detached = false
|
|
135
144
|
/** @type {boolean} */ #preserveSpools = false
|
|
136
145
|
/** @type {boolean} */ #fatal = false
|
|
137
146
|
/** @type {Error | undefined} */ #fatalError
|
|
@@ -159,6 +168,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
159
168
|
/** @type {number} */ #spoolBytes = 0
|
|
160
169
|
/** @type {number} */ #spoolCleanupFailures = 0
|
|
161
170
|
/** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
|
|
171
|
+
/** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */ #disconnectAttribution
|
|
162
172
|
|
|
163
173
|
/** @param {ReverseGatewayConnectionOptions} options - Hub route and safety policy. */
|
|
164
174
|
constructor(options) {
|
|
@@ -204,9 +214,31 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
204
214
|
return this.#createConnection(options, callback, true)
|
|
205
215
|
}
|
|
206
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Reads and validates one durable descriptor without allocating a socket.
|
|
219
|
+
* @param {string} identifier - Stable stream identifier.
|
|
220
|
+
* @returns {{identifier: string, target: {host: string, port: number}, sessionName: string, manualReceiveAck: boolean} | undefined} Recovery descriptor.
|
|
221
|
+
*/
|
|
222
|
+
_inspectRecoveredConnection(identifier) {
|
|
223
|
+
if (typeof identifier !== "string" || !STABLE_IDENTIFIER.test(identifier)) throw new TypeError("Reverse virtual stream identifier must be 8-128 URL-safe characters")
|
|
224
|
+
const value = this.#spool.findRecoveryStateSync(identifier, "reverse-client-v3")
|
|
225
|
+
if (value === undefined) return undefined
|
|
226
|
+
const target = parseTarget(/** @type {{host?: unknown, port?: unknown}} */ (value.target))
|
|
227
|
+
if (target === undefined) throw new Error("Invalid reverse client recovery target")
|
|
228
|
+
const state = validateRecoveryState(value, {
|
|
229
|
+
connectionId: this.#connectionId,
|
|
230
|
+
identifier,
|
|
231
|
+
target,
|
|
232
|
+
selection: this.#routeIdentityFields(),
|
|
233
|
+
initialWindow: this.#initialWindow()
|
|
234
|
+
})
|
|
235
|
+
return {identifier, target, sessionName: state.name, manualReceiveAck: state.manualReceiveAck === true}
|
|
236
|
+
}
|
|
237
|
+
|
|
207
238
|
/** Creates a fresh or synchronously recovered reverse stream. */
|
|
208
239
|
#createConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback, /** @type {boolean} */ recovering) {
|
|
209
240
|
if (this.#closing) throw closedError()
|
|
241
|
+
if (options?.manualReceiveAck !== undefined && typeof options.manualReceiveAck !== "boolean") throw new TypeError("manualReceiveAck must be a boolean")
|
|
210
242
|
if (!recovering && this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
|
|
211
243
|
if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum reverse virtual stream count exceeded")
|
|
212
244
|
const target = parseTarget(options?.target ?? {host: options?.host, port: options?.port})
|
|
@@ -220,7 +252,8 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
220
252
|
target,
|
|
221
253
|
selection: this.#routeIdentityFields(),
|
|
222
254
|
initialWindow: this.#initialWindow(),
|
|
223
|
-
...(options.sessionName === undefined ? {} : {sessionName: options.sessionName})
|
|
255
|
+
...(options.sessionName === undefined ? {} : {sessionName: options.sessionName}),
|
|
256
|
+
...(options.manualReceiveAck === undefined ? {} : {manualReceiveAck: options.manualReceiveAck})
|
|
224
257
|
})
|
|
225
258
|
: undefined
|
|
226
259
|
const chunks = recovery === undefined ? [] : this.#spool.loadChunksSync(identifier, "reverse-client-v3", recovery.sendAck, recovery.sendSequence)
|
|
@@ -231,11 +264,13 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
231
264
|
if (this.#streams.has(id)) throw new Error(`Reverse v3 stream ID ${id} is already active`)
|
|
232
265
|
const name = options.sessionName ?? recovery?.name ?? `socketduct-reverse-${id}`
|
|
233
266
|
if (typeof name !== "string" || name.length === 0 || name.length > 256) throw new TypeError("Reverse virtual stream name must be 1-256 characters")
|
|
267
|
+
const manualReceiveAck = recovery?.manualReceiveAck ?? options.manualReceiveAck ?? false
|
|
234
268
|
const socket = new VirtualSocket(/** @type {import('../client/multiplexed-connection.js').MultiplexedSocketductConnection} */ (/** @type {unknown} */ (this)), id, {
|
|
235
269
|
...target,
|
|
236
270
|
...(options.allowHalfOpen === undefined ? {} : {allowHalfOpen: options.allowHalfOpen}),
|
|
237
271
|
...(options.readableHighWaterMark === undefined ? {} : {readableHighWaterMark: options.readableHighWaterMark}),
|
|
238
|
-
...(options.writableHighWaterMark === undefined ? {} : {writableHighWaterMark: options.writableHighWaterMark})
|
|
272
|
+
...(options.writableHighWaterMark === undefined ? {} : {writableHighWaterMark: options.writableHighWaterMark}),
|
|
273
|
+
manualReceiveAck
|
|
239
274
|
})
|
|
240
275
|
/** @type {ReverseClientStream} */
|
|
241
276
|
const stream = {
|
|
@@ -252,6 +287,9 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
252
287
|
// delivered. Resume inbound delivery from the persisted ACK boundary.
|
|
253
288
|
receiveSequence: recovery?.receiveAck ?? 0,
|
|
254
289
|
receiveAck: recovery?.receiveAck ?? 0,
|
|
290
|
+
receiveDelivered: recovery?.receiveAck ?? 0,
|
|
291
|
+
pendingReceiveAckBytes: 0,
|
|
292
|
+
manualReceiveAck,
|
|
255
293
|
receiveFrames: [],
|
|
256
294
|
remoteEnded: recovery?.remoteEnded ?? false,
|
|
257
295
|
remoteEndPending: recovery?.remoteEnded ?? false,
|
|
@@ -259,8 +297,9 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
259
297
|
replay: chunks.map((chunk) => ({...chunk, sent: false})),
|
|
260
298
|
compressor: new AdaptiveCompressor(),
|
|
261
299
|
pumping: false,
|
|
262
|
-
|
|
300
|
+
pendingWrites: [],
|
|
263
301
|
progressingWrite: false,
|
|
302
|
+
writeTask: undefined,
|
|
264
303
|
localEnded: recovery?.localEnded ?? false,
|
|
265
304
|
endSent: recovery?.endSent ?? false,
|
|
266
305
|
endCallback: undefined,
|
|
@@ -270,7 +309,8 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
270
309
|
spoolRemovalPromise: undefined,
|
|
271
310
|
spoolCleanupFailureRecorded: false,
|
|
272
311
|
ownsSpool: recovery !== undefined,
|
|
273
|
-
recoveredObject: recovery !== undefined
|
|
312
|
+
recoveredObject: recovery !== undefined,
|
|
313
|
+
manualAckQueue: Promise.resolve(recovery?.receiveAck ?? 0)
|
|
274
314
|
}
|
|
275
315
|
if (recovery !== undefined) {
|
|
276
316
|
this.#options.aggregateBudget?.reserveData(bufferedBytes)
|
|
@@ -367,6 +407,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
367
407
|
if (this.#options.connectionId === undefined) throw new Error("detach() requires an explicit stable connectionId")
|
|
368
408
|
if (this.#options.spoolDirectory === undefined) throw new Error("detach() requires an explicit persistent spoolDirectory")
|
|
369
409
|
this.#preserveSpools = true
|
|
410
|
+
this.#detached = true
|
|
370
411
|
this.#closing = true
|
|
371
412
|
if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
|
|
372
413
|
this.#reconnectTimer = undefined
|
|
@@ -377,7 +418,12 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
377
418
|
const frameQueue = this.#physicalFrameQueue
|
|
378
419
|
try {
|
|
379
420
|
await frameQueue
|
|
380
|
-
|
|
421
|
+
const streams = [...this.#streams.values()]
|
|
422
|
+
await Promise.all(streams.map((stream) => stream.writeTask))
|
|
423
|
+
await Promise.all(streams.map((stream) => stream.manualAckQueue))
|
|
424
|
+
const detached = new SocketductSessionLostError("Reverse connection detached before queued write admission", "SOCKETDUCT_CONNECTION_DETACHED")
|
|
425
|
+
for (const stream of streams) while (stream.pendingWrites.length > 0) this.#finishWrite(stream, detached)
|
|
426
|
+
await Promise.all(streams.filter((stream) => this.#streams.get(stream.id) === stream).map((stream) => this.#persistStreamState(stream)))
|
|
381
427
|
} finally {
|
|
382
428
|
physical?.destroy()
|
|
383
429
|
this.#physical = undefined
|
|
@@ -389,7 +435,15 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
389
435
|
_writeStream(/** @type {number} */ streamId, /** @type {Buffer} */ data, /** @type {(error?: Error | null) => void} */ callback) {
|
|
390
436
|
const stream = this.#streams.get(streamId)
|
|
391
437
|
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 }
|
|
392
|
-
stream.
|
|
438
|
+
stream.pendingWrites.push({data, offset: 0, durable: false, callback})
|
|
439
|
+
this.#progressPendingWrites()
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
_writeDurableStream(/** @type {number} */ streamId, /** @type {Buffer} */ data, /** @type {(error?: Error | null) => void} */ callback) {
|
|
443
|
+
const stream = this.#streams.get(streamId)
|
|
444
|
+
if (this.#detached) { callback(new SocketductSessionLostError("Reverse connection is detached", "SOCKETDUCT_CONNECTION_DETACHED")); return }
|
|
445
|
+
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 }
|
|
446
|
+
stream.pendingWrites.push({data, offset: 0, durable: true, callback})
|
|
393
447
|
this.#progressPendingWrites()
|
|
394
448
|
}
|
|
395
449
|
|
|
@@ -411,7 +465,8 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
411
465
|
streamId,
|
|
412
466
|
generation: this.#generation,
|
|
413
467
|
...this.#routeIdentityFields(),
|
|
414
|
-
...(error === null ? {} : errorDiagnostics(error))
|
|
468
|
+
...(error === null ? {} : errorDiagnostics(error)),
|
|
469
|
+
...(error === null ? {} : publicDisconnectAttribution(error))
|
|
415
470
|
})
|
|
416
471
|
this.#retireStream(stream, error ?? new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED"))
|
|
417
472
|
if (!wasClosed && !this.#closing && this.#state === "ready") void this.#sendControl(V3FrameType.RESET, streamId, 0, {code: error instanceof SocketductStreamError ? error.code : "CLIENT_CANCELLED"}).catch(() => {})
|
|
@@ -421,9 +476,17 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
421
476
|
void streamId
|
|
422
477
|
}
|
|
423
478
|
|
|
479
|
+
_deliverReadable(/** @type {number} */ streamId, /** @type {number} */ bytes) {
|
|
480
|
+
const stream = this.#streams.get(streamId)
|
|
481
|
+
if (stream === undefined || !stream.manualReceiveAck || !Number.isSafeInteger(bytes) || bytes <= 0) return
|
|
482
|
+
stream.receiveDelivered += bytes
|
|
483
|
+
if (stream.receiveDelivered > stream.receiveSequence) throw new Error("Reverse client readable delivery accounting overflow")
|
|
484
|
+
}
|
|
485
|
+
|
|
424
486
|
_consumeReadable(/** @type {number} */ streamId, /** @type {number} */ bytes) {
|
|
425
487
|
const stream = this.#streams.get(streamId)
|
|
426
488
|
if (stream === undefined || !Number.isSafeInteger(bytes) || bytes <= 0) return
|
|
489
|
+
if (stream.manualReceiveAck) return
|
|
427
490
|
let remaining = bytes
|
|
428
491
|
let acknowledgedSequence = stream.receiveAck
|
|
429
492
|
while (remaining > 0 && stream.receiveFrames.length > 0) {
|
|
@@ -440,6 +503,27 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
440
503
|
this.#ackReceived(stream, acknowledgedSequence)
|
|
441
504
|
}
|
|
442
505
|
|
|
506
|
+
_acknowledgeReadable(/** @type {number} */ streamId, /** @type {number} */ bytes) {
|
|
507
|
+
const stream = this.#streams.get(streamId)
|
|
508
|
+
if (this.#detached) return Promise.reject(new SocketductStreamError("Reverse connection is detached", "SOCKETDUCT_CONNECTION_DETACHED"))
|
|
509
|
+
if (this.#closing) return Promise.reject(closedError())
|
|
510
|
+
if (stream === undefined) return Promise.reject(new SocketductStreamError("Reverse stream is destroyed", "ERR_STREAM_DESTROYED"))
|
|
511
|
+
if (!stream.manualReceiveAck) return Promise.reject(new SocketductStreamError("Manual receive acknowledgement is not enabled", "SOCKETDUCT_MANUAL_ACK_DISABLED"))
|
|
512
|
+
if (stream.receiveAck + stream.pendingReceiveAckBytes + bytes > stream.receiveDelivered) throw new SocketductStreamError("Receive acknowledgement exceeds bytes delivered to the application", "SOCKETDUCT_INVALID_RECEIVE_ACK")
|
|
513
|
+
stream.pendingReceiveAckBytes += bytes
|
|
514
|
+
const acknowledged = stream.manualAckQueue.then(async () => {
|
|
515
|
+
const sequence = stream.receiveAck + bytes
|
|
516
|
+
await this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream, {receiveAck: sequence})))
|
|
517
|
+
stream.receiveAck = sequence
|
|
518
|
+
stream.pendingReceiveAckBytes -= bytes
|
|
519
|
+
await this.#sendControl(V3FrameType.ACK, stream.id, sequence, {}).catch(() => {})
|
|
520
|
+
return sequence
|
|
521
|
+
})
|
|
522
|
+
stream.manualAckQueue = acknowledged
|
|
523
|
+
void acknowledged.catch((error) => stream.socket._receiveError(asError(error)))
|
|
524
|
+
return acknowledged
|
|
525
|
+
}
|
|
526
|
+
|
|
443
527
|
#allocateStreamId() {
|
|
444
528
|
const id = this.#nextStreamId
|
|
445
529
|
if (id > 0xffff_fffd) throw new Error("Reverse v3 stream ID space exhausted")
|
|
@@ -483,9 +567,18 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
483
567
|
this.#physicalFrameQueue = queue
|
|
484
568
|
let authenticated = false
|
|
485
569
|
let selected = false
|
|
570
|
+
/** @type {import('./disconnect-attribution.js').DisconnectReason | undefined} */
|
|
571
|
+
let attemptDisconnectReason
|
|
572
|
+
/** @type {string | undefined} */
|
|
573
|
+
let attemptSafeSocketCode
|
|
574
|
+
/** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */
|
|
575
|
+
let attemptAttribution
|
|
486
576
|
/** @type {Error | undefined} */
|
|
487
577
|
let attemptError
|
|
488
|
-
const timer = setTimeout(() =>
|
|
578
|
+
const timer = setTimeout(() => {
|
|
579
|
+
attemptDisconnectReason ??= "heartbeat_timeout"
|
|
580
|
+
physical.destroy(Object.assign(new Error("Reverse client TLS/auth timeout"), {code: "SOCKETDUCT_AUTH_TIMEOUT"}))
|
|
581
|
+
}, this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
|
|
489
582
|
timer.unref?.()
|
|
490
583
|
physical.once("secureConnect", () => {
|
|
491
584
|
// The attempted generation must survive a crash after AUTH reaches the
|
|
@@ -505,13 +598,16 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
505
598
|
if (this.#closing || this.#physical !== physical) return
|
|
506
599
|
if (frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.GOAWAY) {
|
|
507
600
|
const detail = decodeV3Control(frame.payload)
|
|
508
|
-
|
|
601
|
+
if (frame.type === V3FrameType.GOAWAY) attemptDisconnectReason ??= "peer_goaway"
|
|
602
|
+
attemptAttribution = validHubAttribution(detail) ?? attemptAttribution
|
|
603
|
+
throw connectionError(typeof detail.code === "string" ? detail.code : "CONNECTION_REJECTED", attemptAttribution)
|
|
509
604
|
}
|
|
510
605
|
if (!authenticated) {
|
|
511
606
|
if (frame.type !== V3FrameType.AUTH_OK) throw fatalError("AUTH_NEGOTIATION_FAILED")
|
|
512
607
|
const detail = decodeV3Control(frame.payload)
|
|
513
608
|
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")
|
|
514
609
|
authenticated = true
|
|
610
|
+
this.#disconnectAttribution = undefined
|
|
515
611
|
this.#codec = ZSTD_CODEC
|
|
516
612
|
this.#sendWindowLimit = Math.min(this.#initialWindow(), detail.initialWindow)
|
|
517
613
|
for (const stream of this.#streams.values()) {
|
|
@@ -544,12 +640,19 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
544
640
|
}
|
|
545
641
|
}).catch((error) => {
|
|
546
642
|
attemptError = asError(error)
|
|
643
|
+
attemptDisconnectReason ??= "protocol_violation"
|
|
547
644
|
if (/** @type {{fatal?: unknown}} */ (attemptError).fatal === true) this.#fatal = true
|
|
548
645
|
physical.destroy(attemptError)
|
|
549
646
|
}).finally(() => { if (!physical.destroyed) physical.resume() })
|
|
550
647
|
this.#physicalFrameQueue = queue
|
|
551
648
|
})
|
|
552
|
-
physical.once("
|
|
649
|
+
physical.once("end", () => { attemptDisconnectReason ??= "remote_eof" })
|
|
650
|
+
physical.once("error", (error) => {
|
|
651
|
+
attemptError = error
|
|
652
|
+
attemptDisconnectReason ??= "remote_socket_error"
|
|
653
|
+
attemptSafeSocketCode ??= safeSocketCode(error)
|
|
654
|
+
if (isFatalTransportError(error)) this.#fatal = true
|
|
655
|
+
})
|
|
553
656
|
physical.once("close", () => {
|
|
554
657
|
clearTimeout(timer)
|
|
555
658
|
void queue.finally(() => {
|
|
@@ -561,11 +664,28 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
561
664
|
this.#resumeNextPage = 0
|
|
562
665
|
if (!this.#closing && !this.#fatal) this.#setState("reconnecting")
|
|
563
666
|
const lost = new SocketductSessionLostError("Reverse gateway physical transport was interrupted", "GATEWAY_DISCONNECTED")
|
|
667
|
+
const attribution = attemptAttribution ?? this.#disconnectAttribution ?? createDisconnectAttribution({
|
|
668
|
+
disconnectReason: attemptDisconnectReason ?? "unknown_transport_close",
|
|
669
|
+
incidentOrigin: "reverse_client",
|
|
670
|
+
physicalRole: "client",
|
|
671
|
+
clientConnectionGeneration: generation,
|
|
672
|
+
fanOutCount: this.#streams.size,
|
|
673
|
+
resumeSuccessCount: 0,
|
|
674
|
+
terminalStreamCount: 0
|
|
675
|
+
})
|
|
676
|
+
this.#disconnectAttribution = attribution
|
|
564
677
|
if (!this.#closing) {
|
|
678
|
+
logDiagnostic(this.#options.logger, "reverse_client.physical_detached", {
|
|
679
|
+
...this.#routeIdentityFields(),
|
|
680
|
+
...attribution,
|
|
681
|
+
...(attemptSafeSocketCode === undefined ? {} : {safeSocketCode: attemptSafeSocketCode})
|
|
682
|
+
})
|
|
565
683
|
logDiagnostic(this.#options.logger, "transport.disconnected", {
|
|
566
|
-
generation,
|
|
684
|
+
clientConnectionGeneration: generation,
|
|
567
685
|
...this.#routeIdentityFields(),
|
|
568
686
|
virtualStreams: this.#streams.size,
|
|
687
|
+
...attribution,
|
|
688
|
+
...(attemptSafeSocketCode === undefined ? {} : {safeSocketCode: attemptSafeSocketCode}),
|
|
569
689
|
...errorDiagnostics(attemptError ?? lost, "GATEWAY_DISCONNECTED")
|
|
570
690
|
})
|
|
571
691
|
}
|
|
@@ -647,7 +767,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
647
767
|
if (this.#closing || this.#physical !== physical) return
|
|
648
768
|
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")
|
|
649
769
|
stream.receiveSequence = frame.sequence
|
|
650
|
-
stream.receiveFrames.push({sequence: frame.sequence, remaining: data.length})
|
|
770
|
+
if (!stream.manualReceiveAck) stream.receiveFrames.push({sequence: frame.sequence, remaining: data.length})
|
|
651
771
|
stream.socket._receive(data)
|
|
652
772
|
return
|
|
653
773
|
}
|
|
@@ -681,7 +801,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
681
801
|
const detail = decodeV3Control(frame.payload)
|
|
682
802
|
const code = typeof detail.code === "string" ? detail.code : "STREAM_RESET"
|
|
683
803
|
stream.state = "reset"
|
|
684
|
-
const error = new SocketductStreamError("Reverse gateway stream was reset", code)
|
|
804
|
+
const error = attachDisconnectAttribution(new SocketductStreamError("Reverse gateway stream was reset", code), validHubAttribution(detail) ?? (code === "GATEWAY_DISCONNECTED" ? this.#disconnectAttribution : undefined))
|
|
685
805
|
stream.socket._receiveError(error)
|
|
686
806
|
return
|
|
687
807
|
}
|
|
@@ -725,7 +845,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
725
845
|
if (stream === undefined) return
|
|
726
846
|
if (result.status !== "resumed") {
|
|
727
847
|
const code = typeof result.code === "string" ? result.code : "RESUME_REJECTED"
|
|
728
|
-
stream.socket._receiveError(new SocketductSessionLostError("Reverse gateway stream could not be resumed", code))
|
|
848
|
+
stream.socket._receiveError(attachDisconnectAttribution(new SocketductSessionLostError("Reverse gateway stream could not be resumed", code), validHubAttribution(result) ?? this.#disconnectAttribution))
|
|
729
849
|
return
|
|
730
850
|
}
|
|
731
851
|
const clientToGatewaySequence = /** @type {number} */ (result.clientToGatewaySequence)
|
|
@@ -820,10 +940,18 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
820
940
|
|
|
821
941
|
/** @param {ReverseClientStream} stream - Stream with an active application write. */
|
|
822
942
|
async #progressWrite(stream) {
|
|
823
|
-
const pending = stream.
|
|
943
|
+
const pending = stream.pendingWrites[0]
|
|
824
944
|
if (pending === undefined || stream.progressingWrite || stream.state === "opening") return
|
|
825
945
|
stream.progressingWrite = true
|
|
826
946
|
try {
|
|
947
|
+
if (pending.durable) {
|
|
948
|
+
await this.#admitDurableWrite(stream, pending)
|
|
949
|
+
if (stream.pendingWrites[0] === pending) this.#finishWrite(stream)
|
|
950
|
+
queueMicrotask(() => {
|
|
951
|
+
void this.#pumpStream(stream).catch((error) => stream.socket._receiveError(asError(error)))
|
|
952
|
+
})
|
|
953
|
+
return
|
|
954
|
+
}
|
|
827
955
|
if (pending.offset < pending.data.length && this.#streams.get(stream.id) === stream) {
|
|
828
956
|
const available = this.#writeCapacity(stream)
|
|
829
957
|
if (available <= 0) return
|
|
@@ -847,17 +975,65 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
847
975
|
})
|
|
848
976
|
}
|
|
849
977
|
await this.#pumpStream(stream)
|
|
850
|
-
if (pending.offset === pending.data.length && stream.
|
|
978
|
+
if (pending.offset === pending.data.length && stream.pendingWrites[0] === pending) this.#finishWrite(stream)
|
|
851
979
|
} catch (error) {
|
|
852
980
|
const failure = asError(error)
|
|
853
981
|
this.#finishWrite(stream, failure)
|
|
854
|
-
stream.socket._receiveError(failure)
|
|
982
|
+
if (!pending.durable) stream.socket._receiveError(failure)
|
|
855
983
|
} finally {
|
|
856
984
|
stream.progressingWrite = false
|
|
857
985
|
this.#progressPendingWrites()
|
|
858
986
|
}
|
|
859
987
|
}
|
|
860
988
|
|
|
989
|
+
/** @param {ReverseClientStream} stream - Stream. @param {PendingClientWrite} pending - Complete durable operation. */
|
|
990
|
+
async #admitDurableWrite(stream, pending) {
|
|
991
|
+
const bytes = pending.data.length
|
|
992
|
+
if (bytes === 0) return
|
|
993
|
+
const streamCapacity = (this.#options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES) - stream.bufferedBytes
|
|
994
|
+
const connectionCapacity = (this.#options.maxConnectionBufferBytes ?? DEFAULT_CONNECTION_BUFFER_BYTES) - this.#bufferedBytes
|
|
995
|
+
if (bytes > streamCapacity || bytes > connectionCapacity || !Number.isSafeInteger(stream.sendSequence + bytes)) throw new SocketductStreamError("Complete durable write exceeds available retained-byte capacity", "SOCKETDUCT_DURABLE_WRITE_LIMIT")
|
|
996
|
+
let reserved = false
|
|
997
|
+
try {
|
|
998
|
+
this.#options.aggregateBudget?.reserveData(bytes)
|
|
999
|
+
reserved = true
|
|
1000
|
+
let sequence = stream.sendSequence
|
|
1001
|
+
/** @type {ClientChunk[]} */
|
|
1002
|
+
const chunks = []
|
|
1003
|
+
for (let offset = 0; offset < bytes; offset += this.#maxFrameBytes()) {
|
|
1004
|
+
const data = Buffer.from(pending.data.subarray(offset, Math.min(bytes, offset + this.#maxFrameBytes())))
|
|
1005
|
+
sequence += data.length
|
|
1006
|
+
chunks.push({sequence, data, sent: false})
|
|
1007
|
+
}
|
|
1008
|
+
await this.#queueSpoolOperation(stream, () => this.#spool.appendChunksAndRecoveryState({
|
|
1009
|
+
sessionId: stream.identifier,
|
|
1010
|
+
ownerId: "reverse-client-v3",
|
|
1011
|
+
previousSequence: stream.sendSequence,
|
|
1012
|
+
chunks,
|
|
1013
|
+
recoveryState: this.#recoveryState(stream, {sendSequence: sequence})
|
|
1014
|
+
}))
|
|
1015
|
+
if (this.#streams.get(stream.id) !== stream) throw new SocketductStreamError("Reverse stream was destroyed during durable admission", "ERR_STREAM_DESTROYED")
|
|
1016
|
+
stream.sendSequence = sequence
|
|
1017
|
+
stream.replay.push(...chunks)
|
|
1018
|
+
stream.bufferedBytes += bytes
|
|
1019
|
+
this.#bufferedBytes += bytes
|
|
1020
|
+
stream.spoolReservedBytes += bytes
|
|
1021
|
+
this.#spoolBytes += bytes
|
|
1022
|
+
pending.offset = bytes
|
|
1023
|
+
} catch (error) {
|
|
1024
|
+
if (reserved) {
|
|
1025
|
+
this.#options.aggregateBudget?.releaseMemory(bytes)
|
|
1026
|
+
this.#options.aggregateBudget?.releaseSpool(bytes)
|
|
1027
|
+
}
|
|
1028
|
+
const failure = asError(error)
|
|
1029
|
+
const code = /** @type {{code?: unknown}} */ (failure).code
|
|
1030
|
+
if (failure instanceof SocketductStreamError || (typeof code === "string" && code.startsWith("SOCKETDUCT_SET_"))) throw failure
|
|
1031
|
+
const wrapped = new SocketductStreamError("Durable write persistence failed before admission", "SOCKETDUCT_DURABLE_WRITE_PERSISTENCE")
|
|
1032
|
+
wrapped.cause = failure
|
|
1033
|
+
throw wrapped
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
861
1037
|
/** @param {ReverseClientStream} stream - Stream. @returns {number} Retained-byte headroom for its next frame. */
|
|
862
1038
|
#writeCapacity(stream) {
|
|
863
1039
|
return Math.max(0, Math.min(
|
|
@@ -870,25 +1046,27 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
870
1046
|
|
|
871
1047
|
/** Starts one bounded pending-write slice using rotating stream priority. */
|
|
872
1048
|
#progressPendingWrites() {
|
|
1049
|
+
if (this.#closing) return
|
|
873
1050
|
const streams = [...this.#streams.values()]
|
|
874
1051
|
if (streams.length === 0) return
|
|
875
1052
|
const previousIndex = streams.findIndex((stream) => stream.id === this.#lastProgressedStreamId)
|
|
876
1053
|
const startIndex = previousIndex === -1 ? 0 : (previousIndex + 1) % streams.length
|
|
877
1054
|
for (let offset = 0; offset < streams.length; offset += 1) {
|
|
878
1055
|
const stream = /** @type {ReverseClientStream} */ (streams[(startIndex + offset) % streams.length])
|
|
879
|
-
const pending = stream.
|
|
880
|
-
if (pending === undefined || stream.progressingWrite || stream.state === "opening" || (pending.offset < pending.data.length && this.#writeCapacity(stream) <= 0)) continue
|
|
1056
|
+
const pending = stream.pendingWrites[0]
|
|
1057
|
+
if (pending === undefined || stream.progressingWrite || stream.state === "opening" || (!pending.durable && pending.offset < pending.data.length && this.#writeCapacity(stream) <= 0)) continue
|
|
881
1058
|
this.#lastProgressedStreamId = stream.id
|
|
882
|
-
|
|
1059
|
+
const task = this.#progressWrite(stream)
|
|
1060
|
+
stream.writeTask = task
|
|
1061
|
+
void task.finally(() => { if (stream.writeTask === task) stream.writeTask = undefined })
|
|
883
1062
|
return
|
|
884
1063
|
}
|
|
885
1064
|
}
|
|
886
1065
|
|
|
887
1066
|
/** @param {ReverseClientStream} stream - Stream. @param {Error} [error] - Write failure. */
|
|
888
1067
|
#finishWrite(stream, error) {
|
|
889
|
-
const pending = stream.
|
|
1068
|
+
const pending = stream.pendingWrites.shift()
|
|
890
1069
|
if (pending === undefined) return
|
|
891
|
-
stream.pendingWrite = undefined
|
|
892
1070
|
pending.callback(error)
|
|
893
1071
|
}
|
|
894
1072
|
|
|
@@ -906,7 +1084,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
906
1084
|
this.#options.aggregateBudget?.releaseMemory(stream.bufferedBytes)
|
|
907
1085
|
stream.replay = []
|
|
908
1086
|
stream.bufferedBytes = 0
|
|
909
|
-
this.#finishWrite(stream, pendingWriteError)
|
|
1087
|
+
while (stream.pendingWrites.length > 0) this.#finishWrite(stream, pendingWriteError)
|
|
910
1088
|
if (!this.#preserveSpools) this.#trackSpoolCleanup(stream)
|
|
911
1089
|
this.#progressPendingWrites()
|
|
912
1090
|
}
|
|
@@ -918,7 +1096,16 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
918
1096
|
const delta = sequence - stream.sendAck
|
|
919
1097
|
stream.sendAck = sequence
|
|
920
1098
|
let released = 0
|
|
921
|
-
stream.replay = stream.replay.
|
|
1099
|
+
stream.replay = stream.replay.flatMap((chunk) => {
|
|
1100
|
+
const start = chunk.sequence - chunk.data.length
|
|
1101
|
+
if (chunk.sequence <= sequence) { released += chunk.data.length; return [] }
|
|
1102
|
+
if (start < sequence) {
|
|
1103
|
+
const acknowledgedBytes = sequence - start
|
|
1104
|
+
released += acknowledgedBytes
|
|
1105
|
+
return [{...chunk, data: Buffer.from(chunk.data.subarray(acknowledgedBytes))}]
|
|
1106
|
+
}
|
|
1107
|
+
return [chunk]
|
|
1108
|
+
})
|
|
922
1109
|
stream.bufferedBytes = Math.max(0, stream.bufferedBytes - released)
|
|
923
1110
|
this.#bufferedBytes = Math.max(0, this.#bufferedBytes - released)
|
|
924
1111
|
this.#options.aggregateBudget?.releaseMemory(released)
|
|
@@ -954,7 +1141,19 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
954
1141
|
/** @param {number} type - Type. @param {number} streamId - Stream. @param {number} sequence - Sequence. @param {Record<string, unknown>} body - Body. */
|
|
955
1142
|
async #sendControl(type, streamId, sequence, body) {
|
|
956
1143
|
const physical = this.#physical
|
|
957
|
-
if (physical === undefined || physical.destroyed)
|
|
1144
|
+
if (physical === undefined || physical.destroyed) {
|
|
1145
|
+
this.#disconnectAttribution ??= createDisconnectAttribution({
|
|
1146
|
+
disconnectReason: "send_after_transport_loss",
|
|
1147
|
+
incidentOrigin: "reverse_client",
|
|
1148
|
+
physicalRole: "client",
|
|
1149
|
+
clientConnectionGeneration: this.#generation,
|
|
1150
|
+
fanOutCount: this.#streams.size,
|
|
1151
|
+
resumeSuccessCount: 0,
|
|
1152
|
+
terminalStreamCount: 0
|
|
1153
|
+
})
|
|
1154
|
+
logDiagnostic(this.#options.logger, "reverse_client.send_after_transport_loss", {...this.#routeIdentityFields(), ...this.#disconnectAttribution})
|
|
1155
|
+
throw attachDisconnectAttribution(new SocketductSessionLostError("Reverse gateway transport is unavailable", "GATEWAY_DISCONNECTED"), this.#disconnectAttribution)
|
|
1156
|
+
}
|
|
958
1157
|
return await writeSocket(physical, encodeV3Frame({type, streamId, sequence}, encodeV3Control(body)))
|
|
959
1158
|
}
|
|
960
1159
|
|
|
@@ -986,8 +1185,8 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
986
1185
|
}
|
|
987
1186
|
}
|
|
988
1187
|
|
|
989
|
-
/** @param {ReverseClientStream} stream - Durable stream state. */
|
|
990
|
-
#recoveryState(stream) {
|
|
1188
|
+
/** @param {ReverseClientStream} stream - Durable stream state. @param {{sendSequence?: number, receiveAck?: number}} [overrides] - Commit-boundary overrides. */
|
|
1189
|
+
#recoveryState(stream, overrides = {}) {
|
|
991
1190
|
return {
|
|
992
1191
|
version: 3,
|
|
993
1192
|
role: "reverse-client",
|
|
@@ -1001,10 +1200,11 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
1001
1200
|
state: stream.state,
|
|
1002
1201
|
opened: stream.opened,
|
|
1003
1202
|
openingSent: stream.openingSent,
|
|
1004
|
-
sendSequence: stream.sendSequence,
|
|
1203
|
+
sendSequence: overrides.sendSequence ?? stream.sendSequence,
|
|
1005
1204
|
sendAck: stream.sendAck,
|
|
1006
1205
|
receiveSequence: stream.receiveSequence,
|
|
1007
|
-
receiveAck: stream.receiveAck,
|
|
1206
|
+
receiveAck: overrides.receiveAck ?? stream.receiveAck,
|
|
1207
|
+
manualReceiveAck: stream.manualReceiveAck,
|
|
1008
1208
|
localEnded: stream.localEnded,
|
|
1009
1209
|
remoteEnded: stream.remoteEnded,
|
|
1010
1210
|
endSent: stream.endSent
|
|
@@ -1094,7 +1294,7 @@ function validateOptions(options) {
|
|
|
1094
1294
|
|
|
1095
1295
|
/**
|
|
1096
1296
|
* @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.
|
|
1297
|
+
* @param {{connectionId: string, identifier: string, target: {host: string, port: number}, selection: {gatewayId: string} | {routeClass: string}, initialWindow: number, sessionName?: string, manualReceiveAck?: boolean}} expected - Caller-owned recovery identity.
|
|
1098
1298
|
* @returns {ReverseClientRecoveryState} Validated state.
|
|
1099
1299
|
*/
|
|
1100
1300
|
function validateRecoveryState(value, expected) {
|
|
@@ -1127,6 +1327,7 @@ function validateRecoveryState(value, expected) {
|
|
|
1127
1327
|
|| typeof state.localEnded !== "boolean"
|
|
1128
1328
|
|| typeof state.remoteEnded !== "boolean"
|
|
1129
1329
|
|| typeof state.endSent !== "boolean"
|
|
1330
|
+
|| (state.manualReceiveAck !== undefined && typeof state.manualReceiveAck !== "boolean")
|
|
1130
1331
|
|| (state.endSent && !state.localEnded)
|
|
1131
1332
|
|| (state.endSent && state.opened !== true)
|
|
1132
1333
|
|| (state.remoteEnded && state.opened !== true)
|
|
@@ -1136,6 +1337,7 @@ function validateRecoveryState(value, expected) {
|
|
|
1136
1337
|
|| /** @type {number} */ (state.receiveAck) > /** @type {number} */ (state.receiveSequence)
|
|
1137
1338
|
|| /** @type {number} */ (state.receiveSequence) - /** @type {number} */ (state.receiveAck) > expected.initialWindow
|
|
1138
1339
|
|| (expected.sessionName !== undefined && state.name !== expected.sessionName)
|
|
1340
|
+
|| (expected.manualReceiveAck !== undefined && (state.manualReceiveAck === true) !== expected.manualReceiveAck)
|
|
1139
1341
|
) throw new Error("Invalid reverse client recovery identity, selector, lifecycle, or sequence state")
|
|
1140
1342
|
return /** @type {ReverseClientRecoveryState} */ (state)
|
|
1141
1343
|
}
|
|
@@ -1168,9 +1370,18 @@ function installCallback(socket, callback) {
|
|
|
1168
1370
|
}
|
|
1169
1371
|
|
|
1170
1372
|
function fatalError(/** @type {string} */ code) { return Object.assign(new Error("Reverse gateway client was rejected by the hub"), {code, fatal: true}) }
|
|
1171
|
-
function connectionError(/** @type {string} */ code) {
|
|
1172
|
-
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})
|
|
1173
|
-
return fatalError(code)
|
|
1373
|
+
function connectionError(/** @type {string} */ code, /** @type {unknown} */ attribution = undefined) {
|
|
1374
|
+
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)
|
|
1375
|
+
return attachDisconnectAttribution(fatalError(code), attribution)
|
|
1376
|
+
}
|
|
1377
|
+
/** Accepts attribution only from the authenticated hub transport. */
|
|
1378
|
+
function validHubAttribution(/** @type {unknown} */ value) {
|
|
1379
|
+
const attribution = parseDisconnectAttribution(value)
|
|
1380
|
+
return attribution?.incidentOrigin === "reverse_hub" ? attribution : undefined
|
|
1381
|
+
}
|
|
1382
|
+
/** Extracts only the closed public attribution contract from an error. */
|
|
1383
|
+
function publicDisconnectAttribution(/** @type {unknown} */ error) {
|
|
1384
|
+
return parseDisconnectAttribution(error) ?? {}
|
|
1174
1385
|
}
|
|
1175
1386
|
/** @param {unknown} value - Advertised peer receive window. @returns {value is number} Whether valid. */
|
|
1176
1387
|
function validPeerWindow(value) { return Number.isSafeInteger(value) && /** @type {number} */ (value) > 0 }
|