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
@@ -0,0 +1,186 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param-type, jsdoc/require-returns */
2
+ import assert from "node:assert/strict"
3
+ import {createHash, randomBytes} from "node:crypto"
4
+ import {mkdtemp, readdir, rm, stat, utimes, writeFile} from "node:fs/promises"
5
+ import {tmpdir} from "node:os"
6
+ import {join} from "node:path"
7
+ import test from "node:test"
8
+
9
+ import {createLogBatchId, inspectLogBatch} from "../../src/logging/log-batch.js"
10
+ import {readCompressedLogDirectory} from "../../src/logging/compressed-log-reader.js"
11
+ import {CompressedLogWorkerSink} from "../../src/logging/compressed-log-worker.js"
12
+ import {enforceLogRetention} from "../../src/logging/log-retention.js"
13
+ import {createIndexEntry, createIndexHeader, INDEX_ENTRY_BYTES, INDEX_HEADER_BYTES, uuidBytes} from "../../src/logging/log-segment-index.js"
14
+ import {compressLogFrame} from "../../src/logging/zstd-frame-codec.js"
15
+
16
+ test("recovery indexes a complete data-synced orphan without duplicating its batch", async (t) => {
17
+ const root = await mkdtemp(join(tmpdir(), "socketduct-compressed-orphan-"))
18
+ t.after(() => rm(root, {recursive: true, force: true}))
19
+ const fixture = await createRecoveryFixture(root, false)
20
+ const sink = new CompressedLogWorkerSink(fixture.options)
21
+ const recovered = await sink.start()
22
+ assert.equal(recovered.durableHighWater, "2")
23
+ assert.equal(recovered.orphanRecoveredCount, 1)
24
+ assert.equal(recovered.lastDurableBatch?.batchId, fixture.secondBatchId)
25
+ assert.equal((await stat(fixture.indexPath)).size, INDEX_HEADER_BYTES + 2 * INDEX_ENTRY_BYTES)
26
+
27
+ const duplicate = await sink.appendBatch(fixture.secondMessage)
28
+ assert.equal(duplicate.duplicate, true)
29
+ assert.equal((await stat(fixture.dataPath)).size, fixture.firstFrame.length + fixture.secondFrame.length)
30
+ await sink.close()
31
+ const result = await readCompressedLogDirectory(root)
32
+ assert.equal(result.incomplete, false)
33
+ assert.deepEqual(result.records.map((record) => record.eventSequence), ["1", "2"])
34
+ })
35
+
36
+ test("recovery truncates only a torn final data frame and accepts its replay", async (t) => {
37
+ const root = await mkdtemp(join(tmpdir(), "socketduct-compressed-torn-"))
38
+ t.after(() => rm(root, {recursive: true, force: true}))
39
+ const fixture = await createRecoveryFixture(root, true)
40
+ const sink = new CompressedLogWorkerSink(fixture.options)
41
+ const recovered = await sink.start()
42
+ assert.equal(recovered.durableHighWater, "1")
43
+ assert.equal((await stat(fixture.dataPath)).size, fixture.firstFrame.length)
44
+
45
+ const replayed = await sink.appendBatch(fixture.secondMessage)
46
+ assert.equal(replayed.duplicate, false)
47
+ await sink.close()
48
+ const result = await readCompressedLogDirectory(root)
49
+ assert.equal(result.incomplete, false)
50
+ assert.deepEqual(result.records.map((record) => record.eventSequence), ["1", "2"])
51
+ })
52
+
53
+ test("worker acknowledgements retain only the latest durable batch identity", async (t) => {
54
+ const root = await mkdtemp(join(tmpdir(), "socketduct-compressed-bounded-ack-"))
55
+ t.after(() => rm(root, {recursive: true, force: true}))
56
+ const writerEpoch = randomBytes(16)
57
+ const options = {directory: root, role: "reverse-hub", writerEpoch: writerEpoch.toString("base64url"), maxSegmentBytes: 4096, compressionLevel: 19}
58
+ const sink = new CompressedLogWorkerSink(options)
59
+ await sink.start()
60
+ let latest = await sink.appendBatch(batchMessage(writerEpoch, 1))
61
+ for (let sequence = 2; sequence <= 40; sequence += 1) latest = await sink.appendBatch(batchMessage(writerEpoch, sequence))
62
+ assert.equal(Object.hasOwn(latest, "durableBatchIds"), false)
63
+ assert.deepEqual(latest.lastDurableBatch, {
64
+ batchId: batchMessage(writerEpoch, 40).batchId,
65
+ firstSequence: "40",
66
+ lastSequence: "40"
67
+ })
68
+ assert.equal((await sink.appendBatch(batchMessage(writerEpoch, 40))).duplicate, true)
69
+ await sink.close()
70
+
71
+ const restarted = new CompressedLogWorkerSink(options)
72
+ const recovered = await restarted.start()
73
+ assert.equal(Object.hasOwn(recovered, "durableBatchIds"), false)
74
+ assert.deepEqual(recovered.lastDurableBatch, latest.lastDurableBatch)
75
+ await restarted.close()
76
+ })
77
+
78
+ test("recovery accepts a retention-deleted prefix but rejects interior gaps", async (t) => {
79
+ const root = await mkdtemp(join(tmpdir(), "socketduct-compressed-retained-prefix-"))
80
+ t.after(() => rm(root, {recursive: true, force: true}))
81
+ const writerEpoch = randomBytes(16)
82
+ const options = {directory: root, role: "reverse-hub", writerEpoch: writerEpoch.toString("base64url"), maxSegmentBytes: 4096, compressionLevel: 19, retentionBytes: 1_000_000}
83
+
84
+ const segments = []
85
+ for (let sequence = 1; sequence <= 3; sequence += 1) {
86
+ const sink = new CompressedLogWorkerSink(options)
87
+ await sink.start()
88
+ await sink.appendBatch(batchMessage(writerEpoch, sequence))
89
+ segments.push(/** @type {string} */ (sink.status().segmentPath))
90
+ await sink.close()
91
+ }
92
+ assert.equal(segments.length, 3)
93
+ for (let index = 0; index < segments.length; index += 1) {
94
+ await utimes(/** @type {string} */ (segments[index]), new Date(1_000 + index), new Date(1_000 + index))
95
+ }
96
+ await enforceLogRetention({directory: root, maxAgeMs: 1, maxBytes: 1_000_000, now: () => new Date(1_003)})
97
+ assert.equal((await readdir(root)).filter((name) => name.endsWith(".jsonl.zst")).length, 1)
98
+
99
+ const recovered = new CompressedLogWorkerSink(options)
100
+ assert.equal((await recovered.start()).durableHighWater, "3")
101
+ assert.equal((await recovered.appendBatch(batchMessage(writerEpoch, 4))).durableHighWater, "4")
102
+ await recovered.close()
103
+
104
+ const gapDirectory = join(root, "interior-gap")
105
+ const gapEpoch = randomBytes(16)
106
+ const gapOptions = {...options, directory: gapDirectory, writerEpoch: gapEpoch.toString("base64url")}
107
+ const gapSegments = []
108
+ for (let sequence = 1; sequence <= 3; sequence += 1) {
109
+ const sink = new CompressedLogWorkerSink(gapOptions)
110
+ await sink.start()
111
+ await sink.appendBatch(batchMessage(gapEpoch, sequence))
112
+ gapSegments.push(/** @type {string} */ (sink.status().segmentPath))
113
+ await sink.close()
114
+ }
115
+ const missing = /** @type {string} */ (gapSegments[1])
116
+ await Promise.all([rm(missing), rm(`${missing}.idx`), rm(`${missing}.sealed`)])
117
+ await assert.rejects(() => new CompressedLogWorkerSink(gapOptions).start(), /interior sequence gap/)
118
+ })
119
+
120
+ /**
121
+ * @param {string} directory - Fixture directory. @param {boolean} torn - Whether to tear the orphan frame.
122
+ * @param torn
123
+ */
124
+ async function createRecoveryFixture(directory, torn) {
125
+ const writerEpoch = randomBytes(16)
126
+ const writerEpochText = writerEpoch.toString("base64url")
127
+ const segmentUuidText = "123e4567-e89b-42d3-a456-426614174000"
128
+ const segmentUuid = uuidBytes(segmentUuidText)
129
+ const stem = `reverse-hub-${writerEpochText}-${segmentUuidText}.jsonl.zst`
130
+ const dataPath = join(directory, stem)
131
+ const indexPath = `${dataPath}.idx`
132
+ const firstRaw = record(writerEpochText, 1, "process.started")
133
+ const secondRaw = record(writerEpochText, 2, "runtime.status")
134
+ const firstFrame = await compressLogFrame(firstRaw)
135
+ const secondFrame = await compressLogFrame(secondRaw)
136
+ const first = inspectLogBatch(firstRaw, writerEpoch)
137
+ const firstEntry = createIndexEntry({
138
+ segmentUuid,
139
+ writerEpoch,
140
+ batchId: first.batchId,
141
+ firstSequence: first.firstSequence,
142
+ lastSequence: first.lastSequence,
143
+ dataOffset: 0n,
144
+ compressedLength: BigInt(firstFrame.length),
145
+ uncompressedLength: BigInt(firstRaw.length),
146
+ recordCount: 1,
147
+ minTimestamp: first.minTimestamp,
148
+ maxTimestamp: first.maxTimestamp,
149
+ frameSha256: createHash("sha256").update(firstFrame).digest()
150
+ })
151
+ await writeFile(dataPath, Buffer.concat([firstFrame, torn ? secondFrame.subarray(0, secondFrame.length - 3) : secondFrame]), {mode: 0o600})
152
+ await writeFile(indexPath, Buffer.concat([createIndexHeader({segmentUuid, writerEpoch}), firstEntry]), {mode: 0o600})
153
+ const secondBatchId = createLogBatchId(writerEpoch, 2n, 2n, secondRaw).toString("base64url")
154
+ return {
155
+ options: {directory, role: "reverse-hub", writerEpoch: writerEpochText, maxSegmentBytes: 4096, compressionLevel: 19},
156
+ dataPath,
157
+ indexPath,
158
+ firstFrame,
159
+ secondFrame,
160
+ secondBatchId,
161
+ secondMessage: {batchId: secondBatchId, firstSequence: "2", lastSequence: "2", jsonl: secondRaw}
162
+ }
163
+ }
164
+
165
+ /**
166
+ * @param {string} writerEpoch - Epoch. @param {number} sequence - Event sequence. @param {string} event - Event name.
167
+ * @param sequence
168
+ * @param event
169
+ */
170
+ function record(writerEpoch, sequence, event) {
171
+ return Buffer.from(`${JSON.stringify({timestamp: new Date(1_795_000_000_000 + sequence).toISOString(), event, processId: 1, role: "reverse-hub", writerEpoch, eventSequence: String(sequence)})}\n`)
172
+ }
173
+
174
+ /**
175
+ * @param {Buffer} writerEpoch - Epoch.
176
+ * @param {number} sequence - Event sequence.
177
+ */
178
+ function batchMessage(writerEpoch, sequence) {
179
+ const jsonl = record(writerEpoch.toString("base64url"), sequence, "runtime.status")
180
+ return {
181
+ batchId: createLogBatchId(writerEpoch, BigInt(sequence), BigInt(sequence), jsonl).toString("base64url"),
182
+ firstSequence: String(sequence),
183
+ lastSequence: String(sequence),
184
+ jsonl
185
+ }
186
+ }