socketduct 0.1.19 → 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 (45) 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/docs/docker.md +5 -4
  5. package/docs/logging.md +131 -159
  6. package/docs/protocol.md +27 -4
  7. package/docs/reverse-gateway.md +34 -0
  8. package/docs/running.md +14 -11
  9. package/docs/security.md +7 -3
  10. package/package.json +5 -1
  11. package/scripts/verify-zstd-interoperability.mjs +41 -0
  12. package/src/cli/socketduct.js +143 -0
  13. package/src/client/virtual-socket.js +10 -0
  14. package/src/config/environment.js +56 -7
  15. package/src/index.js +64 -26
  16. package/src/logging/compressed-log-reader.js +256 -0
  17. package/src/logging/compressed-log-worker.js +430 -0
  18. package/src/logging/compressed-log-writer.js +538 -0
  19. package/src/logging/log-batch.js +72 -0
  20. package/src/logging/log-retention.js +241 -0
  21. package/src/logging/log-segment-index.js +176 -0
  22. package/src/logging/structured-logger.js +136 -30
  23. package/src/logging/zstd-frame-codec.js +73 -0
  24. package/src/reverse/client.js +71 -11
  25. package/src/reverse/disconnect-attribution.js +191 -0
  26. package/src/reverse/gateway.js +58 -8
  27. package/src/reverse/hub.js +215 -32
  28. package/src/reverse/runtime.js +5 -3
  29. package/tensorbuzz.yml +2 -1
  30. package/test/e2e/logger-shutdown-process.test.js +86 -0
  31. package/test/e2e/reverse-gateway-process.test.js +15 -14
  32. package/test/integration/logging-cli.test.js +168 -0
  33. package/test/integration/logging-compressed-recovery.test.js +186 -0
  34. package/test/integration/reverse-gateway.test.js +176 -0
  35. package/test/integration/reverse-route-class.test.js +6 -1
  36. package/test/unit/config/docker-gateway-files.test.js +2 -0
  37. package/test/unit/config/environment.test.js +54 -14
  38. package/test/unit/index.test.js +3 -0
  39. package/test/unit/logging/compressed-log-worker.test.js +29 -0
  40. package/test/unit/logging/compressed-log-writer.test.js +183 -0
  41. package/test/unit/logging/log-retention.test.js +86 -0
  42. package/test/unit/logging/log-segment-index.test.js +104 -0
  43. package/test/unit/logging/structured-logger.test.js +14 -1
  44. package/test/unit/logging/zstd-frame-codec.test.js +24 -0
  45. package/test/unit/reverse/disconnect-attribution.test.js +82 -0
