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.
Files changed (59) hide show
  1. package/.env.example +17 -5
  2. package/README.md +3 -3
  3. package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
  4. package/changelog.d/20260925180000-manual-ack-durable-admission.md +1 -0
  5. package/docs/docker.md +5 -4
  6. package/docs/logging.md +131 -159
  7. package/docs/protocol.md +48 -7
  8. package/docs/reverse-gateway.md +83 -8
  9. package/docs/running.md +17 -13
  10. package/docs/security.md +7 -3
  11. package/docs/session-lifecycle.md +22 -0
  12. package/docs/socket-api.md +22 -17
  13. package/package.json +5 -1
  14. package/scripts/verify-zstd-interoperability.mjs +41 -0
  15. package/src/cli/socketduct.js +143 -0
  16. package/src/client/deferred-virtual-socket.js +42 -1
  17. package/src/client/multiplexed-connection.js +17 -0
  18. package/src/client/multiplexed-transport-set.js +43 -8
  19. package/src/client/multiplexed.js +1 -1
  20. package/src/client/virtual-socket.js +119 -4
  21. package/src/config/environment.js +56 -7
  22. package/src/index.js +65 -27
  23. package/src/logging/compressed-log-reader.js +256 -0
  24. package/src/logging/compressed-log-worker.js +430 -0
  25. package/src/logging/compressed-log-writer.js +538 -0
  26. package/src/logging/log-batch.js +72 -0
  27. package/src/logging/log-retention.js +241 -0
  28. package/src/logging/log-segment-index.js +176 -0
  29. package/src/logging/structured-logger.js +136 -30
  30. package/src/logging/zstd-frame-codec.js +73 -0
  31. package/src/reverse/client.js +245 -34
  32. package/src/reverse/disconnect-attribution.js +191 -0
  33. package/src/reverse/gateway.js +68 -9
  34. package/src/reverse/hub.js +215 -32
  35. package/src/reverse/runtime.js +5 -3
  36. package/src/spool/spool-file.js +92 -6
  37. package/tensorbuzz.yml +2 -1
  38. package/test/e2e/logger-shutdown-process.test.js +86 -0
  39. package/test/e2e/reverse-client-recovery-process.test.js +3 -0
  40. package/test/e2e/reverse-gateway-process.test.js +15 -14
  41. package/test/fixtures/reverse-client-recovery.mjs +14 -2
  42. package/test/integration/logging-cli.test.js +168 -0
  43. package/test/integration/logging-compressed-recovery.test.js +186 -0
  44. package/test/integration/multiplexed-transport-set.test.js +1 -5
  45. package/test/integration/reverse-gateway.test.js +334 -1
  46. package/test/integration/reverse-route-class.test.js +6 -1
  47. package/test/unit/client/multiplexed-transport-set.test.js +50 -3
  48. package/test/unit/client/virtual-socket.test.js +123 -1
  49. package/test/unit/config/docker-gateway-files.test.js +2 -0
  50. package/test/unit/config/environment.test.js +54 -14
  51. package/test/unit/index.test.js +5 -0
  52. package/test/unit/logging/compressed-log-worker.test.js +29 -0
  53. package/test/unit/logging/compressed-log-writer.test.js +183 -0
  54. package/test/unit/logging/log-retention.test.js +86 -0
  55. package/test/unit/logging/log-segment-index.test.js +104 -0
  56. package/test/unit/logging/structured-logger.test.js +14 -1
  57. package/test/unit/logging/zstd-frame-codec.test.js +24 -0
  58. package/test/unit/reverse/disconnect-attribution.test.js +82 -0
  59. package/test/unit/spool/spool-file.test.js +58 -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
  }
@@ -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.filter((chunk) => chunk.sequence > acknowledgedSequence)
206
+ return chunksAfterSequence(chunks, acknowledgedSequence)
184
207
  }
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)
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
- 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)
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 remaining = chunks.filter((chunk) => chunk.sequence > acknowledgedSequence)
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 .