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.
Files changed (55) hide show
  1. package/.env.example +17 -5
  2. package/README.md +5 -5
  3. package/changelog.d/20260925143000-reverse-client-process-recovery.md +1 -0
  4. package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
  5. package/docs/architecture.md +11 -9
  6. package/docs/docker.md +5 -4
  7. package/docs/logging.md +131 -159
  8. package/docs/protocol.md +43 -7
  9. package/docs/reverse-gateway.md +75 -9
  10. package/docs/running.md +16 -13
  11. package/docs/security.md +16 -5
  12. package/docs/session-lifecycle.md +14 -3
  13. package/docs/socket-api.md +54 -20
  14. package/package.json +5 -1
  15. package/scripts/verify-zstd-interoperability.mjs +41 -0
  16. package/src/cli/socketduct.js +143 -0
  17. package/src/client/multiplexed-transport-set.js +12 -5
  18. package/src/client/virtual-socket.js +52 -0
  19. package/src/config/environment.js +56 -7
  20. package/src/index.js +64 -26
  21. package/src/logging/compressed-log-reader.js +256 -0
  22. package/src/logging/compressed-log-worker.js +430 -0
  23. package/src/logging/compressed-log-writer.js +538 -0
  24. package/src/logging/log-batch.js +72 -0
  25. package/src/logging/log-retention.js +241 -0
  26. package/src/logging/log-segment-index.js +176 -0
  27. package/src/logging/structured-logger.js +136 -30
  28. package/src/logging/zstd-frame-codec.js +73 -0
  29. package/src/reverse/client.js +372 -82
  30. package/src/reverse/disconnect-attribution.js +191 -0
  31. package/src/reverse/gateway.js +68 -18
  32. package/src/reverse/hub.js +215 -32
  33. package/src/reverse/runtime.js +5 -3
  34. package/src/spool/spool-file.js +136 -7
  35. package/tensorbuzz.yml +4 -2
  36. package/test/e2e/logger-shutdown-process.test.js +86 -0
  37. package/test/e2e/reverse-client-recovery-process.test.js +170 -0
  38. package/test/e2e/reverse-gateway-process.test.js +15 -14
  39. package/test/fixtures/reverse-client-recovery.mjs +123 -0
  40. package/test/integration/logging-cli.test.js +168 -0
  41. package/test/integration/logging-compressed-recovery.test.js +186 -0
  42. package/test/integration/reverse-gateway.test.js +597 -1
  43. package/test/integration/reverse-route-class.test.js +6 -1
  44. package/test/unit/client/multiplexed-transport-set.test.js +12 -1
  45. package/test/unit/config/docker-gateway-files.test.js +2 -0
  46. package/test/unit/config/environment.test.js +54 -14
  47. package/test/unit/index.test.js +3 -0
  48. package/test/unit/logging/compressed-log-worker.test.js +29 -0
  49. package/test/unit/logging/compressed-log-writer.test.js +183 -0
  50. package/test/unit/logging/log-retention.test.js +86 -0
  51. package/test/unit/logging/log-segment-index.test.js +104 -0
  52. package/test/unit/logging/structured-logger.test.js +14 -1
  53. package/test/unit/logging/zstd-frame-codec.test.js +24 -0
  54. package/test/unit/reverse/disconnect-attribution.test.js +82 -0
  55. package/test/unit/spool/spool-file.test.js +133 -0
@@ -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.on("error", () => {})
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 detail = frame.payload.length === 0 ? {} : decodeV3Control(frame.payload)
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) removeBridge(bridge)
748
- else bridge.state = "closing"
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
- if (bridge.client !== excluded && !bridge.client.socket.destroyed) void sendControl(bridge.client, V3FrameType.RESET, bridge.clientStreamId, 0, {code})
774
- if (bridge.gateway !== excluded && !bridge.gateway.socket.destroyed) void sendControl(bridge.gateway, V3FrameType.RESET, bridge.gatewayStreamId, 0, {code})
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) terminate(/** @type {HubPhysical} */ (client), "GATEWAY_DISCONNECTED", true)
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) terminate(/** @type {HubPhysical} */ (gateway), "CLIENT_DISCONNECTED", true)
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.expiryTimer = undefined
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")
@@ -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
  }
@@ -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.#sessionDirectory(sessionId)
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 .