@@ -0,0 +1,168 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
2
+ import assert from "node:assert/strict"
3
+ import {execFile, spawn} from "node:child_process"
4
+ import {mkdtemp, readFile, readdir, rm, 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
+ import {promisify} from "node:util"
9
+
10
+ import {createCompressedJsonlFileLogger} from "../../src/logging/compressed-log-writer.js"
11
+ import {CompressedLogTailReader} from "../../src/logging/compressed-log-reader.js"
12
+ import {enforceLogRetention} from "../../src/logging/log-retention.js"
13
+
14
+ const run = promisify(execFile)
15
+ const cli = new URL("../../src/cli/socketduct.js", import.meta.url)
16
+
17
+ test("owned CLI searches and tails durable compressed records with stable exits", async (t) => {
18
+ const root = await mkdtemp(join(tmpdir(), "socketduct-logs-cli-"))
19
+ const directory = join(root, "logs")
20
+ t.after(() => rm(root, {recursive: true, force: true}))
21
+ const logger = createCompressedJsonlFileLogger({directory, role: "reverse-hub"})
22
+ logger.log("process.started", {runtimeMode: "reverse-hub"})
23
+ logger.log("runtime.status", {virtualStreams: 7})
24
+ await logger.close()
25
+
26
+ const match = await invoke("logs", "search", "--directory", directory, "--event", "runtime.status")
27
+ assert.equal(match.code, 0)
28
+ assert.deepEqual(match.stdout.trim().split("\n").map((line) => JSON.parse(line).event), ["runtime.status"])
29
+
30
+ const absent = await invoke("logs", "search", "--directory", directory, "--event", "transport.failed")
31
+ assert.equal(absent.code, 1)
32
+ assert.equal(absent.stdout, "")
33
+
34
+ const tail = await invoke("logs", "tail", "--directory", directory)
35
+ assert.equal(tail.code, 0)
36
+ assert.deepEqual(tail.stdout.trim().split("\n").map((line) => JSON.parse(line).event), ["process.started", "runtime.status", "log_batch_accepted", "log_frame_committed", "log_closed"])
37
+
38
+ const invalid = await invoke("logs", "search", "--directory", directory, "--client-generation", "not-a-number")
39
+ assert.equal(invalid.code, 2)
40
+ assert.match(invalid.stderr, /Socketduct logs error/)
41
+ })
42
+
43
+ test("owned CLI follow stays live and emits newly durable writer records", async (t) => {
44
+ const root = await mkdtemp(join(tmpdir(), "socketduct-logs-follow-"))
45
+ const directory = join(root, "logs")
46
+ t.after(() => rm(root, {recursive: true, force: true}))
47
+ const initial = createCompressedJsonlFileLogger({directory, role: "reverse-client"})
48
+ initial.log("process.started", {runtimeMode: "reverse-client"})
49
+ await initial.close()
50
+
51
+ const child = spawn(process.execPath, [cli.pathname, "logs", "tail", "--directory", directory, "--follow", "--poll-ms", "10"], {stdio: ["ignore", "pipe", "pipe"]})
52
+ let stdout = ""
53
+ let stderr = ""
54
+ child.stdout.on("data", (chunk) => { stdout += chunk.toString() })
55
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString() })
56
+ t.after(() => { if (child.exitCode === null) child.kill("SIGKILL") })
57
+ await waitFor(() => stdout.includes('"eventSequence":"1"'), child, () => stderr)
58
+
59
+ const later = createCompressedJsonlFileLogger({directory, role: "reverse-client"})
60
+ later.log("runtime.status", {virtualStreams: 2})
61
+ await later.close()
62
+ await waitFor(() => stdout.includes('"event":"runtime.status"'), child, () => stderr)
63
+ child.kill("SIGTERM")
64
+ const code = await new Promise((resolve) => child.once("exit", resolve))
65
+ assert.equal(code, 0)
66
+ })
67
+
68
+ test("owned CLI exits 2 instead of hiding corrupt interior evidence", async (t) => {
69
+ const root = await mkdtemp(join(tmpdir(), "socketduct-logs-corrupt-"))
70
+ const directory = join(root, "logs")
71
+ t.after(() => rm(root, {recursive: true, force: true}))
72
+ const logger = createCompressedJsonlFileLogger({directory, role: "reverse-gateway"})
73
+ logger.log("process.started", {runtimeMode: "reverse-gateway"})
74
+ await logger.close()
75
+ const name = (await readdir(directory)).find((entry) => entry.endsWith(".jsonl.zst"))
76
+ assert(name !== undefined)
77
+ const path = join(directory, name)
78
+ const bytes = await readFile(path)
79
+ bytes[bytes.length - 1] = /** @type {number} */ (bytes[bytes.length - 1]) ^ 0xff
80
+ await writeFile(path, bytes)
81
+
82
+ const result = await invoke("logs", "search", "--directory", directory, "--event", "process.started")
83
+ assert.equal(result.code, 2)
84
+ assert.equal(result.stdout, "")
85
+ assert.match(result.stderr, /Socketduct log evidence error/)
86
+ })
87
+
88
+ test("tail reader incrementally follows rotation and prunes retention-deleted cursors", async (t) => {
89
+ const root = await mkdtemp(join(tmpdir(), "socketduct-logs-incremental-tail-"))
90
+ const directory = join(root, "logs")
91
+ t.after(() => rm(root, {recursive: true, force: true}))
92
+ const logger = createCompressedJsonlFileLogger({directory, role: "reverse-hub", frameMaxBytes: 256, maxSegmentBytes: 1024, compressionLevel: 1})
93
+ await logger.flush()
94
+ for (let index = 0; index < 8; index += 1) {
95
+ logger.log("process.started", {runtimeMode: "reverse-hub"})
96
+ await logger.flush()
97
+ }
98
+ await logger.flush()
99
+
100
+ const reader = new CompressedLogTailReader(directory)
101
+ const initial = await reader.poll()
102
+ assert(initial.records.length > 0)
103
+ const identities = new Set(initial.records.map((record) => `${record.writerEpoch}\0${record.eventSequence}`))
104
+ assert.equal(identities.size, initial.records.length)
105
+ const decoded = reader.status().decodedFrames
106
+ assert(decoded > 0)
107
+ assert.deepEqual((await reader.poll()).records, [])
108
+ assert.equal(reader.status().decodedFrames, decoded)
109
+
110
+ logger.log("process.started", {runtimeMode: "reverse-hub"})
111
+ await logger.flush()
112
+ await logger.flush()
113
+ const activeDelta = await reader.poll()
114
+ assert(activeDelta.records.length > 0)
115
+ for (const record of activeDelta.records) assert.equal(identities.has(`${record.writerEpoch}\0${record.eventSequence}`), false)
116
+ await logger.close()
117
+ const closeDelta = await reader.poll()
118
+ assert(closeDelta.records.length > 0)
119
+ for (const record of closeDelta.records) assert.equal(identities.has(`${record.writerEpoch}\0${record.eventSequence}`), false)
120
+
121
+ const oldSegments = (await readdir(directory)).filter((name) => name.endsWith(".jsonl.zst"))
122
+ assert(oldSegments.length > 1, "fixture must rotate through more than one segment")
123
+ for (const name of oldSegments) await utimes(join(directory, name), new Date(1_000), new Date(1_000))
124
+ const later = createCompressedJsonlFileLogger({directory, role: "reverse-hub", compressionLevel: 1})
125
+ later.log("runtime.status", {virtualStreams: 2})
126
+ await later.close()
127
+ const laterSegments = (await readdir(directory)).filter((name) => name.endsWith(".jsonl.zst") && !oldSegments.includes(name))
128
+ for (const name of laterSegments) await utimes(join(directory, name), new Date(10_000), new Date(10_000))
129
+ const laterDelta = await reader.poll()
130
+ assert(laterDelta.records.some((record) => record.event === "runtime.status"))
131
+
132
+ await enforceLogRetention({directory, maxAgeMs: 5_000, maxBytes: 1_000_000, now: () => new Date(10_000)})
133
+ const beforePruneDecode = reader.status().decodedFrames
134
+ assert.deepEqual((await reader.poll()).records, [])
135
+ const survivingSegments = (await readdir(directory)).filter((name) => name.endsWith(".jsonl.zst")).length
136
+ assert(reader.status().trackedSegments <= survivingSegments)
137
+ assert.equal(reader.status().decodedFrames, beforePruneDecode)
138
+ })
139
+
140
+ /** @param {...string} args - CLI arguments. */
141
+ async function invoke(...args) {
142
+ try {
143
+ const result = await run(process.execPath, [cli.pathname, ...args])
144
+ return {code: 0, stdout: result.stdout, stderr: result.stderr}
145
+ } catch (error) {
146
+ if (error === null || typeof error !== "object") throw error
147
+ return {
148
+ code: /** @type {{code?: number}} */ (error).code,
149
+ stdout: /** @type {{stdout?: string}} */ (error).stdout ?? "",
150
+ stderr: /** @type {{stderr?: string}} */ (error).stderr ?? ""
151
+ }
152
+ }
153
+ }
154
+
155
+ /** @param {() => boolean} predicate - Completion check. @param {import('node:child_process').ChildProcess} child - CLI process. @param {() => string} stderr - Error output. */
156
+ async function waitFor(predicate, child, stderr) {
157
+ if (predicate()) return
158
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
159
+ let timer
160
+ await new Promise((resolve, reject) => {
161
+ const cleanup = () => { if (timer !== undefined) clearTimeout(timer); child.stdout?.off("data", onData); child.off("exit", onExit) }
162
+ const onData = () => { if (predicate()) { cleanup(); resolve(undefined) } }
163
+ const onExit = (/** @type {number | null} */ code) => { cleanup(); reject(new Error(`CLI exited ${code}: ${stderr()}`)) }
164
+ timer = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for CLI output: ${stderr()}`)) }, 5_000)
165
+ child.stdout?.on("data", onData)
166
+ child.once("exit", onExit)
167
+ })
168
+ }
@@ -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
+ }
@@ -307,6 +307,45 @@ test("reverse client contains a TLS AUTH write race inside the transport", async
307
307
  await assert.rejects(connecting, /closed/i)
308
308
  })
309
309
 
310
+ test("fatal hub GOAWAY preserves validated attribution on the public client error", async (t) => {
311
+ /** @type {AuthWriteSocket | undefined} */
312
+ let physical
313
+ const client = new ReverseGatewayConnection({
314
+ gatewayId: "o2",
315
+ connectionId: "fatal_goaway_attribution_client",
316
+ hub: {host: "127.0.0.1", port: 3100, token: "client-token"},
317
+ tls: {servername: "localhost"},
318
+ continuousReconnect: false,
319
+ createPhysicalSocket: () => {
320
+ physical = new AuthWriteSocket()
321
+ return /** @type {import('node:tls').TLSSocket} */ (/** @type {unknown} */ (physical))
322
+ }
323
+ })
324
+ t.after(() => client.close())
325
+ const connecting = client.connect()
326
+ physical?.emit("secureConnect")
327
+ physical?.emit("data", encodeV3Frame({type: V3FrameType.GOAWAY, streamId: 0, sequence: 0}, encodeV3Control({
328
+ code: "PROTOCOL_VIOLATION",
329
+ disconnectIncidentId: "AAAAAAAAAAAAAAAAAAAAAA",
330
+ disconnectReason: "protocol_violation",
331
+ incidentOrigin: "reverse_hub",
332
+ physicalRole: "client",
333
+ clientConnectionGeneration: 1,
334
+ fanOutCount: 0,
335
+ resumeSuccessCount: 0,
336
+ terminalStreamCount: 0
337
+ })))
338
+ /** @type {Error & Record<string, unknown> | undefined} */
339
+ let rejection
340
+ await assert.rejects(withTimeout(connecting, "fatal attributed GOAWAY"), (error) => {
341
+ rejection = /** @type {Error & Record<string, unknown>} */ (error)
342
+ return rejection.code === "PROTOCOL_VIOLATION"
343
+ })
344
+ assert.equal(rejection?.disconnectIncidentId, "AAAAAAAAAAAAAAAAAAAAAA")
345
+ assert.equal(rejection?.disconnectReason, "protocol_violation")
346
+ assert.equal(rejection?.incidentOrigin, "reverse_hub")
347
+ })
348
+
310
349
  test("recovered reverse clients require explicit cold durable identity before any physical attempt", async (t) => {
311
350
  const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-recovery-contract-"))
312
351
  t.after(() => rm(root, {recursive: true, force: true}))
@@ -2168,6 +2207,143 @@ test("an expired reverse resume window fails the logical stream once and release
2168
2207
  assert.equal(targets[0]?.destroyed, true)
2169
2208
  })
2170
2209
 
2210
+ test("one gateway disconnect incident preserves physical cause across fan-out and terminal resume errors", async (t) => {
2211
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-attribution-"))
2212
+ t.after(() => rm(root, {recursive: true, force: true}))
2213
+ const certificates = generateTestCertificates(root)
2214
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-attribution-token"}, {username: "app", token: "client-attribution-token"}])
2215
+ /** @type {Array<Record<string, unknown> & {event: string}>} */
2216
+ const hubEvents = []
2217
+ /** @type {Array<Record<string, unknown> & {event: string}>} */
2218
+ const gatewayEvents = []
2219
+ /** @type {Array<Record<string, unknown> & {event: string}>} */
2220
+ const clientEvents = []
2221
+ const hub = await startReverseGatewayHub({
2222
+ tokens,
2223
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
2224
+ authorizeGateway: () => true,
2225
+ authorizeClientGateway: () => true,
2226
+ authorizeOpen: () => true,
2227
+ resumeWindowMs: 40,
2228
+ logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => { hubEvents.push({event, ...fields}) }},
2229
+ connectionFingerprint: {key: Buffer.alloc(32, 0x5a), keyEpoch: "test-epoch"}
2230
+ })
2231
+ t.after(() => hub.close())
2232
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
2233
+ const targets = []
2234
+ const gateway = new OutboundReverseGateway({
2235
+ gatewayId: "o2",
2236
+ connectionId: "low_entropy_gateway_connection",
2237
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-attribution-token"},
2238
+ tls,
2239
+ authorizeTarget: () => true,
2240
+ reconnectDelayMs: 150,
2241
+ maxReconnectDelayMs: 150,
2242
+ logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => { gatewayEvents.push({event, ...fields}) }},
2243
+ createTargetConnection: () => {
2244
+ const target = new InterruptibleTarget()
2245
+ targets.push(target)
2246
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (target))
2247
+ }
2248
+ })
2249
+ t.after(() => gateway.close())
2250
+ await gateway.start()
2251
+ const client = new ReverseGatewayConnection({
2252
+ gatewayId: "o2",
2253
+ connectionId: "low_entropy_client_connection",
2254
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-attribution-token"},
2255
+ tls,
2256
+ reconnectDelayMs: 5,
2257
+ maxReconnectDelayMs: 10,
2258
+ logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => { clientEvents.push({event, ...fields}) }}
2259
+ })
2260
+ t.after(() => client.close())
2261
+ await client.connect()
2262
+ const sockets = [client.createConnection({host: "target", port: 2375}), client.createConnection({host: "target", port: 2375})]
2263
+ await Promise.all(sockets.map((socket) => onceEvent(socket, "connect")))
2264
+ const terminalErrors = sockets.map((socket) => nextError(socket))
2265
+
2266
+ await hub.closeActiveGateway("o2")
2267
+ const errors = /** @type {Array<Error & Record<string, unknown>>} */ (await Promise.all(terminalErrors))
2268
+ assert.deepEqual(errors.map((error) => error.code), ["RESUME_WINDOW_EXPIRED", "RESUME_WINDOW_EXPIRED"])
2269
+ assert.equal(new Set(errors.map((error) => error.disconnectIncidentId)).size, 1)
2270
+ for (const error of errors) {
2271
+ assert.match(/** @type {string} */ (error.disconnectIncidentId), /^[A-Za-z0-9_-]{22}$/)
2272
+ assert.equal(error.disconnectReason, "unknown_transport_close")
2273
+ assert.equal(error.incidentOrigin, "reverse_hub")
2274
+ assert.equal(error.physicalRole, "gateway")
2275
+ assert.equal(typeof error.clientConnectionGeneration, "number")
2276
+ assert.equal(typeof error.gatewayConnectionGeneration, "number")
2277
+ assert.equal(error.fanOutCount, 2)
2278
+ }
2279
+ const detached = hubEvents.find(({event, physicalRole}) => event === "reverse_hub.physical_detached" && physicalRole === "gateway")
2280
+ assert(detached !== undefined)
2281
+ assert.equal(detached.disconnectIncidentId, /** @type {Error & Record<string, unknown>} */ (errors[0]).disconnectIncidentId)
2282
+ assert.match(/** @type {string} */ (detached.connectionFingerprint), /^[a-f0-9]{32}$/)
2283
+ assert.equal(detached.fingerprintKeyEpoch, "test-epoch")
2284
+ assert(hubEvents.some(({event, fanOutCount}) => event === "reverse_hub.streams_interrupted" && fanOutCount === 2))
2285
+ assert(hubEvents.some(({event, terminalStreamCount}) => event === "reverse_hub.streams_reset" && terminalStreamCount === 2))
2286
+ assert(clientEvents.some(({event}) => event === "reverse_client.physical_detached"))
2287
+ assert(gatewayEvents.some(({event}) => event === "reverse_gateway.physical_detached"))
2288
+ assert.equal(targets.length, 2)
2289
+ assert.doesNotMatch(JSON.stringify([hubEvents, gatewayEvents, clientEvents]), /low_entropy_(?:gateway|client)_connection|attribution-token/)
2290
+ })
2291
+
2292
+ test("peer GOAWAY classification and hub attribution reach terminal non-resumable RESET", async (t) => {
2293
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-terminal-attribution-"))
2294
+ t.after(() => rm(root, {recursive: true, force: true}))
2295
+ const certificates = generateTestCertificates(root)
2296
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
2297
+ /** @type {Array<Record<string, unknown> & {event: string}>} */
2298
+ const hubEvents = []
2299
+ const hub = await startReverseGatewayHub({
2300
+ tokens,
2301
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
2302
+ authorizeGateway: () => true,
2303
+ authorizeClientGateway: () => true,
2304
+ authorizeOpen: () => true,
2305
+ logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => { hubEvents.push({event, ...fields}) }}
2306
+ })
2307
+ t.after(() => hub.close())
2308
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
2309
+ const gateway = new OutboundReverseGateway({
2310
+ gatewayId: "o2",
2311
+ connectionId: "terminal_attribution_gateway",
2312
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
2313
+ tls,
2314
+ authorizeTarget: () => true,
2315
+ createTargetConnection: () => /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (new InterruptibleTarget())),
2316
+ createPhysicalSocket: (options) => {
2317
+ const physical = connectTls({host: options.host, port: options.port, ...options.tls})
2318
+ const originalWrite = physical.write.bind(physical)
2319
+ physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
2320
+ if (!Buffer.isBuffer(chunk) || chunk[5] !== V3FrameType.GOAWAY) return Reflect.apply(originalWrite, physical, [chunk, ...args])
2321
+ const safeGoaway = encodeV3Frame({type: V3FrameType.GOAWAY, streamId: 0, sequence: 0}, encodeV3Control({code: "GATEWAY_MAINTENANCE", disconnectIncidentId: "BBBBBBBBBBBBBBBBBBBBBB"}))
2322
+ return Reflect.apply(originalWrite, physical, [safeGoaway, ...args])
2323
+ })
2324
+ return physical
2325
+ }
2326
+ })
2327
+ t.after(() => gateway.close())
2328
+ await gateway.start()
2329
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "terminal_attribution_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, continuousReconnect: false})
2330
+ t.after(() => client.close())
2331
+ await client.connect()
2332
+ const socket = client.createConnection({host: "target", port: 2375})
2333
+ await onceEvent(socket, "connect")
2334
+ const terminal = nextError(socket)
2335
+ await gateway.close()
2336
+ const error = /** @type {Error & Record<string, unknown>} */ (await withTimeout(terminal, "terminal peer GOAWAY reset"))
2337
+ assert.equal(error.code, "GATEWAY_MAINTENANCE")
2338
+ assert.equal(error.disconnectReason, "peer_goaway")
2339
+ assert.equal(error.incidentOrigin, "reverse_hub")
2340
+ assert.equal(error.physicalRole, "gateway")
2341
+ assert.equal(error.fanOutCount, 1)
2342
+ assert.equal(error.terminalStreamCount, 1)
2343
+ assert.notEqual(error.disconnectIncidentId, "BBBBBBBBBBBBBBBBBBBBBB")
2344
+ assert(hubEvents.some(({event, terminalStreamCount}) => event === "reverse_hub.streams_reset" && terminalStreamCount === 1), JSON.stringify(hubEvents))
2345
+ })
2346
+
2171
2347
  test("reverse mode enforces verified TLS and stream resource bounds", async (t) => {
2172
2348
  const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-limits-"))
2173
2349
  const wrongRoot = await mkdtemp(join(tmpdir(), "socketduct-reverse-wrong-ca-"))
@@ -364,7 +364,12 @@ test("a backpressured gateway does not block DATA or a new OPEN for another rout
364
364
  t.after(() => socket.destroy())
365
365
  return nextError(socket)
366
366
  })
367
- assert.equal((await withTimeout(streamAError, "gateway A output limit")).code, "OUTPUT_BACKPRESSURE_LIMIT")
367
+ const outputError = /** @type {Error & Record<string, unknown>} */ (await withTimeout(streamAError, "gateway A output limit"))
368
+ assert.equal(outputError.code, "OUTPUT_BACKPRESSURE_LIMIT")
369
+ assert.equal(outputError.disconnectReason, "output_backpressure_limit")
370
+ assert.equal(outputError.incidentOrigin, "reverse_hub")
371
+ assert.equal(outputError.physicalRole, "gateway")
372
+ assert.match(/** @type {string} */ (outputError.disconnectIncidentId), /^[A-Za-z0-9_-]{22}$/)
368
373
  await withTimeout(Promise.all(queuedAErrors), "queued gateway A stream cleanup")
369
374
  assert.equal(await exchange(streamB, "after-limit"), "b:after-limit")
370
375
  assert.equal(hub.status().healthyGateways, 1)
@@ -36,6 +36,8 @@ test("TensorBuzz exclusively owns verification and container build validation",
36
36
  const packageMetadata = JSON.parse(packageJson)
37
37
 
38
38
  assert.deepEqual(workflowNames, [])
39
+ assert.match(tensorbuzz, /^dependency_roots:\n {2}- path: \.$/m)
40
+ assert.doesNotMatch(tensorbuzz, /^\s*- npm ci$/m)
39
41
  assert.match(tensorbuzz, /DOCKER_HOST: "tcp:\/\/docker_server:2375"/)
40
42
  assert.match(tensorbuzz, /docker_server:\n\s+image: "docker:[^"]+-dind"/)
41
43
  assert.match(tensorbuzz, /privileged: true/)
@@ -79,37 +79,77 @@ test("loadEnvironmentConfig parses or disables the status interval", () => {
79
79
  assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_STATUS_INTERVAL_MS: "-1"}), /non-negative integer/)
80
80
  })
81
81
 
82
- test("loadEnvironmentConfig keeps persistent logging disabled by default and parses explicit bounds", () => {
82
+ test("loadEnvironmentConfig keeps persistent logging disabled by default and parses compressed bounds", () => {
83
83
  assert.equal(loadEnvironmentConfig({}).fileLogging, undefined)
84
84
  assert.equal(loadEnvironmentConfig({SOCKETDUCT_LOG_DIRECTORY: " "}).fileLogging, undefined)
85
85
  assert.deepEqual(loadEnvironmentConfig({
86
86
  SOCKETDUCT_LOG_DIRECTORY: " /var/log/socketduct ",
87
- SOCKETDUCT_LOG_MAX_FILE_BYTES: "2097152",
88
- SOCKETDUCT_LOG_MAX_FILES: "4"
87
+ SOCKETDUCT_LOG_FRAME_MAX_BYTES: "2097152",
88
+ SOCKETDUCT_LOG_FLUSH_INTERVAL_MS: "1500",
89
+ SOCKETDUCT_LOG_CRITICAL_FLUSH_INTERVAL_MS: "200",
90
+ SOCKETDUCT_LOG_SEGMENT_MAX_BYTES: "268435456",
91
+ SOCKETDUCT_LOG_RETENTION_DAYS: "14",
92
+ SOCKETDUCT_LOG_RETENTION_MAX_BYTES: "3221225472",
93
+ SOCKETDUCT_LOG_QUEUE_MAX_BYTES: "33554432",
94
+ SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES: "4194304",
95
+ SOCKETDUCT_LOG_COMPRESSION_LEVEL: "18"
89
96
  }).fileLogging, {
90
97
  directory: "/var/log/socketduct",
91
- maxFileBytes: 2_097_152,
92
- maxFiles: 4
98
+ frameMaxBytes: 2_097_152,
99
+ flushIntervalMs: 1_500,
100
+ criticalFlushIntervalMs: 200,
101
+ maxSegmentBytes: 268_435_456,
102
+ retentionDays: 14,
103
+ retentionBytes: 3_221_225_472,
104
+ maxQueueBytes: 33_554_432,
105
+ criticalReserveBytes: 4_194_304,
106
+ compressionLevel: 18
93
107
  })
94
108
  })
95
109
 
96
- test("loadEnvironmentConfig rejects unsafe persistent logging bounds", () => {
110
+ test("loadEnvironmentConfig rejects unsafe compressed logging bounds", () => {
97
111
  assert.throws(() => loadEnvironmentConfig({
98
112
  SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
99
- SOCKETDUCT_LOG_MAX_FILE_BYTES: "100"
100
- }), /SOCKETDUCT_LOG_MAX_FILE_BYTES/)
113
+ SOCKETDUCT_LOG_FRAME_MAX_BYTES: "100"
114
+ }), /SOCKETDUCT_LOG_FRAME_MAX_BYTES/)
101
115
  assert.throws(() => loadEnvironmentConfig({
102
116
  SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
103
- SOCKETDUCT_LOG_MAX_FILE_BYTES: "9007199254740992"
104
- }), /SOCKETDUCT_LOG_MAX_FILE_BYTES/)
117
+ SOCKETDUCT_LOG_COMPRESSION_LEVEL: "23"
118
+ }), /SOCKETDUCT_LOG_COMPRESSION_LEVEL/)
119
+ assert.throws(() => loadEnvironmentConfig({
120
+ SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
121
+ SOCKETDUCT_LOG_QUEUE_MAX_BYTES: "1024",
122
+ SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES: "1024"
123
+ }), /SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES/)
105
124
  assert.throws(() => loadEnvironmentConfig({
106
125
  SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
107
- SOCKETDUCT_LOG_MAX_FILES: "0"
108
- }), /SOCKETDUCT_LOG_MAX_FILES/)
126
+ SOCKETDUCT_LOG_SEGMENT_MAX_BYTES: "5368709120",
127
+ SOCKETDUCT_LOG_RETENTION_MAX_BYTES: "4294967296"
128
+ }), /SOCKETDUCT_LOG_SEGMENT_MAX_BYTES/)
109
129
  assert.throws(() => loadEnvironmentConfig({
110
130
  SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
111
- SOCKETDUCT_LOG_MAX_FILES: "101"
112
- }), /SOCKETDUCT_LOG_MAX_FILES/)
131
+ SOCKETDUCT_LOG_MAX_FILE_BYTES: "10485760"
132
+ }), /SOCKETDUCT_LOG_MAX_FILE_BYTES/)
133
+ })
134
+
135
+ test("loadEnvironmentConfig accepts a paired hub-only fingerprint key and epoch", () => {
136
+ const encodedKey = Buffer.alloc(32, 0x5a).toString("base64url")
137
+ const config = loadEnvironmentConfig({
138
+ SOCKETDUCT_MODE: "reverse-hub",
139
+ SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
140
+ SOCKETDUCT_CONNECTION_FINGERPRINT_KEY: encodedKey,
141
+ SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH: "hub-2026-09"
142
+ })
143
+ assert.deepEqual(config.connectionFingerprint, {key: Buffer.alloc(32, 0x5a), keyEpoch: "hub-2026-09"})
144
+ assert.throws(() => loadEnvironmentConfig({
145
+ SOCKETDUCT_MODE: "reverse-hub",
146
+ SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct"
147
+ }), /SOCKETDUCT_CONNECTION_FINGERPRINT_KEY/)
148
+ assert.throws(() => loadEnvironmentConfig({
149
+ SOCKETDUCT_MODE: "reverse-gateway",
150
+ SOCKETDUCT_CONNECTION_FINGERPRINT_KEY: encodedKey,
151
+ SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH: "hub-2026-09"
152
+ }), /hub-only/)
113
153
  })
114
154
 
115
155
  test("loadEnvironmentConfig selects v3 explicitly and keeps v2 as rollback default", () => {