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/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
|
@@ -18,6 +18,12 @@ import {join, resolve} from "node:path"
|
|
|
18
18
|
* @property {string} [ownerId] - ownerId value.
|
|
19
19
|
* @property {number} sequence - sequence value.
|
|
20
20
|
* @property {Uint8Array} data - data value.
|
|
21
|
+
* @typedef {object} AppendChunkBatchInput
|
|
22
|
+
* @property {string} sessionId - Stable session identifier.
|
|
23
|
+
* @property {string} ownerId - Expected spool owner.
|
|
24
|
+
* @property {number} previousSequence - Last sequence committed by recovery metadata.
|
|
25
|
+
* @property {readonly SpoolChunk[]} chunks - Complete application operation split into frames.
|
|
26
|
+
* @property {Record<string, unknown>} recoveryState - Recovery commit written after every chunk.
|
|
21
27
|
* @typedef {object} CreateSpoolInput
|
|
22
28
|
* @property {string} sessionId - sessionId value.
|
|
23
29
|
* @property {string} ownerId - ownerId value.
|
|
@@ -125,6 +131,23 @@ export class SessionSpool {
|
|
|
125
131
|
}
|
|
126
132
|
}
|
|
127
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Looks up recovery state without treating an absent session as corruption.
|
|
136
|
+
* @param {string} sessionId - Stable session identifier.
|
|
137
|
+
* @param {string} ownerId - Expected spool owner.
|
|
138
|
+
* @returns {Record<string, unknown> | undefined} Parsed state, or undefined when the session directory is absent.
|
|
139
|
+
*/
|
|
140
|
+
findRecoveryStateSync(sessionId, ownerId) {
|
|
141
|
+
const directory = this.#sessionDirectory(sessionId)
|
|
142
|
+
try {
|
|
143
|
+
statSync(directory)
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (/** @type {{code?: unknown}} */ (error).code === "ENOENT") return undefined
|
|
146
|
+
throw error
|
|
147
|
+
}
|
|
148
|
+
return this.readRecoveryStateSync(sessionId, ownerId)
|
|
149
|
+
}
|
|
150
|
+
|
|
128
151
|
/**
|
|
129
152
|
* Loads durable chunks synchronously so recovery can fail before network
|
|
130
153
|
* activity. Ownership, age, encoding, accounting, and configured byte bounds
|
|
@@ -180,13 +203,15 @@ export class SessionSpool {
|
|
|
180
203
|
if (usage.totalBytes !== totalBytes) throw new Error("recovery chunk accounting mismatch")
|
|
181
204
|
this.#usedBytes.set(sessionId, totalBytes)
|
|
182
205
|
this.#usageLoaded.add(sessionId)
|
|
183
|
-
return chunks
|
|
206
|
+
return chunksAfterSequence(chunks, acknowledgedSequence)
|
|
184
207
|
}
|
|
185
|
-
|
|
186
|
-
|
|
208
|
+
const unacknowledged = chunksAfterSequence(chunks, acknowledgedSequence)
|
|
209
|
+
if (!committedChunksAreCovered(unacknowledged, acknowledgedSequence, committedSequence)) throw new Error("recovery chunks do not cover the committed sequence")
|
|
210
|
+
const firstAfterAck = unacknowledged[0]
|
|
187
211
|
if (firstAfterAck !== undefined && firstAfterAck.sequence - firstAfterAck.data.length !== acknowledgedSequence) throw new Error("recovery chunks do not start at the acknowledgement")
|
|
188
|
-
|
|
189
|
-
|
|
212
|
+
const lastChunk = chunks.at(-1)
|
|
213
|
+
if (firstAfterAck === undefined && lastChunk !== undefined && lastChunk.sequence < acknowledgedSequence) throw new Error("recovery acknowledgement exceeds stored chunks")
|
|
214
|
+
const committed = unacknowledged.filter((chunk) => chunk.sequence <= committedSequence)
|
|
190
215
|
const committedBytes = committed.reduce((sum, chunk) => sum + chunk.data.length, 0)
|
|
191
216
|
if (!Number.isSafeInteger(committedBytes) || (this.#maxBytes !== undefined && committedBytes > this.#maxBytes)) throw new Error("recovery chunks exceed the configured byte limit")
|
|
192
217
|
const repairedChunks = serializeChunks(committed)
|
|
@@ -360,6 +385,45 @@ export class SessionSpool {
|
|
|
360
385
|
this.#usedBytes.set(input.sessionId, newTotal)
|
|
361
386
|
}
|
|
362
387
|
|
|
388
|
+
/**
|
|
389
|
+
* Replaces any uncommitted tail, writes a complete operation, then advances
|
|
390
|
+
* recovery metadata as the commit record. A failure before the last step is
|
|
391
|
+
* ignored by recovery and safely replaced by a retry.
|
|
392
|
+
* @param {AppendChunkBatchInput} input - Atomic durable admission input.
|
|
393
|
+
* @returns {Promise<void>} Completion after the recovery commit.
|
|
394
|
+
*/
|
|
395
|
+
async appendChunksAndRecoveryState(input) {
|
|
396
|
+
const session = await this.#readSession(input.sessionId)
|
|
397
|
+
if (session.metadata.ownerId !== input.ownerId) throw new Error("Chunk owner does not match spool metadata")
|
|
398
|
+
if (!Number.isSafeInteger(input.previousSequence) || input.previousSequence < 0) throw new RangeError("Previous durable sequence must be a non-negative safe integer")
|
|
399
|
+
if (!Array.isArray(input.chunks) || input.chunks.length === 0) throw new TypeError("Durable chunk batch must not be empty")
|
|
400
|
+
const encodedRecovery = `${JSON.stringify(input.recoveryState)}\n`
|
|
401
|
+
if (Buffer.byteLength(encodedRecovery) > MAX_RECOVERY_STATE_BYTES) throw new Error("Socketduct recovery state exceeds its size limit")
|
|
402
|
+
|
|
403
|
+
const createdAt = new Date(session.metadata.createdAt).getTime()
|
|
404
|
+
const ageMs = this.#now() - createdAt
|
|
405
|
+
if (this.#maxAgeMs !== undefined && ageMs > this.#maxAgeMs) throw new SpoolQuotaError(`Spool age ${ageMs}ms exceeds max ${this.#maxAgeMs}ms for session ${input.sessionId}`, "SPOOL_AGE_EXCEEDED")
|
|
406
|
+
|
|
407
|
+
const stored = await this.#readAllChunks(input.sessionId)
|
|
408
|
+
const committed = stored.filter((chunk) => chunk.sequence <= input.previousSequence)
|
|
409
|
+
let sequence = input.previousSequence
|
|
410
|
+
const additions = input.chunks.map((chunk) => {
|
|
411
|
+
const data = Buffer.from(chunk.data)
|
|
412
|
+
if (data.length === 0 || !Number.isSafeInteger(chunk.sequence) || chunk.sequence !== sequence + data.length) throw new Error("Durable chunk batch is not contiguous")
|
|
413
|
+
sequence = chunk.sequence
|
|
414
|
+
return {sequence: chunk.sequence, data}
|
|
415
|
+
})
|
|
416
|
+
const chunks = [...committed, ...additions]
|
|
417
|
+
const totalBytes = chunks.reduce((sum, chunk) => sum + chunk.data.length, 0)
|
|
418
|
+
if (!Number.isSafeInteger(totalBytes) || (this.#maxBytes !== undefined && totalBytes > this.#maxBytes)) throw new SpoolQuotaError(`Spool byte quota exceeded for session ${input.sessionId}: ${totalBytes} / max ${this.#maxBytes}`, "SPOOL_QUOTA_EXCEEDED")
|
|
419
|
+
|
|
420
|
+
await this.#writeChunks(input.sessionId, chunks)
|
|
421
|
+
await writeFileAtomic(join(session.directory, "usage.json"), JSON.stringify({totalBytes}) + "\n", {mode: 0o600})
|
|
422
|
+
this.#usedBytes.set(input.sessionId, totalBytes)
|
|
423
|
+
this.#usageLoaded.add(input.sessionId)
|
|
424
|
+
await this.writeRecoveryState(input.sessionId, input.recoveryState)
|
|
425
|
+
}
|
|
426
|
+
|
|
363
427
|
/**
|
|
364
428
|
* @param {string} sessionId - sessionId value.
|
|
365
429
|
* @param {number} acknowledgedSequence - acknowledgedSequence value.
|
|
@@ -377,7 +441,8 @@ export class SessionSpool {
|
|
|
377
441
|
*/
|
|
378
442
|
async truncateAcked(sessionId, acknowledgedSequence) {
|
|
379
443
|
const chunks = await this.#readAllChunks(sessionId)
|
|
380
|
-
const
|
|
444
|
+
const byteSequenced = chunks.every((chunk, index) => chunk.data.length <= chunk.sequence && (index === 0 || chunk.sequence - chunk.data.length === chunks[index - 1]?.sequence))
|
|
445
|
+
const remaining = byteSequenced ? chunksAfterSequence(chunks, acknowledgedSequence) : chunks.filter((chunk) => chunk.sequence > acknowledgedSequence)
|
|
381
446
|
await this.#writeChunks(sessionId, remaining)
|
|
382
447
|
const newTotal = remaining.reduce((sum, chunk) => sum + chunk.data.length, 0)
|
|
383
448
|
const directory = this.#sessionDirectory(sessionId)
|
|
@@ -547,6 +612,27 @@ function serializeChunks(chunks) {
|
|
|
547
612
|
return lines.length === 0 ? "" : `${lines.join("\n")}\n`
|
|
548
613
|
}
|
|
549
614
|
|
|
615
|
+
/**
|
|
616
|
+
* Removes an acknowledged prefix, including a byte-granular prefix inside the
|
|
617
|
+
* first retained physical chunk.
|
|
618
|
+
* @param {readonly SpoolChunk[]} chunks - Ordered chunks.
|
|
619
|
+
* @param {number} acknowledgedSequence - Cumulative application-byte ACK.
|
|
620
|
+
* @returns {SpoolChunk[]} Unacknowledged suffix.
|
|
621
|
+
*/
|
|
622
|
+
function chunksAfterSequence(chunks, acknowledgedSequence) {
|
|
623
|
+
/** @type {SpoolChunk[]} */
|
|
624
|
+
const remaining = []
|
|
625
|
+
for (const chunk of chunks) {
|
|
626
|
+
if (chunk.sequence <= acknowledgedSequence) continue
|
|
627
|
+
const start = chunk.sequence - chunk.data.length
|
|
628
|
+
remaining.push({
|
|
629
|
+
sequence: chunk.sequence,
|
|
630
|
+
data: start < acknowledgedSequence ? Buffer.from(chunk.data.subarray(acknowledgedSequence - start)) : Buffer.from(chunk.data)
|
|
631
|
+
})
|
|
632
|
+
}
|
|
633
|
+
return remaining
|
|
634
|
+
}
|
|
635
|
+
|
|
550
636
|
/**
|
|
551
637
|
* @param {readonly SpoolChunk[]} chunks - Structurally validated physical chunks.
|
|
552
638
|
* @param {number} acknowledgedSequence - Durable ACK boundary.
|
package/tensorbuzz.yml
CHANGED
|
@@ -2,7 +2,7 @@ dependency_roots:
|
|
|
2
2
|
- path: .
|
|
3
3
|
before_install:
|
|
4
4
|
- curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
|
|
5
|
-
- sudo apt-get install -y docker.io nodejs
|
|
5
|
+
- sudo apt-get install -y docker.io nodejs zstd
|
|
6
6
|
before_script:
|
|
7
7
|
- node --version
|
|
8
8
|
environment:
|
|
@@ -34,4 +34,5 @@ script:
|
|
|
34
34
|
- npm run typecheck
|
|
35
35
|
- npm run lint
|
|
36
36
|
- npm test
|
|
37
|
+
- npm run verify:zstd-logs
|
|
37
38
|
- docker build --tag socketduct-ci .
|