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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/require-param-type, jsdoc/require-returns, jsdoc/require-returns-description */
|
|
2
|
+
import {createHmac, randomBytes} from "node:crypto"
|
|
3
|
+
|
|
4
|
+
const INCIDENT_ID = /^[A-Za-z0-9_-]{22}$/
|
|
5
|
+
const FINGERPRINT_KEY_EPOCH = /^[A-Za-z0-9_-]{1,32}$/
|
|
6
|
+
const SAFE_CONTROL_CODE = /^[A-Z][A-Z0-9_]{0,63}$/
|
|
7
|
+
const DISCONNECT_REASONS = new Set([
|
|
8
|
+
"remote_socket_error",
|
|
9
|
+
"remote_eof",
|
|
10
|
+
"peer_goaway",
|
|
11
|
+
"protocol_violation",
|
|
12
|
+
"output_backpressure_limit",
|
|
13
|
+
"heartbeat_timeout",
|
|
14
|
+
"gateway_replaced",
|
|
15
|
+
"gateway_shutdown",
|
|
16
|
+
"hub_shutdown",
|
|
17
|
+
"local_close",
|
|
18
|
+
"send_after_transport_loss",
|
|
19
|
+
"unknown_transport_close"
|
|
20
|
+
])
|
|
21
|
+
const INCIDENT_ORIGINS = new Set(["reverse_hub", "reverse_client", "reverse_gateway"])
|
|
22
|
+
const PHYSICAL_ROLES = new Set(["client", "gateway"])
|
|
23
|
+
const RESERVED_CONTROL_FIELDS = new Set([
|
|
24
|
+
"disconnectIncidentId",
|
|
25
|
+
"disconnectReason",
|
|
26
|
+
"incidentOrigin",
|
|
27
|
+
"physicalRole",
|
|
28
|
+
"clientConnectionGeneration",
|
|
29
|
+
"gatewayConnectionGeneration",
|
|
30
|
+
"fanOutCount",
|
|
31
|
+
"resumeSuccessCount",
|
|
32
|
+
"terminalStreamCount",
|
|
33
|
+
"connectionFingerprint",
|
|
34
|
+
"fingerprintKeyEpoch"
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
/** @typedef {'remote_socket_error' | 'remote_eof' | 'peer_goaway' | 'protocol_violation' | 'output_backpressure_limit' | 'heartbeat_timeout' | 'gateway_replaced' | 'gateway_shutdown' | 'hub_shutdown' | 'local_close' | 'send_after_transport_loss' | 'unknown_transport_close'} DisconnectReason */
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {object} DisconnectAttribution
|
|
41
|
+
* @property {string} disconnectIncidentId - Random 128-bit incident identity.
|
|
42
|
+
* @property {DisconnectReason} disconnectReason - First physical cause.
|
|
43
|
+
* @property {'reverse_hub' | 'reverse_client' | 'reverse_gateway'} incidentOrigin - Component that observed the cause.
|
|
44
|
+
* @property {'client' | 'gateway'} physicalRole - Physical endpoint role where the cause originated.
|
|
45
|
+
* @property {number} [clientConnectionGeneration] - Named client physical generation.
|
|
46
|
+
* @property {number} [gatewayConnectionGeneration] - Named gateway physical generation.
|
|
47
|
+
* @property {number} fanOutCount - Logical streams affected by the incident.
|
|
48
|
+
* @property {number} resumeSuccessCount - Logical streams resumed so far.
|
|
49
|
+
* @property {number} terminalStreamCount - Logical streams made terminal so far.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Creates one validated disconnect-attribution value.
|
|
54
|
+
* @param fields
|
|
55
|
+
*/
|
|
56
|
+
export function createDisconnectAttribution(/** @type {Record<string, unknown>} */ fields) {
|
|
57
|
+
const value = {
|
|
58
|
+
disconnectIncidentId: fields.disconnectIncidentId ?? randomBytes(16).toString("base64url"),
|
|
59
|
+
disconnectReason: fields.disconnectReason,
|
|
60
|
+
incidentOrigin: fields.incidentOrigin,
|
|
61
|
+
physicalRole: fields.physicalRole,
|
|
62
|
+
...(fields.clientConnectionGeneration === undefined ? {} : {clientConnectionGeneration: fields.clientConnectionGeneration}),
|
|
63
|
+
...(fields.gatewayConnectionGeneration === undefined ? {} : {gatewayConnectionGeneration: fields.gatewayConnectionGeneration}),
|
|
64
|
+
fanOutCount: fields.fanOutCount ?? 0,
|
|
65
|
+
resumeSuccessCount: fields.resumeSuccessCount ?? 0,
|
|
66
|
+
terminalStreamCount: fields.terminalStreamCount ?? 0
|
|
67
|
+
}
|
|
68
|
+
const parsed = parseDisconnectAttribution(value)
|
|
69
|
+
if (parsed === undefined) throw new TypeError("Invalid disconnect attribution")
|
|
70
|
+
return parsed
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Returns a closed, validated attribution value or undefined.
|
|
75
|
+
* @param value
|
|
76
|
+
*/
|
|
77
|
+
export function parseDisconnectAttribution(/** @type {unknown} */ value) {
|
|
78
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
|
|
79
|
+
const candidate = /** @type {Record<string, unknown>} */ (value)
|
|
80
|
+
if (typeof candidate.disconnectIncidentId !== "string" || !INCIDENT_ID.test(candidate.disconnectIncidentId)) return undefined
|
|
81
|
+
if (typeof candidate.disconnectReason !== "string" || !DISCONNECT_REASONS.has(candidate.disconnectReason)) return undefined
|
|
82
|
+
if (typeof candidate.incidentOrigin !== "string" || !INCIDENT_ORIGINS.has(candidate.incidentOrigin)) return undefined
|
|
83
|
+
if (typeof candidate.physicalRole !== "string" || !PHYSICAL_ROLES.has(candidate.physicalRole)) return undefined
|
|
84
|
+
if (!validOptionalGeneration(candidate.clientConnectionGeneration) || !validOptionalGeneration(candidate.gatewayConnectionGeneration)) return undefined
|
|
85
|
+
if (!validCount(candidate.fanOutCount) || !validCount(candidate.resumeSuccessCount) || !validCount(candidate.terminalStreamCount)) return undefined
|
|
86
|
+
if (candidate.physicalRole === "client" && candidate.clientConnectionGeneration === undefined) return undefined
|
|
87
|
+
if (candidate.physicalRole === "gateway" && candidate.gatewayConnectionGeneration === undefined) return undefined
|
|
88
|
+
return /** @type {DisconnectAttribution} */ ({
|
|
89
|
+
disconnectIncidentId: candidate.disconnectIncidentId,
|
|
90
|
+
disconnectReason: /** @type {DisconnectReason} */ (candidate.disconnectReason),
|
|
91
|
+
incidentOrigin: /** @type {'reverse_hub' | 'reverse_client' | 'reverse_gateway'} */ (candidate.incidentOrigin),
|
|
92
|
+
physicalRole: /** @type {'client' | 'gateway'} */ (candidate.physicalRole),
|
|
93
|
+
...(candidate.clientConnectionGeneration === undefined ? {} : {clientConnectionGeneration: candidate.clientConnectionGeneration}),
|
|
94
|
+
...(candidate.gatewayConnectionGeneration === undefined ? {} : {gatewayConnectionGeneration: candidate.gatewayConnectionGeneration}),
|
|
95
|
+
fanOutCount: candidate.fanOutCount,
|
|
96
|
+
resumeSuccessCount: candidate.resumeSuccessCount,
|
|
97
|
+
terminalStreamCount: candidate.terminalStreamCount
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Copies only validated disconnect fields onto a public typed error.
|
|
103
|
+
* @param error
|
|
104
|
+
* @param value
|
|
105
|
+
*/
|
|
106
|
+
export function attachDisconnectAttribution(/** @type {Error} */ error, /** @type {unknown} */ value) {
|
|
107
|
+
const attribution = parseDisconnectAttribution(value)
|
|
108
|
+
if (attribution !== undefined) Object.assign(error, attribution)
|
|
109
|
+
return error
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Removes fields a peer is never authorized to assert.
|
|
114
|
+
* @param value
|
|
115
|
+
*/
|
|
116
|
+
export function sanitizePeerControlDetail(/** @type {unknown} */ value) {
|
|
117
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return {detail: {}, reservedFieldsStripped: 0}
|
|
118
|
+
const candidate = /** @type {Record<string, unknown>} */ (value)
|
|
119
|
+
let reservedFieldsStripped = 0
|
|
120
|
+
for (const field of RESERVED_CONTROL_FIELDS) if (Object.hasOwn(candidate, field)) reservedFieldsStripped += 1
|
|
121
|
+
return {
|
|
122
|
+
detail: typeof candidate.code === "string" && SAFE_CONTROL_CODE.test(candidate.code) ? {code: candidate.code} : {},
|
|
123
|
+
reservedFieldsStripped
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Computes the plan-defined hub-only, role-separated 128-bit fingerprint.
|
|
129
|
+
* @param epochKey
|
|
130
|
+
* @param physicalRole
|
|
131
|
+
* @param connectionId
|
|
132
|
+
*/
|
|
133
|
+
export function connectionFingerprint(/** @type {Buffer | Uint8Array} */ epochKey, /** @type {'client' | 'gateway'} */ physicalRole, /** @type {string} */ connectionId) {
|
|
134
|
+
if (!(epochKey instanceof Uint8Array) || epochKey.byteLength < 32) throw new TypeError("connection fingerprint key must contain at least 32 bytes")
|
|
135
|
+
if (!PHYSICAL_ROLES.has(physicalRole)) throw new TypeError("physicalRole must be client or gateway")
|
|
136
|
+
if (typeof connectionId !== "string" || connectionId.length === 0) throw new TypeError("connectionId must be non-empty")
|
|
137
|
+
return createHmac("sha256", epochKey)
|
|
138
|
+
.update("socketduct-connection-id:v1")
|
|
139
|
+
.update(Buffer.from([0]))
|
|
140
|
+
.update(physicalRole)
|
|
141
|
+
.update(Buffer.from([0]))
|
|
142
|
+
.update(connectionId)
|
|
143
|
+
.digest()
|
|
144
|
+
.subarray(0, 16)
|
|
145
|
+
.toString("hex")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Validates the public epoch label used beside keyed fingerprints.
|
|
150
|
+
* @param value
|
|
151
|
+
*/
|
|
152
|
+
export function validateFingerprintKeyEpoch(/** @type {unknown} */ value) {
|
|
153
|
+
if (typeof value !== "string" || !FINGERPRINT_KEY_EPOCH.test(value)) throw new TypeError("fingerprintKeyEpoch must be 1-32 URL-safe characters")
|
|
154
|
+
return value
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Maps a terminal classification to its first-cause enum when locally initiated. */
|
|
158
|
+
/**
|
|
159
|
+
* @param code
|
|
160
|
+
* @param fallback
|
|
161
|
+
* @returns {DisconnectReason}
|
|
162
|
+
*/
|
|
163
|
+
export function disconnectReasonForCode(/** @type {unknown} */ code, /** @type {DisconnectReason} */ fallback = "unknown_transport_close") {
|
|
164
|
+
if (code === "OUTPUT_BACKPRESSURE_LIMIT") return "output_backpressure_limit"
|
|
165
|
+
if (code === "HEARTBEAT_TIMEOUT" || code === "HANDSHAKE_TIMEOUT" || code === "RESUME_WINDOW_EXPIRED" || code === "SOCKETDUCT_AUTH_TIMEOUT") return "heartbeat_timeout"
|
|
166
|
+
if (code === "GATEWAY_REPLACED" || code === "CONNECTION_REPLACED") return "gateway_replaced"
|
|
167
|
+
if (code === "GATEWAY_SHUTDOWN") return "gateway_shutdown"
|
|
168
|
+
if (code === "HUB_SHUTDOWN") return "hub_shutdown"
|
|
169
|
+
if (typeof code === "string" && (code.includes("PROTOCOL") || code === "STALE_GENERATION" || code === "UNSUPPORTED_FRAME")) return "protocol_violation"
|
|
170
|
+
return fallback
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Returns the safe socket error code for logs without retaining arbitrary text.
|
|
175
|
+
* @param error
|
|
176
|
+
*/
|
|
177
|
+
export function safeSocketCode(/** @type {unknown} */ error) {
|
|
178
|
+
if (error === null || typeof error !== "object") return undefined
|
|
179
|
+
const code = /** @type {{code?: unknown}} */ (error).code
|
|
180
|
+
return typeof code === "string" && /^(?:E[A-Z0-9_]{1,31}|ERR_(?:TLS|SSL)_[A-Z0-9_]{1,48})$/.test(code) ? code : undefined
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** @param {unknown} value - Candidate generation. */
|
|
184
|
+
function validOptionalGeneration(value) {
|
|
185
|
+
return value === undefined || (Number.isSafeInteger(value) && /** @type {number} */ (value) >= 0)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** @param {unknown} value - Candidate count. */
|
|
189
|
+
function validCount(value) {
|
|
190
|
+
return Number.isSafeInteger(value) && /** @type {number} */ (value) >= 0
|
|
191
|
+
}
|
package/src/reverse/gateway.js
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
STABLE_IDENTIFIER,
|
|
26
26
|
writeSocket
|
|
27
27
|
} from "./common.js"
|
|
28
|
+
import {attachDisconnectAttribution, createDisconnectAttribution, parseDisconnectAttribution, safeSocketCode} from "./disconnect-attribution.js"
|
|
28
29
|
|
|
29
30
|
const DEFAULT_STREAM_BUFFER_BYTES = 4 * 1024 * 1024
|
|
30
31
|
const DEFAULT_TOTAL_BUFFER_BYTES = 32 * 1024 * 1024
|
|
@@ -124,6 +125,7 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
124
125
|
/** @type {number} */ #spoolBytes = 0
|
|
125
126
|
/** @type {number} */ #spoolCleanupFailures = 0
|
|
126
127
|
/** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
|
|
128
|
+
/** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */ #disconnectAttribution
|
|
127
129
|
|
|
128
130
|
/** @param {OutboundReverseGatewayOptions} options - Identity, hub, TLS and local policy. */
|
|
129
131
|
constructor(options) {
|
|
@@ -240,7 +242,16 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
240
242
|
let attemptError
|
|
241
243
|
let authenticated = false
|
|
242
244
|
let registered = false
|
|
243
|
-
|
|
245
|
+
/** @type {import('./disconnect-attribution.js').DisconnectReason | undefined} */
|
|
246
|
+
let attemptDisconnectReason
|
|
247
|
+
/** @type {string | undefined} */
|
|
248
|
+
let attemptSafeSocketCode
|
|
249
|
+
/** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */
|
|
250
|
+
let attemptAttribution
|
|
251
|
+
const timer = setTimeout(() => {
|
|
252
|
+
attemptDisconnectReason ??= "heartbeat_timeout"
|
|
253
|
+
physical.destroy(Object.assign(new Error("Reverse gateway connect/auth/register timeout"), {code: "SOCKETDUCT_AUTH_TIMEOUT"}))
|
|
254
|
+
}, this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
|
|
244
255
|
timer.unref?.()
|
|
245
256
|
physical.once("secureConnect", () => {
|
|
246
257
|
if (this.#physical !== physical || physical.destroyed) return
|
|
@@ -260,13 +271,17 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
260
271
|
for (const frame of decoder.push(chunk)) {
|
|
261
272
|
if (frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.GOAWAY) {
|
|
262
273
|
const detail = decodeV3Control(frame.payload)
|
|
263
|
-
|
|
274
|
+
if (frame.type === V3FrameType.GOAWAY) attemptDisconnectReason ??= "peer_goaway"
|
|
275
|
+
const parsedAttribution = parseDisconnectAttribution(detail)
|
|
276
|
+
if (parsedAttribution?.incidentOrigin === "reverse_hub") attemptAttribution = parsedAttribution
|
|
277
|
+
throw connectionError(typeof detail.code === "string" ? detail.code : "CONNECTION_REJECTED", attemptAttribution)
|
|
264
278
|
}
|
|
265
279
|
if (!authenticated) {
|
|
266
280
|
if (frame.type !== V3FrameType.AUTH_OK) throw fatalError("AUTH_NEGOTIATION_FAILED")
|
|
267
281
|
const detail = decodeV3Control(frame.payload)
|
|
268
282
|
if (detail.version !== 3 || detail.role !== "gateway" || detail.connectionId !== this.#options.connectionId || detail.generation !== generation || detail.compression !== ZSTD_CODEC || !validPeerWindow(detail.initialWindow)) throw fatalError("AUTH_NEGOTIATION_FAILED")
|
|
269
283
|
authenticated = true
|
|
284
|
+
this.#disconnectAttribution = undefined
|
|
270
285
|
this.#codec = ZSTD_CODEC
|
|
271
286
|
this.#sendWindowLimit = Math.min(this.#initialWindow(), detail.initialWindow)
|
|
272
287
|
this.#setState("registering")
|
|
@@ -298,13 +313,20 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
298
313
|
}
|
|
299
314
|
}).catch((error) => {
|
|
300
315
|
attemptError = asError(error)
|
|
316
|
+
attemptDisconnectReason ??= "protocol_violation"
|
|
301
317
|
if (/** @type {{fatal?: unknown}} */ (attemptError).fatal === true) this.#fatal = true
|
|
302
318
|
physical.destroy(attemptError)
|
|
303
319
|
}).finally(() => {
|
|
304
320
|
if (!physical.destroyed) physical.resume()
|
|
305
321
|
})
|
|
306
322
|
})
|
|
307
|
-
physical.once("
|
|
323
|
+
physical.once("end", () => { attemptDisconnectReason ??= "remote_eof" })
|
|
324
|
+
physical.once("error", (error) => {
|
|
325
|
+
attemptError = error
|
|
326
|
+
attemptDisconnectReason ??= "remote_socket_error"
|
|
327
|
+
attemptSafeSocketCode ??= safeSocketCode(error)
|
|
328
|
+
if (isFatalTransportError(error)) this.#fatal = true
|
|
329
|
+
})
|
|
308
330
|
physical.once("close", () => {
|
|
309
331
|
clearTimeout(timer)
|
|
310
332
|
void queue.finally(() => {
|
|
@@ -315,11 +337,28 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
315
337
|
this.#resumeResults = []
|
|
316
338
|
this.#resumeNextPage = 0
|
|
317
339
|
if (!this.#closing && !this.#fatal) this.#setState("reconnecting")
|
|
340
|
+
const attribution = attemptAttribution ?? this.#disconnectAttribution ?? createDisconnectAttribution({
|
|
341
|
+
disconnectReason: attemptDisconnectReason ?? "unknown_transport_close",
|
|
342
|
+
incidentOrigin: "reverse_gateway",
|
|
343
|
+
physicalRole: "gateway",
|
|
344
|
+
gatewayConnectionGeneration: generation,
|
|
345
|
+
fanOutCount: this.#streams.size,
|
|
346
|
+
resumeSuccessCount: 0,
|
|
347
|
+
terminalStreamCount: 0
|
|
348
|
+
})
|
|
349
|
+
this.#disconnectAttribution = attribution
|
|
318
350
|
if (!this.#closing) {
|
|
351
|
+
logDiagnostic(this.#options.logger, "reverse_gateway.physical_detached", {
|
|
352
|
+
gatewayId: this.#options.gatewayId,
|
|
353
|
+
...attribution,
|
|
354
|
+
...(attemptSafeSocketCode === undefined ? {} : {safeSocketCode: attemptSafeSocketCode})
|
|
355
|
+
})
|
|
319
356
|
logDiagnostic(this.#options.logger, "transport.disconnected", {
|
|
320
|
-
generation,
|
|
357
|
+
gatewayConnectionGeneration: generation,
|
|
321
358
|
gatewayId: this.#options.gatewayId,
|
|
322
359
|
virtualStreams: this.#streams.size,
|
|
360
|
+
...attribution,
|
|
361
|
+
...(attemptSafeSocketCode === undefined ? {} : {safeSocketCode: attemptSafeSocketCode}),
|
|
323
362
|
...errorDiagnostics(attemptError, "TRANSPORT_CLOSED")
|
|
324
363
|
})
|
|
325
364
|
}
|
|
@@ -696,7 +735,16 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
696
735
|
const delta = sequence - stream.sendAck
|
|
697
736
|
stream.sendAck = sequence
|
|
698
737
|
let released = 0
|
|
699
|
-
stream.replay = stream.replay.
|
|
738
|
+
stream.replay = stream.replay.flatMap((chunk) => {
|
|
739
|
+
const start = chunk.sequence - chunk.data.length
|
|
740
|
+
if (chunk.sequence <= sequence) { released += chunk.data.length; return [] }
|
|
741
|
+
if (start < sequence) {
|
|
742
|
+
const acknowledgedBytes = sequence - start
|
|
743
|
+
released += acknowledgedBytes
|
|
744
|
+
return [{...chunk, data: Buffer.from(chunk.data.subarray(acknowledgedBytes))}]
|
|
745
|
+
}
|
|
746
|
+
return [chunk]
|
|
747
|
+
})
|
|
700
748
|
stream.bufferedBytes = Math.max(0, stream.bufferedBytes - released)
|
|
701
749
|
this.#bufferedBytes = Math.max(0, this.#bufferedBytes - released)
|
|
702
750
|
stream.sendWindow = Math.min(stream.sendWindowLimit, stream.sendWindow + delta)
|
|
@@ -752,7 +800,18 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
752
800
|
/** @param {number} type - Type. @param {number} streamId - Stream. @param {number} sequence - Sequence. @param {Record<string, unknown>} body - Body. */
|
|
753
801
|
async #sendControl(type, streamId, sequence, body) {
|
|
754
802
|
const physical = this.#physical
|
|
755
|
-
if (physical === undefined || physical.destroyed)
|
|
803
|
+
if (physical === undefined || physical.destroyed) {
|
|
804
|
+
this.#disconnectAttribution ??= createDisconnectAttribution({
|
|
805
|
+
disconnectReason: "send_after_transport_loss",
|
|
806
|
+
incidentOrigin: "reverse_gateway",
|
|
807
|
+
physicalRole: "gateway",
|
|
808
|
+
gatewayConnectionGeneration: this.#generation,
|
|
809
|
+
fanOutCount: this.#streams.size,
|
|
810
|
+
resumeSuccessCount: 0,
|
|
811
|
+
terminalStreamCount: 0
|
|
812
|
+
})
|
|
813
|
+
return false
|
|
814
|
+
}
|
|
756
815
|
return await writeSocket(physical, encodeV3Frame({type, streamId, sequence}, encodeV3Control(body)))
|
|
757
816
|
}
|
|
758
817
|
|
|
@@ -865,9 +924,9 @@ function fatalError(/** @type {string} */ code) {
|
|
|
865
924
|
return error
|
|
866
925
|
}
|
|
867
926
|
|
|
868
|
-
function connectionError(/** @type {string} */ code) {
|
|
869
|
-
if (code === "HUB_SHUTDOWN" || code === "CLIENT_DISCONNECTED" || code === "GATEWAY_DISCONNECTED") return Object.assign(new Error("Reverse gateway hub is temporarily unavailable"), {code, fatal: false})
|
|
870
|
-
return fatalError(code)
|
|
927
|
+
function connectionError(/** @type {string} */ code, /** @type {unknown} */ attribution = undefined) {
|
|
928
|
+
if (code === "HUB_SHUTDOWN" || code === "CLIENT_DISCONNECTED" || code === "GATEWAY_DISCONNECTED") return attachDisconnectAttribution(Object.assign(new Error("Reverse gateway hub is temporarily unavailable"), {code, fatal: false}), attribution)
|
|
929
|
+
return attachDisconnectAttribution(fatalError(code), attribution)
|
|
871
930
|
}
|
|
872
931
|
|
|
873
932
|
/** @param {unknown} value - Advertised peer receive window. @returns {value is number} Whether valid. */
|