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
@@ -0,0 +1,86 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
2
+ import assert from "node:assert/strict"
3
+ import {spawn} from "node:child_process"
4
+ import {mkdtemp, rm} from "node:fs/promises"
5
+ import {createServer} from "node:net"
6
+ import {tmpdir} from "node:os"
7
+ import {join} from "node:path"
8
+ import test from "node:test"
9
+
10
+ import {readCompressedLogDirectory} from "../../src/logging/compressed-log-reader.js"
11
+
12
+ const TIMEOUT_MS = 8_000
13
+
14
+ test("SIGTERM durably flushes queued critical lifecycle records before exit", async (t) => {
15
+ const root = await mkdtemp(join(tmpdir(), "socketduct-logger-shutdown-"))
16
+ const directory = join(root, "logs")
17
+ const port = await reservePort()
18
+ const child = spawn(process.execPath, ["src/index.js"], {
19
+ cwd: process.cwd(),
20
+ env: {
21
+ ...process.env,
22
+ SOCKETDUCT_MODE: "relay",
23
+ SOCKETDUCT_RELAY_HOST: "127.0.0.1",
24
+ SOCKETDUCT_RELAY_PORT: String(port),
25
+ SOCKETDUCT_AUTH_TOKENS: "shutdown-test:shutdown-test-token",
26
+ SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:1",
27
+ SOCKETDUCT_STATUS_INTERVAL_MS: "0",
28
+ SOCKETDUCT_LOG_DIRECTORY: directory,
29
+ SOCKETDUCT_LOG_FLUSH_INTERVAL_MS: "60000",
30
+ SOCKETDUCT_LOG_CRITICAL_FLUSH_INTERVAL_MS: "60000"
31
+ },
32
+ stdio: ["ignore", "pipe", "pipe"]
33
+ })
34
+ let output = ""
35
+ child.stdout.on("data", (chunk) => { output += chunk.toString() })
36
+ child.stderr.on("data", (chunk) => { output += chunk.toString() })
37
+ t.after(async () => {
38
+ if (child.exitCode === null) child.kill("SIGKILL")
39
+ await rm(root, {recursive: true, force: true})
40
+ })
41
+
42
+ await waitUntil(() => output.includes('"event":"relay.started"'), child, () => output)
43
+ assert.equal(child.kill("SIGTERM"), true)
44
+ const exit = await withTimeout(new Promise((resolve) => child.once("exit", (code, signal) => resolve({code, signal}))), "process exit")
45
+ assert.deepEqual(exit, {code: 0, signal: null})
46
+
47
+ const result = await readCompressedLogDirectory(directory)
48
+ assert.equal(result.incomplete, false)
49
+ assert.deepEqual(result.records.map((record) => record.event), ["process.started", "process.stopped", "log_batch_accepted", "log_frame_committed", "log_closed"])
50
+ })
51
+
52
+ /** @returns {Promise<number>} A released loopback port. */
53
+ async function reservePort() {
54
+ const server = createServer()
55
+ await new Promise((resolve, reject) => {
56
+ server.once("error", reject)
57
+ server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
58
+ })
59
+ const address = server.address()
60
+ assert(address !== null && typeof address !== "string")
61
+ await new Promise((resolve) => server.close(() => resolve(undefined)))
62
+ return address.port
63
+ }
64
+
65
+ /** @param {() => boolean} predicate - Completion check. @param {import('node:child_process').ChildProcess} child - Child process. @param {() => string} output - Captured output. */
66
+ async function waitUntil(predicate, child, output) {
67
+ if (predicate()) return
68
+ await withTimeout(new Promise((resolve, reject) => {
69
+ const onData = () => { if (predicate()) { cleanup(); resolve(undefined) } }
70
+ const onExit = (/** @type {number | null} */ code) => { cleanup(); reject(new Error(`process exited ${code}: ${output()}`)) }
71
+ const cleanup = () => { child.stdout?.off("data", onData); child.stderr?.off("data", onData); child.off("exit", onExit) }
72
+ child.stdout?.on("data", onData)
73
+ child.stderr?.on("data", onData)
74
+ child.once("exit", onExit)
75
+ }), "process startup")
76
+ }
77
+
78
+ /** @template T @param {Promise<T>} promise - Work. @param {string} label - Timeout label. @returns {Promise<T>} Result. */
79
+ async function withTimeout(promise, label) {
80
+ let timer
81
+ try {
82
+ return await Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`Timed out: ${label}`)), TIMEOUT_MS) })])
83
+ } finally {
84
+ if (timer !== undefined) clearTimeout(timer)
85
+ }
86
+ }
@@ -154,6 +154,9 @@ test("SIGKILLed reverse client process recovers its durable stream while hub gat
154
154
  assert.equal(recovered.openFrames, 0)
155
155
  assert(/** @type {number} */ (recovered.generation) > /** @type {number} */ (connected.generation))
156
156
  assert.equal(targetAccepts, 1)
157
+ replacement.child.send({type: "acknowledge", bytes: replayedTargetBytes.length})
158
+ const acknowledged = await waitForMessage(replacement, "acknowledged")
159
+ assert.equal(acknowledged.sequence, replayedTargetBytes.length)
157
160
  await waitUntil(() => Buffer.concat(targetReceived).toString() === "before-killduring-kill", "replay strictly above gateway high-water")
158
161
 
159
162
  replacement.child.send({type: "write", payload: Buffer.from("after-kill").toString("base64")})
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
2
2
  import assert from "node:assert/strict"
3
3
  import {spawn} from "node:child_process"
4
- import {mkdtemp, readFile, readdir, rm} from "node:fs/promises"
4
+ import {mkdtemp, rm} from "node:fs/promises"
5
5
  import {createServer} from "node:net"
6
6
  import {tmpdir} from "node:os"
7
7
  import {join} from "node:path"
@@ -9,10 +9,12 @@ import test from "node:test"
9
9
  import {connect as connectTls} from "node:tls"
10
10
 
11
11
  import {ReverseGatewayConnection} from "../../src/reverse/client.js"
12
+ import {readCompressedLogDirectory} from "../../src/logging/compressed-log-reader.js"
12
13
  import {generateTestCertificates} from "../helpers/tls-certificates.js"
13
14
 
14
15
  const PROCESS_TIMEOUT_MS = 8_000
15
16
  const PROCESS_LISTEN_ATTEMPTS = 3
17
+ const TEST_FINGERPRINT_KEY = Buffer.alloc(32, 0x5a).toString("base64url")
16
18
 
17
19
  /** A child exit observed before its expected startup event. */
18
20
  class ProcessStartupExitError extends Error {
@@ -234,8 +236,9 @@ test("spawned reverse hub and outbound gateway expose an allowlisted Docker stre
234
236
  SOCKETDUCT_REVERSE_ROUTES: `tensorbuzz:o2:127.0.0.1:${targetPort}`,
235
237
  SOCKETDUCT_STATUS_INTERVAL_MS: "0",
236
238
  SOCKETDUCT_LOG_DIRECTORY: logDirectory,
237
- SOCKETDUCT_LOG_MAX_FILE_BYTES: "4096",
238
- SOCKETDUCT_LOG_MAX_FILES: "2"
239
+ SOCKETDUCT_LOG_SEGMENT_MAX_BYTES: "4096",
240
+ SOCKETDUCT_CONNECTION_FINGERPRINT_KEY: TEST_FINGERPRINT_KEY,
241
+ SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH: "process-test"
239
242
  }, "reverse-hub.started")
240
243
  t.after(() => stopProcess(hub))
241
244
  const gateway = await startProcess({
@@ -250,8 +253,7 @@ test("spawned reverse hub and outbound gateway expose an allowlisted Docker stre
250
253
  SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetPort}`,
251
254
  SOCKETDUCT_STATUS_INTERVAL_MS: "0",
252
255
  SOCKETDUCT_LOG_DIRECTORY: logDirectory,
253
- SOCKETDUCT_LOG_MAX_FILE_BYTES: "4096",
254
- SOCKETDUCT_LOG_MAX_FILES: "2"
256
+ SOCKETDUCT_LOG_SEGMENT_MAX_BYTES: "4096"
255
257
  }, "reverse-gateway.started")
256
258
  t.after(() => stopProcess(gateway))
257
259
 
@@ -270,19 +272,18 @@ test("spawned reverse hub and outbound gateway expose an allowlisted Docker stre
270
272
  assert.equal(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(response, "process response"))).toString(), "docker:GET /version")
271
273
  assert.equal(client.status().compression, "zstd")
272
274
  socket.destroy()
275
+ client.close()
276
+ await stopProcess(gateway)
277
+ await stopProcess(hub)
273
278
 
274
- assert(hub.pid !== undefined)
275
- assert(gateway.pid !== undefined)
276
- const files = (await readdir(logDirectory)).sort()
277
- assert(files.includes(`reverse-hub-${hub.pid}.jsonl`))
278
- assert(files.includes(`reverse-gateway-${gateway.pid}.jsonl`))
279
- const hubEntries = (await readFile(join(logDirectory, `reverse-hub-${hub.pid}.jsonl`), "utf8")).trim().split("\n").map((line) => JSON.parse(line))
280
- const gatewayContents = await readFile(join(logDirectory, `reverse-gateway-${gateway.pid}.jsonl`), "utf8")
281
- const gatewayEntries = gatewayContents.trim().split("\n").map((line) => JSON.parse(line))
279
+ const logs = await readCompressedLogDirectory(logDirectory)
280
+ assert.equal(logs.incomplete, false)
281
+ const hubEntries = logs.records.filter(({role}) => role === "reverse-hub")
282
+ const gatewayEntries = logs.records.filter(({role}) => role === "reverse-gateway")
282
283
  assert(hubEntries.some(({event}) => event === "process.started"))
283
284
  assert(gatewayEntries.some(({event}) => event === "process.started"))
284
285
  assert(gatewayEntries.some(({event}) => event === "transport.connected"))
285
- assert.doesNotMatch(gatewayContents, /process-gateway-token|127\.0\.0\.1|docker-server/)
286
+ assert.doesNotMatch(JSON.stringify(logs.records), /process-gateway-token|127\.0\.0\.1|docker-server|process_home_o2_gateway/)
286
287
  })
287
288
 
288
289
  test("one route-class client physical connection interleaves streams through different gateway processes", async (t) => {
@@ -37,6 +37,7 @@ const client = new ReverseGatewayConnection({
37
37
 
38
38
  const streamOptions = {
39
39
  recover: true,
40
+ manualReceiveAck: true,
40
41
  identifier: configuration.identifier,
41
42
  sessionName: configuration.sessionName,
42
43
  host: "127.0.0.1",
@@ -74,7 +75,8 @@ if (activeSocket !== undefined) {
74
75
  identifier: configuration.identifier,
75
76
  sessionName: configuration.sessionName,
76
77
  host: "127.0.0.1",
77
- port: configuration.targetPort
78
+ port: configuration.targetPort,
79
+ manualReceiveAck: true
78
80
  })
79
81
  installSocketEvents(activeSocket)
80
82
  }
@@ -86,7 +88,17 @@ process.on("message", (message) => {
86
88
  return
87
89
  }
88
90
  if (message?.type === "write") {
89
- activeSocket.write(Buffer.from(message.payload, "base64"), (error) => report("written", {code: error?.code ?? null}))
91
+ void activeSocket.writeDurable(Buffer.from(message.payload, "base64")).then(
92
+ () => report("written", {code: null}),
93
+ (error) => report("written", {code: error?.code ?? "ERROR"})
94
+ )
95
+ return
96
+ }
97
+ if (message?.type === "acknowledge") {
98
+ void activeSocket.acknowledgeReceive(message.bytes).then(
99
+ (sequence) => report("acknowledged", {sequence}),
100
+ (error) => report("acknowledge-error", {code: error?.code ?? "ERROR"})
101
+ )
90
102
  return
91
103
  }
92
104
  if (message?.type === "observe-buffer") {
@@ -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
+ }
@@ -431,11 +431,7 @@ test("process replacement recovers a durable stream on its original named gatewa
431
431
  t.after(() => { SessionSpool.prototype.readChunksAfter = originalReadChunksAfter })
432
432
  replacement = new MultiplexedSocketductTransportSet(setOptions)
433
433
  const recovered = replacement.recoverConnection({
434
- gatewayId: "starlink",
435
- host: "127.0.0.1",
436
- port: targetPort,
437
- identifier: "gateway_bound_stream_01",
438
- sessionName: "gateway-bound"
434
+ identifier: "gateway_bound_stream_01"
439
435
  })
440
436
  await recoveryRead
441
437
  await new Promise((resolve) => setImmediate(resolve))