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/hub.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import {readFileSync} from "node:fs"
|
|
3
3
|
import {createServer as createTlsServer} from "node:tls"
|
|
4
4
|
|
|
5
|
+
import {errorDiagnostics, logDiagnostic} from "../logging/structured-logger.js"
|
|
5
6
|
import {availableCodecs, negotiateCodec, zstdAvailable} from "../protocol/compression.js"
|
|
6
7
|
import {decodeV3Data, encodeV3Data, AdaptiveCompressor} from "../protocol/v3-compression.js"
|
|
7
8
|
import {decodeV3Control, encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../protocol/v3-frame.js"
|
|
@@ -22,6 +23,15 @@ import {
|
|
|
22
23
|
STABLE_IDENTIFIER,
|
|
23
24
|
writeSocket
|
|
24
25
|
} from "./common.js"
|
|
26
|
+
import {
|
|
27
|
+
connectionFingerprint,
|
|
28
|
+
createDisconnectAttribution,
|
|
29
|
+
disconnectReasonForCode,
|
|
30
|
+
parseDisconnectAttribution,
|
|
31
|
+
safeSocketCode,
|
|
32
|
+
sanitizePeerControlDetail,
|
|
33
|
+
validateFingerprintKeyEpoch
|
|
34
|
+
} from "./disconnect-attribution.js"
|
|
25
35
|
|
|
26
36
|
const DEFAULT_OUTPUT_BUFFER_BYTES = 32 * 1024 * 1024
|
|
27
37
|
const DEFAULT_RESUME_MS = 60_000
|
|
@@ -52,6 +62,8 @@ const MANIFEST_PAGE_STREAMS = 64
|
|
|
52
62
|
* @property {number} [maxOutputBytesPerConnection] - Per-physical queued hub output bound.
|
|
53
63
|
* @property {number} [resumeWindowMs] - Detached reverse-stream retention window.
|
|
54
64
|
* @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - zstd safety bounds.
|
|
65
|
+
* @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured logger.
|
|
66
|
+
* @property {{key: Buffer | Uint8Array, keyEpoch: string}} [connectionFingerprint] - Optional hub-only correlation key and public epoch.
|
|
55
67
|
* @typedef {object} HubPhysical
|
|
56
68
|
* @property {import('node:tls').TLSSocket} socket
|
|
57
69
|
* @property {Principal | undefined} principal
|
|
@@ -78,6 +90,10 @@ const MANIFEST_PAGE_STREAMS = 64
|
|
|
78
90
|
* @property {HubOutputItem[]} outputQueue
|
|
79
91
|
* @property {number} outputBytes
|
|
80
92
|
* @property {boolean} outputWriting
|
|
93
|
+
* @property {import('./disconnect-attribution.js').DisconnectReason | undefined} firstDisconnectReason
|
|
94
|
+
* @property {import('./disconnect-attribution.js').DisconnectAttribution | undefined} disconnectAttribution
|
|
95
|
+
* @property {string | undefined} connectionFingerprint
|
|
96
|
+
* @property {string | undefined} safeSocketCode
|
|
81
97
|
* @typedef {object} HubOutputItem
|
|
82
98
|
* @property {Buffer} bytes
|
|
83
99
|
* @property {(delivered: boolean) => void} resolve
|
|
@@ -119,6 +135,7 @@ const MANIFEST_PAGE_STREAMS = 64
|
|
|
119
135
|
* @property {{generation: number, clientToGatewaySequence: number, clientToGatewayAck: number, gatewayToClientSequence: number, gatewayToClientAck: number, localEnded: boolean, remoteEnded: boolean} | undefined} clientResume
|
|
120
136
|
* @property {{generation: number, clientToGatewaySequence: number, clientToGatewayAck: number, gatewayToClientSequence: number, gatewayToClientAck: number, localEnded: boolean, remoteEnded: boolean} | undefined} gatewayResume
|
|
121
137
|
* @property {string | undefined} reconciledResume
|
|
138
|
+
* @property {import('./disconnect-attribution.js').DisconnectAttribution | undefined} disconnectAttribution
|
|
122
139
|
*/
|
|
123
140
|
|
|
124
141
|
/** Starts the Server3 reverse hub. */
|
|
@@ -141,6 +158,8 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
141
158
|
const tcpSockets = new Set()
|
|
142
159
|
/** @type {Map<string, {socket: import('node:net').Socket, timer: ReturnType<typeof setTimeout>}>} */
|
|
143
160
|
const pendingTls = new Map()
|
|
161
|
+
/** @type {Map<string, {fanOutCount: number, resumeSuccessCount: number, terminalStreamCount: number}>} */
|
|
162
|
+
const incidents = new Map()
|
|
144
163
|
let bridges = 0
|
|
145
164
|
let physicalAccepts = 0
|
|
146
165
|
let gatewayRegistrations = 0
|
|
@@ -209,7 +228,11 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
209
228
|
clearHandshake: undefined,
|
|
210
229
|
outputQueue: [],
|
|
211
230
|
outputBytes: 0,
|
|
212
|
-
outputWriting: false
|
|
231
|
+
outputWriting: false,
|
|
232
|
+
firstDisconnectReason: undefined,
|
|
233
|
+
disconnectAttribution: undefined,
|
|
234
|
+
connectionFingerprint: undefined,
|
|
235
|
+
safeSocketCode: undefined
|
|
213
236
|
}
|
|
214
237
|
physicals.add(physical)
|
|
215
238
|
const decoder = new V3FrameDecoder({maxFrameBytes: Math.max(MAX_CONTROL_BYTES, maxFrameBytes + 4), maxBufferedBytes: Math.max(MAX_CONTROL_BYTES, maxFrameBytes + 4) * 2})
|
|
@@ -232,7 +255,11 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
232
255
|
if (!socket.destroyed && !physical.closing) socket.resume()
|
|
233
256
|
})
|
|
234
257
|
})
|
|
235
|
-
socket.
|
|
258
|
+
socket.once("end", () => { physical.firstDisconnectReason ??= "remote_eof" })
|
|
259
|
+
socket.on("error", (error) => {
|
|
260
|
+
physical.firstDisconnectReason ??= "remote_socket_error"
|
|
261
|
+
physical.safeSocketCode ??= safeSocketCode(error)
|
|
262
|
+
})
|
|
236
263
|
socket.once("close", () => {
|
|
237
264
|
clearTimeout(handshakeTimer)
|
|
238
265
|
void queue.finally(() => detachPhysical(physical, physical.detachCode ?? "TRANSPORT_CLOSED"))
|
|
@@ -257,6 +284,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
257
284
|
physical.principal = principal
|
|
258
285
|
physical.connectionId = auth.connectionId
|
|
259
286
|
physical.generation = /** @type {number} */ (auth.generation)
|
|
287
|
+
if (options.connectionFingerprint !== undefined) physical.connectionFingerprint = connectionFingerprint(options.connectionFingerprint.key, physical.role, physical.connectionId)
|
|
260
288
|
physical.codec = negotiateCodec(auth.compression, availableCodecs())
|
|
261
289
|
if (auth.role === "client") authenticateClientOwner(physical, maxIdentities)
|
|
262
290
|
physical.authenticated = true
|
|
@@ -277,6 +305,15 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
277
305
|
}
|
|
278
306
|
if (frame.type === V3FrameType.PONG) return
|
|
279
307
|
if (frame.type === V3FrameType.GOAWAY) {
|
|
308
|
+
const detail = decodeV3Control(frame.payload)
|
|
309
|
+
const sanitized = sanitizePeerControlDetail(detail)
|
|
310
|
+
if (sanitized.reservedFieldsStripped > 0) logDiagnostic(options.logger, "reverse_hub.reserved_control_field_stripped", {
|
|
311
|
+
physicalRole: physical.role,
|
|
312
|
+
...(physical.role === "client" ? {clientConnectionGeneration: physical.generation} : {gatewayConnectionGeneration: physical.generation}),
|
|
313
|
+
reservedFieldsStripped: sanitized.reservedFieldsStripped
|
|
314
|
+
})
|
|
315
|
+
physical.firstDisconnectReason ??= "peer_goaway"
|
|
316
|
+
if (typeof sanitized.detail.code === "string") physical.detachCode ??= sanitized.detail.code
|
|
280
317
|
physical.retainOnDetach = false
|
|
281
318
|
physical.socket.end()
|
|
282
319
|
return
|
|
@@ -447,7 +484,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
447
484
|
continue
|
|
448
485
|
}
|
|
449
486
|
if (bridge.identifier !== item.identifier) {
|
|
450
|
-
closeBridge(bridge, "RESUME_IDENTITY_MISMATCH", physical)
|
|
487
|
+
await closeBridge(bridge, "RESUME_IDENTITY_MISMATCH", physical)
|
|
451
488
|
physical.resumeEntries.push({streamId: item.streamId, bridge: undefined, code: "RESUME_IDENTITY_MISMATCH"})
|
|
452
489
|
continue
|
|
453
490
|
}
|
|
@@ -464,7 +501,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
464
501
|
}
|
|
465
502
|
for (const bridge of [...physical.streams.values()]) {
|
|
466
503
|
const streamId = physical.role === "client" ? bridge.clientStreamId : bridge.gatewayStreamId
|
|
467
|
-
if (!presented.has(streamId)) closeBridge(bridge, "RESUME_STREAM_OMITTED", physical)
|
|
504
|
+
if (!presented.has(streamId)) await closeBridge(bridge, "RESUME_STREAM_OMITTED", physical)
|
|
468
505
|
}
|
|
469
506
|
physical.resumeItems = []
|
|
470
507
|
physical.resumeFinal = true
|
|
@@ -507,7 +544,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
507
544
|
try {
|
|
508
545
|
reconcileBridge(bridge)
|
|
509
546
|
} catch {
|
|
510
|
-
closeBridge(bridge, "RESUME_STATE_MISMATCH")
|
|
547
|
+
await closeBridge(bridge, "RESUME_STATE_MISMATCH")
|
|
511
548
|
results.push({streamId: entry.streamId, status: "reset", code: "RESUME_STATE_MISMATCH"})
|
|
512
549
|
continue
|
|
513
550
|
}
|
|
@@ -575,6 +612,18 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
575
612
|
bridge.clientToGatewayCompressor = new AdaptiveCompressor()
|
|
576
613
|
bridge.gatewayToClientCompressor = new AdaptiveCompressor()
|
|
577
614
|
bridge.reconciledResume = key
|
|
615
|
+
if (bridge.disconnectAttribution !== undefined) {
|
|
616
|
+
const attribution = bridge.disconnectAttribution
|
|
617
|
+
const incident = incidentState(attribution)
|
|
618
|
+
incident.resumeSuccessCount += 1
|
|
619
|
+
logDiagnostic(options.logger, "reverse_hub.streams_resumed", {
|
|
620
|
+
...currentIncidentAttribution(attribution),
|
|
621
|
+
gatewayId: bridge.gatewayOwner.gatewayId,
|
|
622
|
+
...(bridge.client.routeClass === undefined ? {} : {routeClass: bridge.client.routeClass})
|
|
623
|
+
})
|
|
624
|
+
bridge.disconnectAttribution = undefined
|
|
625
|
+
releaseCompletedIncident(attribution)
|
|
626
|
+
}
|
|
578
627
|
}
|
|
579
628
|
|
|
580
629
|
/** @param {Record<string, unknown>} value - Resume manifest entry. */
|
|
@@ -668,7 +717,8 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
668
717
|
gatewayToClientCompressor: new AdaptiveCompressor(),
|
|
669
718
|
clientResume: undefined,
|
|
670
719
|
gatewayResume: undefined,
|
|
671
|
-
reconciledResume: undefined
|
|
720
|
+
reconciledResume: undefined,
|
|
721
|
+
disconnectAttribution: undefined
|
|
672
722
|
}
|
|
673
723
|
clientOwner.streams.set(frame.streamId, bridge)
|
|
674
724
|
gatewayOwner.streams.set(gatewayStreamId, bridge)
|
|
@@ -711,7 +761,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
711
761
|
}
|
|
712
762
|
if (bridge.state === "closing") {
|
|
713
763
|
if (frame.type === V3FrameType.DATA || frame.type === V3FrameType.END) {
|
|
714
|
-
closeBridge(bridge, "STREAM_CLOSING")
|
|
764
|
+
await closeBridge(bridge, "STREAM_CLOSING")
|
|
715
765
|
return
|
|
716
766
|
}
|
|
717
767
|
if (frame.type !== V3FrameType.CLOSE && frame.type !== V3FrameType.RESET) throw protocolError("STREAM_CLOSING")
|
|
@@ -742,10 +792,32 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
742
792
|
if (frame.type === V3FrameType.CLOSE || frame.type === V3FrameType.RESET) {
|
|
743
793
|
const expected = fromClient ? bridge.clientToGatewaySequence : bridge.gatewayToClientSequence
|
|
744
794
|
if ((frame.type === V3FrameType.CLOSE && frame.sequence !== expected) || (frame.type === V3FrameType.RESET && frame.sequence !== 0)) throw protocolError("INVALID_STREAM_CLOSE")
|
|
745
|
-
const
|
|
795
|
+
const peerDetail = frame.payload.length === 0 ? {} : decodeV3Control(frame.payload)
|
|
796
|
+
const sanitized = sanitizePeerControlDetail(peerDetail)
|
|
797
|
+
if (sanitized.reservedFieldsStripped > 0) logDiagnostic(options.logger, "reverse_hub.reserved_control_field_stripped", {
|
|
798
|
+
physicalRole: source.role,
|
|
799
|
+
...(source.role === "client" ? {clientConnectionGeneration: source.generation} : {gatewayConnectionGeneration: source.generation}),
|
|
800
|
+
reservedFieldsStripped: sanitized.reservedFieldsStripped
|
|
801
|
+
})
|
|
802
|
+
let attribution
|
|
803
|
+
if (frame.type === V3FrameType.RESET && bridge.disconnectAttribution !== undefined) {
|
|
804
|
+
incidentState(bridge.disconnectAttribution).terminalStreamCount += 1
|
|
805
|
+
attribution = currentIncidentAttribution(bridge.disconnectAttribution)
|
|
806
|
+
}
|
|
807
|
+
const detail = attribution === undefined ? sanitized.detail : {...sanitized.detail, ...attribution}
|
|
746
808
|
forwardControl(destination, frame.type, destinationStreamId, frame.sequence, detail)
|
|
747
|
-
if (frame.type === V3FrameType.RESET || fromClient)
|
|
748
|
-
|
|
809
|
+
if (frame.type === V3FrameType.RESET || fromClient) {
|
|
810
|
+
removeBridge(bridge)
|
|
811
|
+
if (attribution !== undefined && bridge.disconnectAttribution !== undefined) {
|
|
812
|
+
logDiagnostic(options.logger, "reverse_hub.streams_reset", {
|
|
813
|
+
...attribution,
|
|
814
|
+
gatewayId: bridge.gatewayOwner.gatewayId,
|
|
815
|
+
...(bridge.client.routeClass === undefined ? {} : {routeClass: bridge.client.routeClass}),
|
|
816
|
+
...errorDiagnostics(sanitized.detail, "STREAM_RESET")
|
|
817
|
+
})
|
|
818
|
+
releaseCompletedIncident(bridge.disconnectAttribution)
|
|
819
|
+
}
|
|
820
|
+
} else bridge.state = "closing"
|
|
749
821
|
return
|
|
750
822
|
}
|
|
751
823
|
throw protocolError("UNSUPPORTED_FRAME")
|
|
@@ -768,44 +840,76 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
768
840
|
forwardOutput(destination, encodeV3Frame({type, streamId, sequence}, encodeV3Control(value)))
|
|
769
841
|
}
|
|
770
842
|
|
|
771
|
-
/** @param {Bridge} bridge - Stream. @param {string} code - Reset code. @param {HubPhysical} [excluded] - Already-dead peer. */
|
|
843
|
+
/** @param {Bridge} bridge - Stream. @param {string} code - Reset code. @param {HubPhysical} [excluded] - Already-dead peer. @returns {Promise<void>} Terminal control delivery. */
|
|
772
844
|
function closeBridge(bridge, code, excluded) {
|
|
773
|
-
|
|
774
|
-
|
|
845
|
+
const owned = bridgeStillOwned(bridge)
|
|
846
|
+
let attribution
|
|
847
|
+
if (owned && bridge.disconnectAttribution !== undefined) {
|
|
848
|
+
const incident = incidentState(bridge.disconnectAttribution)
|
|
849
|
+
incident.terminalStreamCount += 1
|
|
850
|
+
attribution = currentIncidentAttribution(bridge.disconnectAttribution)
|
|
851
|
+
}
|
|
852
|
+
const detail = {code, ...(attribution ?? {})}
|
|
853
|
+
const deliveries = []
|
|
854
|
+
if (bridge.client !== excluded && !bridge.client.socket.destroyed) deliveries.push(sendControl(bridge.client, V3FrameType.RESET, bridge.clientStreamId, 0, detail))
|
|
855
|
+
if (bridge.gateway !== excluded && !bridge.gateway.socket.destroyed) deliveries.push(sendControl(bridge.gateway, V3FrameType.RESET, bridge.gatewayStreamId, 0, detail))
|
|
775
856
|
removeBridge(bridge)
|
|
857
|
+
if (attribution !== undefined) logDiagnostic(options.logger, "reverse_hub.streams_reset", {
|
|
858
|
+
...attribution,
|
|
859
|
+
gatewayId: bridge.gatewayOwner.gatewayId,
|
|
860
|
+
...(bridge.client.routeClass === undefined ? {} : {routeClass: bridge.client.routeClass}),
|
|
861
|
+
...errorDiagnostics({code})
|
|
862
|
+
})
|
|
863
|
+
if (bridge.disconnectAttribution !== undefined) releaseCompletedIncident(bridge.disconnectAttribution)
|
|
864
|
+
return Promise.all(deliveries).then(() => undefined)
|
|
776
865
|
}
|
|
777
866
|
|
|
778
|
-
/** @param {HubPhysical} physical - Connection. @param {string} code - Reason. @param {boolean} [retainStreams] - Preserve logical streams for paired resume. */
|
|
779
|
-
function terminate(physical, code, retainStreams = false) {
|
|
867
|
+
/** @param {HubPhysical} physical - Connection. @param {string} code - Reason. @param {boolean} [retainStreams] - Preserve logical streams for paired resume. @param {import('./disconnect-attribution.js').DisconnectAttribution} [attribution] - Propagated first-cause incident. */
|
|
868
|
+
function terminate(physical, code, retainStreams = false, attribution = undefined) {
|
|
780
869
|
if (physical.closing) return
|
|
781
870
|
physical.closing = true
|
|
782
871
|
physical.retainOnDetach = retainStreams
|
|
783
872
|
physical.detachCode = code
|
|
873
|
+
const disconnectAttribution = ensurePhysicalAttribution(physical, disconnectReasonForCode(code), attribution)
|
|
784
874
|
const forceTimer = setTimeout(() => physical.socket.destroy(), 1_000)
|
|
785
875
|
forceTimer.unref?.()
|
|
786
|
-
void sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code}, true).finally(() => {
|
|
876
|
+
void sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code, ...(disconnectAttribution === undefined ? {} : currentIncidentAttribution(disconnectAttribution))}, true).finally(() => {
|
|
787
877
|
clearTimeout(forceTimer)
|
|
788
878
|
physical.socket.destroy()
|
|
789
879
|
})
|
|
790
880
|
}
|
|
791
881
|
|
|
792
882
|
/** @param {HubPhysical} physical - Closed connection. @param {string} code - Stream reset. */
|
|
793
|
-
function detachPhysical(physical, code) {
|
|
883
|
+
async function detachPhysical(physical, code) {
|
|
794
884
|
if (!physicals.delete(physical)) return
|
|
795
885
|
physical.closing = true
|
|
886
|
+
const attribution = ensurePhysicalAttribution(physical, physical.firstDisconnectReason ?? disconnectReasonForCode(code))
|
|
887
|
+
if (attribution !== undefined) logDiagnostic(options.logger, "reverse_hub.physical_detached", {
|
|
888
|
+
...currentIncidentAttribution(attribution),
|
|
889
|
+
...(physical.connectionFingerprint === undefined || attribution.physicalRole !== physical.role ? {} : {connectionFingerprint: physical.connectionFingerprint, fingerprintKeyEpoch: options.connectionFingerprint?.keyEpoch}),
|
|
890
|
+
...(physical.safeSocketCode === undefined ? {} : {safeSocketCode: physical.safeSocketCode})
|
|
891
|
+
})
|
|
796
892
|
clearResumeTimeout(physical)
|
|
797
893
|
discardQueuedOutput(physical)
|
|
798
894
|
if (physical.gatewayId !== undefined) {
|
|
799
895
|
const owner = gateways.get(physical.gatewayId)
|
|
800
896
|
if (physical.role === "gateway" && owner?.physical === physical) {
|
|
801
897
|
owner.physical = undefined
|
|
898
|
+
if (attribution !== undefined) for (const bridge of owner.streams.values()) assignBridgeAttribution(bridge, attribution)
|
|
802
899
|
if (!physical.retainOnDetach || closing) {
|
|
803
|
-
for (const bridge of [...owner.streams.values()]) closeBridge(bridge, code, physical)
|
|
900
|
+
for (const bridge of [...owner.streams.values()]) await closeBridge(bridge, code, physical)
|
|
804
901
|
gateways.delete(owner.gatewayId)
|
|
805
902
|
} else {
|
|
903
|
+
if (attribution !== undefined) {
|
|
904
|
+
logDiagnostic(options.logger, "reverse_hub.streams_interrupted", {...currentIncidentAttribution(attribution), gatewayId: owner.gatewayId})
|
|
905
|
+
}
|
|
806
906
|
scheduleOwnerExpiry(owner, "gateway")
|
|
807
907
|
const clientsToReconnect = new Set([...owner.streams.values()].map((bridge) => bridge.clientOwner.physical).filter((peer) => peer !== undefined))
|
|
808
|
-
for (const client of clientsToReconnect)
|
|
908
|
+
for (const client of clientsToReconnect) {
|
|
909
|
+
const peer = /** @type {HubPhysical} */ (client)
|
|
910
|
+
const propagated = attribution === undefined ? undefined : createDisconnectAttribution({...currentIncidentAttribution(attribution), clientConnectionGeneration: peer.generation})
|
|
911
|
+
terminate(peer, "GATEWAY_DISCONNECTED", true, propagated)
|
|
912
|
+
}
|
|
809
913
|
}
|
|
810
914
|
}
|
|
811
915
|
}
|
|
@@ -813,37 +917,109 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
813
917
|
const owner = clients.get(physical.connectionId)
|
|
814
918
|
if (owner?.physical === physical) {
|
|
815
919
|
owner.physical = undefined
|
|
920
|
+
if (attribution !== undefined) for (const bridge of owner.streams.values()) assignBridgeAttribution(bridge, attribution)
|
|
816
921
|
if (!physical.retainOnDetach || closing) {
|
|
817
|
-
for (const bridge of [...owner.streams.values()]) closeBridge(bridge, code, physical)
|
|
922
|
+
for (const bridge of [...owner.streams.values()]) await closeBridge(bridge, code, physical)
|
|
818
923
|
clients.delete(owner.connectionId)
|
|
819
924
|
} else {
|
|
925
|
+
if (attribution !== undefined) {
|
|
926
|
+
logDiagnostic(options.logger, "reverse_hub.streams_interrupted", {
|
|
927
|
+
...currentIncidentAttribution(attribution),
|
|
928
|
+
...(physical.gatewayId === undefined ? {} : {gatewayId: physical.gatewayId}),
|
|
929
|
+
...(physical.routeClass === undefined ? {} : {routeClass: physical.routeClass})
|
|
930
|
+
})
|
|
931
|
+
}
|
|
820
932
|
scheduleOwnerExpiry(owner, "client")
|
|
821
933
|
const gatewaysToReconnect = new Set([...owner.streams.values()].map((bridge) => bridge.gatewayOwner.physical).filter((peer) => peer !== undefined))
|
|
822
|
-
for (const gateway of gatewaysToReconnect)
|
|
934
|
+
for (const gateway of gatewaysToReconnect) {
|
|
935
|
+
const peer = /** @type {HubPhysical} */ (gateway)
|
|
936
|
+
const propagated = attribution === undefined ? undefined : createDisconnectAttribution({...currentIncidentAttribution(attribution), gatewayConnectionGeneration: peer.generation})
|
|
937
|
+
terminate(peer, "CLIENT_DISCONNECTED", true, propagated)
|
|
938
|
+
}
|
|
823
939
|
}
|
|
824
940
|
}
|
|
825
941
|
}
|
|
942
|
+
if (attribution !== undefined && (!physical.retainOnDetach || incidentState(attribution).fanOutCount === 0)) incidents.delete(attribution.disconnectIncidentId)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** @param {HubPhysical} physical - Connection. @param {import('./disconnect-attribution.js').DisconnectReason} reason - Physical cause. @param {import('./disconnect-attribution.js').DisconnectAttribution | undefined} [propagated] - Existing incident. */
|
|
946
|
+
function ensurePhysicalAttribution(physical, reason, propagated = undefined) {
|
|
947
|
+
if (physical.disconnectAttribution !== undefined) return physical.disconnectAttribution
|
|
948
|
+
if (physical.role === undefined) return undefined
|
|
949
|
+
const source = propagated === undefined ? undefined : parseDisconnectAttribution(propagated)
|
|
950
|
+
physical.disconnectAttribution = createDisconnectAttribution({
|
|
951
|
+
...(source ?? {}),
|
|
952
|
+
disconnectReason: source?.disconnectReason ?? reason,
|
|
953
|
+
incidentOrigin: source?.incidentOrigin ?? "reverse_hub",
|
|
954
|
+
physicalRole: source?.physicalRole ?? physical.role,
|
|
955
|
+
...(physical.role === "client" ? {clientConnectionGeneration: physical.generation} : {gatewayConnectionGeneration: physical.generation}),
|
|
956
|
+
fanOutCount: source?.fanOutCount ?? physical.streams.size,
|
|
957
|
+
resumeSuccessCount: source?.resumeSuccessCount ?? 0,
|
|
958
|
+
terminalStreamCount: source?.terminalStreamCount ?? 0
|
|
959
|
+
})
|
|
960
|
+
incidentState(physical.disconnectAttribution)
|
|
961
|
+
return physical.disconnectAttribution
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Preserves the first incident while completing both endpoint generations. */
|
|
965
|
+
function assignBridgeAttribution(/** @type {Bridge} */ bridge, /** @type {import('./disconnect-attribution.js').DisconnectAttribution} */ attribution) {
|
|
966
|
+
bridge.disconnectAttribution ??= createDisconnectAttribution({
|
|
967
|
+
...currentIncidentAttribution(attribution),
|
|
968
|
+
clientConnectionGeneration: bridge.client.generation,
|
|
969
|
+
gatewayConnectionGeneration: bridge.gateway.generation
|
|
970
|
+
})
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** @param {import('./disconnect-attribution.js').DisconnectAttribution} attribution - Incident. @returns {{fanOutCount: number, resumeSuccessCount: number, terminalStreamCount: number}} */
|
|
974
|
+
function incidentState(attribution) {
|
|
975
|
+
let state = incidents.get(attribution.disconnectIncidentId)
|
|
976
|
+
if (state === undefined) {
|
|
977
|
+
state = {fanOutCount: attribution.fanOutCount, resumeSuccessCount: attribution.resumeSuccessCount, terminalStreamCount: attribution.terminalStreamCount}
|
|
978
|
+
incidents.set(attribution.disconnectIncidentId, state)
|
|
979
|
+
} else {
|
|
980
|
+
state.fanOutCount = Math.max(state.fanOutCount, attribution.fanOutCount)
|
|
981
|
+
state.resumeSuccessCount = Math.max(state.resumeSuccessCount, attribution.resumeSuccessCount)
|
|
982
|
+
state.terminalStreamCount = Math.max(state.terminalStreamCount, attribution.terminalStreamCount)
|
|
983
|
+
}
|
|
984
|
+
return state
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** @param {import('./disconnect-attribution.js').DisconnectAttribution} attribution - Incident. */
|
|
988
|
+
function currentIncidentAttribution(attribution) {
|
|
989
|
+
const state = incidentState(attribution)
|
|
990
|
+
return createDisconnectAttribution({...attribution, ...state})
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/** @param {import('./disconnect-attribution.js').DisconnectAttribution} attribution - Incident. */
|
|
994
|
+
function releaseCompletedIncident(attribution) {
|
|
995
|
+
const state = incidentState(attribution)
|
|
996
|
+
if (state.resumeSuccessCount + state.terminalStreamCount >= state.fanOutCount) incidents.delete(attribution.disconnectIncidentId)
|
|
826
997
|
}
|
|
827
998
|
|
|
828
999
|
/** @param {GatewayOwner | ClientOwner} owner - Detached identity. @param {'gateway' | 'client'} role - Owner role. */
|
|
829
1000
|
function scheduleOwnerExpiry(owner, role) {
|
|
830
1001
|
if (owner.expiryTimer !== undefined) clearTimeout(owner.expiryTimer)
|
|
831
1002
|
owner.expiryTimer = setTimeout(() => {
|
|
832
|
-
owner
|
|
833
|
-
if (owner.physical !== undefined) return
|
|
834
|
-
for (const bridge of [...owner.streams.values()]) closeBridge(bridge, "RESUME_WINDOW_EXPIRED")
|
|
835
|
-
if (role === "gateway") {
|
|
836
|
-
const gatewayOwner = /** @type {GatewayOwner} */ (owner)
|
|
837
|
-
if (gateways.get(gatewayOwner.gatewayId) === gatewayOwner) gateways.delete(gatewayOwner.gatewayId)
|
|
838
|
-
} else {
|
|
839
|
-
const clientOwner = /** @type {ClientOwner} */ (owner)
|
|
840
|
-
if (clients.get(clientOwner.connectionId) === clientOwner) clients.delete(clientOwner.connectionId)
|
|
841
|
-
}
|
|
842
|
-
void tryCompleteResumes()
|
|
1003
|
+
void expireOwner(owner, role)
|
|
843
1004
|
}, resumeWindowMs)
|
|
844
1005
|
owner.expiryTimer.unref?.()
|
|
845
1006
|
}
|
|
846
1007
|
|
|
1008
|
+
/** @param {GatewayOwner | ClientOwner} owner - Detached identity. @param {'gateway' | 'client'} role - Owner role. */
|
|
1009
|
+
async function expireOwner(owner, role) {
|
|
1010
|
+
owner.expiryTimer = undefined
|
|
1011
|
+
if (owner.physical !== undefined) return
|
|
1012
|
+
for (const bridge of [...owner.streams.values()]) await closeBridge(bridge, "RESUME_WINDOW_EXPIRED")
|
|
1013
|
+
if (role === "gateway") {
|
|
1014
|
+
const gatewayOwner = /** @type {GatewayOwner} */ (owner)
|
|
1015
|
+
if (gateways.get(gatewayOwner.gatewayId) === gatewayOwner) gateways.delete(gatewayOwner.gatewayId)
|
|
1016
|
+
} else {
|
|
1017
|
+
const clientOwner = /** @type {ClientOwner} */ (owner)
|
|
1018
|
+
if (clients.get(clientOwner.connectionId) === clientOwner) clients.delete(clientOwner.connectionId)
|
|
1019
|
+
}
|
|
1020
|
+
void tryCompleteResumes()
|
|
1021
|
+
}
|
|
1022
|
+
|
|
847
1023
|
/** @param {HubPhysical} physical - Destination. @param {Buffer} bytes - Encoded frame. @param {boolean} [allowClosing] - Allow a final GOAWAY after closing starts. @returns {Promise<boolean>} Delivery result. */
|
|
848
1024
|
function enqueueOutput(physical, bytes, allowClosing = false) {
|
|
849
1025
|
if (physical.socket.destroyed || (physical.closing && !allowClosing)) return Promise.resolve(false)
|
|
@@ -897,6 +1073,8 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
897
1073
|
physical.closing = true
|
|
898
1074
|
physical.retainOnDetach = false
|
|
899
1075
|
physical.detachCode ??= code
|
|
1076
|
+
physical.firstDisconnectReason ??= disconnectReasonForCode(code)
|
|
1077
|
+
ensurePhysicalAttribution(physical, physical.firstDisconnectReason)
|
|
900
1078
|
discardQueuedOutput(physical)
|
|
901
1079
|
if (!physical.socket.destroyed) physical.socket.destroy(protocolError(code))
|
|
902
1080
|
}
|
|
@@ -961,6 +1139,11 @@ function socketKey(socket) {
|
|
|
961
1139
|
/** @param {ReverseGatewayHubOptions} options - Options. */
|
|
962
1140
|
function validateOptions(options) {
|
|
963
1141
|
if (typeof options !== "object" || options === null || options.tokens === undefined || typeof options.tokens.authenticate !== "function") throw new TypeError("Reverse gateway hub requires a token authenticator")
|
|
1142
|
+
if (options.connectionFingerprint !== undefined) {
|
|
1143
|
+
const {key, keyEpoch} = options.connectionFingerprint
|
|
1144
|
+
if (!(key instanceof Uint8Array) || key.byteLength < 32) throw new TypeError("connection fingerprint key must contain at least 32 bytes")
|
|
1145
|
+
validateFingerprintKeyEpoch(keyEpoch)
|
|
1146
|
+
}
|
|
964
1147
|
if (typeof options.authorizeGateway !== "function" || typeof options.authorizeClientGateway !== "function" || typeof options.authorizeOpen !== "function") throw new TypeError("Reverse gateway hub requires gateway, client selection, and open authorization policies")
|
|
965
1148
|
if (typeof options.tls !== "object" || options.tls === null || typeof options.tls.certFile !== "string" || typeof options.tls.keyFile !== "string") throw new TypeError("Reverse gateway hub requires TLS certFile and keyFile")
|
|
966
1149
|
if (!zstdAvailable()) throw new Error("Reverse gateway hub requires Node.js zstd support")
|
package/src/reverse/runtime.js
CHANGED
|
@@ -3,8 +3,8 @@ import {StaticTokenStore} from "../auth/static-token-store.js"
|
|
|
3
3
|
import {OutboundReverseGateway} from "./gateway.js"
|
|
4
4
|
import {startReverseGatewayHub} from "./hub.js"
|
|
5
5
|
|
|
6
|
-
/** @param {import('../config/environment.js').RelayEnvironmentConfig} config - Parsed environment. */
|
|
7
|
-
export async function startReverseHubRuntime(config) {
|
|
6
|
+
/** @param {import('../config/environment.js').RelayEnvironmentConfig} config - Parsed environment. @param {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional structured logger. */
|
|
7
|
+
export async function startReverseHubRuntime(config, logger) {
|
|
8
8
|
if (config.mode !== "reverse-hub") throw new Error("Reverse hub runtime requires SOCKETDUCT_MODE=reverse-hub")
|
|
9
9
|
if (config.relayTls === undefined) throw new Error("Reverse hub mode requires SOCKETDUCT_TLS_CERT_FILE and SOCKETDUCT_TLS_KEY_FILE")
|
|
10
10
|
if (config.authTokens.length === 0) throw new Error("SOCKETDUCT_AUTH_TOKENS must contain hub client and gateway credentials")
|
|
@@ -54,7 +54,9 @@ export async function startReverseHubRuntime(config) {
|
|
|
54
54
|
resumeWindowMs: config.resumeWindowMs,
|
|
55
55
|
handshakeTimeoutMs: config.handshakeTimeoutMs,
|
|
56
56
|
maxInputBytesPerConnection: config.maxInputBytesPerConnection,
|
|
57
|
-
compressionLimits: compressionLimits(config)
|
|
57
|
+
compressionLimits: compressionLimits(config),
|
|
58
|
+
...(logger === undefined ? {} : {logger}),
|
|
59
|
+
...(config.connectionFingerprint === undefined ? {} : {connectionFingerprint: config.connectionFingerprint})
|
|
58
60
|
})
|
|
59
61
|
return {hub, close: hub.close}
|
|
60
62
|
}
|
package/src/spool/spool-file.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {readFileSync, readdirSync, rmSync, statSync} from "node:fs"
|
|
1
|
+
import {readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync} from "node:fs"
|
|
2
2
|
import {appendFile, mkdir, readFile, rename, rm, stat, writeFile} from "node:fs/promises"
|
|
3
3
|
import {join, resolve} from "node:path"
|
|
4
4
|
|
|
@@ -33,6 +33,7 @@ import {join, resolve} from "node:path"
|
|
|
33
33
|
|
|
34
34
|
const VALID_SESSION_ID = /^[a-zA-Z0-9_-]{8,}$/
|
|
35
35
|
const MAX_RECOVERY_STATE_BYTES = 16 * 1024
|
|
36
|
+
const MAX_SERIALIZED_CHUNK_OVERHEAD = 64
|
|
36
37
|
|
|
37
38
|
export class SpoolQuotaError extends Error {
|
|
38
39
|
/**
|
|
@@ -112,12 +113,7 @@ export class SessionSpool {
|
|
|
112
113
|
*/
|
|
113
114
|
readRecoveryStateSync(sessionId, ownerId) {
|
|
114
115
|
try {
|
|
115
|
-
const directory = this.#
|
|
116
|
-
const metadataPath = join(directory, "metadata.json")
|
|
117
|
-
if (statSync(metadataPath).size > MAX_RECOVERY_STATE_BYTES) throw new Error("recovery ownership metadata is oversized")
|
|
118
|
-
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"))
|
|
119
|
-
if (metadata?.sessionId !== sessionId || metadata?.ownerId !== ownerId) throw new Error("recovery ownership mismatch")
|
|
120
|
-
this.#assertMetadataAge(metadata, sessionId)
|
|
116
|
+
const directory = this.#readRecoveryOwnerSync(sessionId, ownerId)
|
|
121
117
|
const path = join(directory, "recovery.json")
|
|
122
118
|
if (statSync(path).size > MAX_RECOVERY_STATE_BYTES) throw new Error("recovery metadata is oversized")
|
|
123
119
|
const state = JSON.parse(readFileSync(path, "utf8"))
|
|
@@ -129,6 +125,97 @@ export class SessionSpool {
|
|
|
129
125
|
}
|
|
130
126
|
}
|
|
131
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Loads durable chunks synchronously so recovery can fail before network
|
|
130
|
+
* activity. Ownership, age, encoding, accounting, and configured byte bounds
|
|
131
|
+
* are all checked before any chunk is returned.
|
|
132
|
+
* @param {string} sessionId - Stable session identifier.
|
|
133
|
+
* @param {string} ownerId - Expected spool owner.
|
|
134
|
+
* @param {number} [acknowledgedSequence] - Omit chunks already covered by durable recovery state.
|
|
135
|
+
* @param {number} [committedSequence] - Highest chunk boundary committed by durable recovery state.
|
|
136
|
+
* @returns {SpoolChunk[]} Parsed durable chunks after the acknowledgement.
|
|
137
|
+
*/
|
|
138
|
+
loadChunksSync(sessionId, ownerId, acknowledgedSequence = 0, committedSequence = undefined) {
|
|
139
|
+
try {
|
|
140
|
+
if (!Number.isSafeInteger(acknowledgedSequence) || acknowledgedSequence < 0) throw new Error("recovery acknowledgement is invalid")
|
|
141
|
+
if (committedSequence !== undefined && (!Number.isSafeInteger(committedSequence) || committedSequence < acknowledgedSequence)) throw new Error("recovery committed sequence is invalid")
|
|
142
|
+
const directory = this.#readRecoveryOwnerSync(sessionId, ownerId)
|
|
143
|
+
const chunksPath = join(directory, "chunks.jsonl")
|
|
144
|
+
if (this.#maxBytes !== undefined) {
|
|
145
|
+
const serializedLimit = Math.min(Number.MAX_SAFE_INTEGER, Math.max(MAX_RECOVERY_STATE_BYTES, (this.#maxBytes * MAX_SERIALIZED_CHUNK_OVERHEAD) + MAX_RECOVERY_STATE_BYTES))
|
|
146
|
+
if (statSync(chunksPath).size > serializedLimit) throw new Error("recovery chunk storage is oversized")
|
|
147
|
+
}
|
|
148
|
+
const raw = readFileSync(chunksPath, "utf8")
|
|
149
|
+
/** @type {SpoolChunk[]} */
|
|
150
|
+
const chunks = []
|
|
151
|
+
let totalBytes = 0
|
|
152
|
+
let previousSequence
|
|
153
|
+
if (raw !== "") {
|
|
154
|
+
const lines = raw.split("\n")
|
|
155
|
+
if (lines.at(-1) === "") lines.pop()
|
|
156
|
+
for (const [index, line] of lines.entries()) {
|
|
157
|
+
let serialized
|
|
158
|
+
try {
|
|
159
|
+
serialized = /** @type {Partial<SerializedChunk>} */ (JSON.parse(line))
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const partialTail = index === lines.length - 1 && !raw.endsWith("\n") && committedSequence !== undefined && committedChunksAreCovered(chunks, acknowledgedSequence, committedSequence)
|
|
162
|
+
if (partialTail) break
|
|
163
|
+
throw error
|
|
164
|
+
}
|
|
165
|
+
if (!Number.isSafeInteger(serialized.sequence) || /** @type {number} */ (serialized.sequence) < 0 || typeof serialized.dataBase64 !== "string") throw new Error("recovery chunk is malformed")
|
|
166
|
+
const data = Buffer.from(serialized.dataBase64, "base64")
|
|
167
|
+
if (data.length === 0 || data.toString("base64") !== serialized.dataBase64) throw new Error("recovery chunk encoding is invalid")
|
|
168
|
+
if (data.length > /** @type {number} */ (serialized.sequence) || (previousSequence !== undefined && serialized.sequence !== previousSequence + data.length)) throw new Error("recovery chunks are not contiguous")
|
|
169
|
+
previousSequence = /** @type {number} */ (serialized.sequence)
|
|
170
|
+
totalBytes += data.length
|
|
171
|
+
if (!Number.isSafeInteger(totalBytes) || (this.#maxBytes !== undefined && totalBytes > this.#maxBytes)) throw new Error("recovery chunks exceed the configured byte limit")
|
|
172
|
+
chunks.push({sequence: /** @type {number} */ (serialized.sequence), data})
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const usagePath = join(directory, "usage.json")
|
|
176
|
+
if (statSync(usagePath).size > MAX_RECOVERY_STATE_BYTES) throw new Error("recovery usage metadata is oversized")
|
|
177
|
+
const usage = JSON.parse(readFileSync(usagePath, "utf8"))
|
|
178
|
+
if (!Number.isSafeInteger(usage?.totalBytes) || usage.totalBytes < 0) throw new Error("recovery chunk accounting is malformed")
|
|
179
|
+
if (committedSequence === undefined) {
|
|
180
|
+
if (usage.totalBytes !== totalBytes) throw new Error("recovery chunk accounting mismatch")
|
|
181
|
+
this.#usedBytes.set(sessionId, totalBytes)
|
|
182
|
+
this.#usageLoaded.add(sessionId)
|
|
183
|
+
return chunks.filter((chunk) => chunk.sequence > acknowledgedSequence)
|
|
184
|
+
}
|
|
185
|
+
if (!committedChunksAreCovered(chunks, acknowledgedSequence, committedSequence)) throw new Error("recovery chunks do not cover the committed sequence")
|
|
186
|
+
const firstAfterAck = chunks.find((chunk) => chunk.sequence > acknowledgedSequence)
|
|
187
|
+
if (firstAfterAck !== undefined && firstAfterAck.sequence - firstAfterAck.data.length !== acknowledgedSequence) throw new Error("recovery chunks do not start at the acknowledgement")
|
|
188
|
+
if (firstAfterAck === undefined && chunks.length > 0 && chunks.at(-1)?.sequence !== acknowledgedSequence) throw new Error("recovery acknowledgement is not a chunk boundary")
|
|
189
|
+
const committed = chunks.filter((chunk) => chunk.sequence > acknowledgedSequence && chunk.sequence <= committedSequence)
|
|
190
|
+
const committedBytes = committed.reduce((sum, chunk) => sum + chunk.data.length, 0)
|
|
191
|
+
if (!Number.isSafeInteger(committedBytes) || (this.#maxBytes !== undefined && committedBytes > this.#maxBytes)) throw new Error("recovery chunks exceed the configured byte limit")
|
|
192
|
+
const repairedChunks = serializeChunks(committed)
|
|
193
|
+
if (raw !== repairedChunks) writeFileAtomicSync(chunksPath, repairedChunks, {mode: 0o600})
|
|
194
|
+
if (usage.totalBytes !== committedBytes) writeFileAtomicSync(usagePath, JSON.stringify({totalBytes: committedBytes}) + "\n", {mode: 0o600})
|
|
195
|
+
this.#usedBytes.set(sessionId, committedBytes)
|
|
196
|
+
this.#usageLoaded.add(sessionId)
|
|
197
|
+
return committed
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error instanceof SpoolQuotaError) throw error
|
|
200
|
+
throw new Error(`Invalid Socketduct recovery chunks for ${sessionId}`, {cause: error})
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param {string} sessionId - Stable session identifier.
|
|
206
|
+
* @param {string} ownerId - Expected owner.
|
|
207
|
+
* @returns {string} Validated session directory.
|
|
208
|
+
*/
|
|
209
|
+
#readRecoveryOwnerSync(sessionId, ownerId) {
|
|
210
|
+
const directory = this.#sessionDirectory(sessionId)
|
|
211
|
+
const metadataPath = join(directory, "metadata.json")
|
|
212
|
+
if (statSync(metadataPath).size > MAX_RECOVERY_STATE_BYTES) throw new Error("recovery ownership metadata is oversized")
|
|
213
|
+
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"))
|
|
214
|
+
if (metadata?.sessionId !== sessionId || metadata?.ownerId !== ownerId) throw new Error("recovery ownership mismatch")
|
|
215
|
+
this.#assertMetadataAge(metadata, sessionId)
|
|
216
|
+
return directory
|
|
217
|
+
}
|
|
218
|
+
|
|
132
219
|
/**
|
|
133
220
|
* Removes only well-formed, expired session directories beneath the private spool root.
|
|
134
221
|
* @returns {void}
|
|
@@ -436,6 +523,48 @@ async function writeFileAtomic(path, contents, options) {
|
|
|
436
523
|
await rename(tempPath, path)
|
|
437
524
|
}
|
|
438
525
|
|
|
526
|
+
/**
|
|
527
|
+
* @param {string} path - Path to replace.
|
|
528
|
+
* @param {string} contents - Complete durable contents.
|
|
529
|
+
* @param {{mode: number}} options - File mode.
|
|
530
|
+
* @returns {void}
|
|
531
|
+
*/
|
|
532
|
+
function writeFileAtomicSync(path, contents, options) {
|
|
533
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`
|
|
534
|
+
writeFileSync(tempPath, contents, {mode: options.mode})
|
|
535
|
+
renameSync(tempPath, path)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* @param {readonly SpoolChunk[]} chunks - Ordered chunks.
|
|
540
|
+
* @returns {string} JSONL contents.
|
|
541
|
+
*/
|
|
542
|
+
function serializeChunks(chunks) {
|
|
543
|
+
const lines = chunks.map((chunk) => JSON.stringify(/** @type {SerializedChunk} */ ({
|
|
544
|
+
sequence: chunk.sequence,
|
|
545
|
+
dataBase64: chunk.data.toString("base64")
|
|
546
|
+
})))
|
|
547
|
+
return lines.length === 0 ? "" : `${lines.join("\n")}\n`
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* @param {readonly SpoolChunk[]} chunks - Structurally validated physical chunks.
|
|
552
|
+
* @param {number} acknowledgedSequence - Durable ACK boundary.
|
|
553
|
+
* @param {number} committedSequence - Durable append boundary.
|
|
554
|
+
* @returns {boolean} Whether the committed interval is complete and contiguous.
|
|
555
|
+
*/
|
|
556
|
+
function committedChunksAreCovered(chunks, acknowledgedSequence, committedSequence) {
|
|
557
|
+
if (committedSequence === acknowledgedSequence) return true
|
|
558
|
+
let sequence = acknowledgedSequence
|
|
559
|
+
for (const chunk of chunks) {
|
|
560
|
+
if (chunk.sequence <= acknowledgedSequence) continue
|
|
561
|
+
if (chunk.sequence > committedSequence) break
|
|
562
|
+
if (chunk.sequence - chunk.data.length !== sequence) return false
|
|
563
|
+
sequence = chunk.sequence
|
|
564
|
+
}
|
|
565
|
+
return sequence === committedSequence
|
|
566
|
+
}
|
|
567
|
+
|
|
439
568
|
/**
|
|
440
569
|
* @param {string} directory - directory value.
|
|
441
570
|
* @returns {Promise<void>} - Result value.
|
package/tensorbuzz.yml
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
dependency_roots:
|
|
2
|
+
- path: .
|
|
1
3
|
before_install:
|
|
2
4
|
- curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
|
|
3
|
-
- sudo apt-get install -y docker.io nodejs
|
|
5
|
+
- sudo apt-get install -y docker.io nodejs zstd=1.5.7+dfsg-3
|
|
4
6
|
before_script:
|
|
5
7
|
- node --version
|
|
6
|
-
- npm ci
|
|
7
8
|
environment:
|
|
8
9
|
DOCKER_HOST: "tcp://docker_server:2375"
|
|
9
10
|
services:
|
|
@@ -33,4 +34,5 @@ script:
|
|
|
33
34
|
- npm run typecheck
|
|
34
35
|
- npm run lint
|
|
35
36
|
- npm test
|
|
37
|
+
- npm run verify:zstd-logs
|
|
36
38
|
- docker build --tag socketduct-ci .
|