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,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
+ }
@@ -0,0 +1,170 @@
1
+ /* eslint-disable jsdoc/require-returns */
2
+ import assert from "node:assert/strict"
3
+ import {fork} from "node:child_process"
4
+ import {once} from "node:events"
5
+ import {mkdtemp, readFile, rm} from "node:fs/promises"
6
+ import {createServer} from "node:net"
7
+ import {tmpdir} from "node:os"
8
+ import {join} from "node:path"
9
+ import test from "node:test"
10
+
11
+ import {StaticTokenStore} from "../../src/auth/static-token-store.js"
12
+ import {OutboundReverseGateway, startReverseGatewayHub} from "../../src/reverse/index.js"
13
+ import {generateTestCertificates} from "../helpers/tls-certificates.js"
14
+
15
+ /** @typedef {{gatewayId: string, connectionId: string, hubPort: number, token: string, caFile: string, spoolDirectory: string, identifier: string, sessionName: string, targetPort: number, initialGeneration: number, phase: string}} RecoveryFixtureConfiguration */
16
+
17
+ /** @param {import('node:net').Server} server - Server. */
18
+ async function listen(server) {
19
+ await new Promise((resolve, reject) => {
20
+ server.once("error", reject)
21
+ server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
22
+ })
23
+ const address = server.address()
24
+ assert(address !== null && typeof address !== "string")
25
+ return address.port
26
+ }
27
+
28
+ /**
29
+ * @param {() => boolean} predicate - Completion predicate.
30
+ * @param {string} label - Timeout label.
31
+ */
32
+ async function waitUntil(predicate, label) {
33
+ const deadline = Date.now() + 8_000
34
+ while (!predicate()) {
35
+ if (Date.now() >= deadline) throw new Error(`Timed out: ${label}`)
36
+ await new Promise((resolve) => setTimeout(resolve, 10))
37
+ }
38
+ }
39
+
40
+ /** @param {RecoveryFixtureConfiguration} configuration - Child configuration. */
41
+ function startClient(configuration) {
42
+ const child = fork("test/fixtures/reverse-client-recovery.mjs", [], {
43
+ cwd: process.cwd(),
44
+ env: {...process.env, SOCKETDUCT_RECOVERY_FIXTURE: JSON.stringify(configuration)},
45
+ stdio: ["ignore", "pipe", "pipe", "ipc"]
46
+ })
47
+ /** @type {Record<string, unknown>[]} */
48
+ const messages = []
49
+ let stderr = ""
50
+ child.on("message", (message) => messages.push(/** @type {Record<string, unknown>} */ (message)))
51
+ child.stderr?.on("data", (chunk) => { stderr += chunk.toString() })
52
+ return {child, messages, stderr: () => stderr}
53
+ }
54
+
55
+ /**
56
+ * @param {ReturnType<typeof startClient>} processState - Child and captured messages.
57
+ * @param {string} type - Expected message type.
58
+ */
59
+ async function waitForMessage(processState, type) {
60
+ await waitUntil(() => processState.messages.some((message) => message.type === type) || processState.child.exitCode !== null, `child message ${type}`)
61
+ const message = processState.messages.find((candidate) => candidate.type === type)
62
+ if (message === undefined) throw new Error(`Child exited before ${type}: ${processState.stderr()}`)
63
+ return message
64
+ }
65
+
66
+ test("SIGKILLed reverse client process recovers its durable stream while hub gateway and target survive", async (t) => {
67
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-client-process-recovery-"))
68
+ t.after(() => rm(root, {recursive: true, force: true}))
69
+ const certificates = generateTestCertificates(root)
70
+ const spoolDirectory = join(root, "client-spool")
71
+ let targetAccepts = 0
72
+ /** @type {import('node:net').Socket | undefined} */
73
+ let targetSocket
74
+ const targetSockets = new Set()
75
+ /** @type {Buffer[]} */
76
+ const targetReceived = []
77
+ const target = createServer((socket) => {
78
+ targetAccepts += 1
79
+ targetSocket = socket
80
+ targetSockets.add(socket)
81
+ socket.once("close", () => targetSockets.delete(socket))
82
+ socket.on("data", (chunk) => targetReceived.push(Buffer.from(chunk)))
83
+ })
84
+ const targetPort = await listen(target)
85
+ t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
86
+
87
+ const hub = await startReverseGatewayHub({
88
+ tokens: new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-process-token"}]),
89
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
90
+ authorizeGateway: () => true,
91
+ authorizeClientGateway: () => true,
92
+ authorizeOpen: () => true,
93
+ resumeWindowMs: 4_000
94
+ })
95
+ t.after(() => hub.close())
96
+ const gateway = new OutboundReverseGateway({
97
+ gatewayId: "o2",
98
+ connectionId: "process_recovery_gateway",
99
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
100
+ tls: {caFile: certificates.caCertFile, servername: "localhost"},
101
+ spoolDirectory: join(root, "gateway-spool"),
102
+ authorizeTarget: () => true,
103
+ reconnectDelayMs: 10,
104
+ maxReconnectDelayMs: 20
105
+ })
106
+ t.after(() => gateway.close())
107
+ await gateway.start()
108
+
109
+ const configuration = {
110
+ gatewayId: "o2",
111
+ connectionId: "process_recovery_client",
112
+ hubPort: hub.port,
113
+ token: "client-process-token",
114
+ caFile: certificates.caCertFile,
115
+ spoolDirectory,
116
+ identifier: "process_recovery_stream",
117
+ sessionName: "durable-process-stream",
118
+ targetPort,
119
+ initialGeneration: 100
120
+ }
121
+ const original = startClient({...configuration, phase: "original"})
122
+ t.after(() => { if (original.child.exitCode === null) original.child.kill("SIGKILL") })
123
+ const connected = await waitForMessage(original, "connected")
124
+ original.child.send({type: "write", payload: Buffer.from("before-kill").toString("base64")})
125
+ await waitForMessage(original, "written")
126
+ await waitUntil(() => Buffer.concat(targetReceived).toString() === "before-kill", "original target request")
127
+ assert.equal(targetAccepts, 1)
128
+ assert(targetSocket !== undefined)
129
+
130
+ original.child.send({type: "hold-next-data"})
131
+ await waitForMessage(original, "data-hold-armed")
132
+ original.child.send({type: "write", payload: Buffer.from("during-kill").toString("base64")})
133
+ await waitForMessage(original, "data-held")
134
+ await waitUntil(() => original.messages.filter((message) => message.type === "written").length === 2, "durably spooled held request")
135
+ assert.equal(Buffer.concat(targetReceived).toString(), "before-kill")
136
+
137
+ const replayedTargetBytes = Buffer.from("accepted-but-not-durably-acked")
138
+ original.child.send({type: "observe-buffer", bytes: replayedTargetBytes.length})
139
+ targetSocket.write(replayedTargetBytes)
140
+ await waitForMessage(original, "buffered")
141
+ original.child.send({type: "report-acks"})
142
+ const acknowledgements = await waitForMessage(original, "acks")
143
+ assert.equal(acknowledgements.ackFrames, 0, "unread process-local bytes must not be durably acknowledged")
144
+ await waitUntil(() => gateway.status().spoolBytes === replayedTargetBytes.length, "gateway retains unacknowledged target bytes")
145
+ const originalExit = once(original.child, "exit")
146
+ original.child.kill("SIGKILL")
147
+ await originalExit
148
+
149
+ const replacement = startClient({...configuration, phase: "replacement"})
150
+ t.after(() => { if (replacement.child.exitCode === null) replacement.child.kill("SIGKILL") })
151
+ const recovered = await waitForMessage(replacement, "recovered")
152
+ const replay = await waitForMessage(replacement, "data")
153
+ assert.equal(Buffer.from(/** @type {string} */ (replay.payload), "base64").toString(), replayedTargetBytes.toString())
154
+ assert.equal(recovered.openFrames, 0)
155
+ assert(/** @type {number} */ (recovered.generation) > /** @type {number} */ (connected.generation))
156
+ assert.equal(targetAccepts, 1)
157
+ await waitUntil(() => Buffer.concat(targetReceived).toString() === "before-killduring-kill", "replay strictly above gateway high-water")
158
+
159
+ replacement.child.send({type: "write", payload: Buffer.from("after-kill").toString("base64")})
160
+ await waitForMessage(replacement, "written")
161
+ await waitUntil(() => Buffer.concat(targetReceived).toString() === "before-killduring-killafter-kill", "recovered target request")
162
+ assert.equal(targetAccepts, 1)
163
+ const persisted = await readFile(join(spoolDirectory, configuration.identifier, "recovery.json"), "utf8")
164
+ assert.doesNotMatch(persisted, /client-process-token|gateway-token/)
165
+
166
+ const replacementExit = once(replacement.child, "exit")
167
+ replacement.child.send({type: "close"})
168
+ await replacementExit
169
+ assert.equal(replacement.child.exitCode, 0, replacement.stderr())
170
+ })
@@ -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) => {
@@ -0,0 +1,123 @@
1
+ import {connect as connectTls} from "node:tls"
2
+
3
+ import {V3FrameType} from "../../src/protocol/v3-frame.js"
4
+ import {ReverseGatewayConnection} from "../../src/reverse/client.js"
5
+
6
+ const configuration = JSON.parse(process.env.SOCKETDUCT_RECOVERY_FIXTURE ?? "null")
7
+ if (configuration === null) throw new Error("Missing recovery fixture configuration")
8
+
9
+ let openFrames = 0
10
+ let ackFrames = 0
11
+ let holdNextData = false
12
+ const client = new ReverseGatewayConnection({
13
+ gatewayId: configuration.gatewayId,
14
+ connectionId: configuration.connectionId,
15
+ hub: {host: "127.0.0.1", port: configuration.hubPort, token: configuration.token},
16
+ tls: {caFile: configuration.caFile, servername: "localhost"},
17
+ spoolDirectory: configuration.spoolDirectory,
18
+ initialGeneration: configuration.phase === "replacement" ? 0 : configuration.initialGeneration,
19
+ reconnectDelayMs: 10,
20
+ maxReconnectDelayMs: 20,
21
+ createPhysicalSocket: (options) => {
22
+ const physical = connectTls({host: options.host, port: options.port, ...options.tls})
23
+ const write = physical.write.bind(physical)
24
+ physical.write = ((chunk, ...args) => {
25
+ if (Buffer.isBuffer(chunk) && chunk.length >= 6 && chunk[5] === V3FrameType.OPEN) openFrames += 1
26
+ if (Buffer.isBuffer(chunk) && chunk.length >= 6 && chunk[5] === V3FrameType.ACK) ackFrames += 1
27
+ if (holdNextData && Buffer.isBuffer(chunk) && chunk.length >= 6 && chunk[5] === V3FrameType.DATA) {
28
+ holdNextData = false
29
+ report("data-held")
30
+ return true
31
+ }
32
+ return Reflect.apply(write, physical, [chunk, ...args])
33
+ })
34
+ return physical
35
+ }
36
+ })
37
+
38
+ const streamOptions = {
39
+ recover: true,
40
+ identifier: configuration.identifier,
41
+ sessionName: configuration.sessionName,
42
+ host: "127.0.0.1",
43
+ port: configuration.targetPort
44
+ }
45
+ const socket = configuration.phase === "replacement"
46
+ ? client.registerRecoveredConnection(streamOptions)
47
+ : undefined
48
+
49
+ /**
50
+ * @param {string} type - Parent message type.
51
+ * @param {Record<string, unknown>} [fields] - Message fields.
52
+ */
53
+ function report(type, fields = {}) {
54
+ process.send?.({type, ...fields})
55
+ }
56
+
57
+ /** @param {import('../../src/client/virtual-socket.js').VirtualSocket} activeSocket - Stream under test. */
58
+ function installSocketEvents(activeSocket) {
59
+ activeSocket.once("connect", () => report(configuration.phase === "replacement" ? "recovered" : "connected", {
60
+ generation: client.status().connectionGeneration,
61
+ openFrames
62
+ }))
63
+ activeSocket.on("error", (error) => report("stream-error", {code: error.code ?? "ERROR"}))
64
+ }
65
+
66
+ let activeSocket = socket
67
+ if (activeSocket !== undefined) {
68
+ installSocketEvents(activeSocket)
69
+ activeSocket.on("data", (chunk) => report("data", {payload: Buffer.from(chunk).toString("base64")}))
70
+ await client.connect()
71
+ } else {
72
+ await client.connect()
73
+ activeSocket = client.createConnection({
74
+ identifier: configuration.identifier,
75
+ sessionName: configuration.sessionName,
76
+ host: "127.0.0.1",
77
+ port: configuration.targetPort
78
+ })
79
+ installSocketEvents(activeSocket)
80
+ }
81
+
82
+ process.on("message", (message) => {
83
+ if (message?.type === "hold-next-data") {
84
+ holdNextData = true
85
+ report("data-hold-armed")
86
+ return
87
+ }
88
+ if (message?.type === "write") {
89
+ activeSocket.write(Buffer.from(message.payload, "base64"), (error) => report("written", {code: error?.code ?? null}))
90
+ return
91
+ }
92
+ if (message?.type === "observe-buffer") {
93
+ const timer = setInterval(() => {
94
+ if (activeSocket.readableLength < message.bytes) return
95
+ clearInterval(timer)
96
+ report("buffered", {bytes: activeSocket.readableLength})
97
+ }, 5)
98
+ timer.unref?.()
99
+ return
100
+ }
101
+ if (message?.type === "report-acks") {
102
+ setTimeout(() => report("acks", {ackFrames}), 50)
103
+ return
104
+ }
105
+ if (message?.type === "close") {
106
+ activeSocket.destroy()
107
+ void client.close().then(() => process.exit(0), (error) => {
108
+ report("close-error", {code: error?.code ?? "ERROR"})
109
+ process.exit(1)
110
+ })
111
+ }
112
+ })
113
+
114
+ process.on("uncaughtException", (error) => {
115
+ report("fatal", {message: error.message, code: error.code ?? "ERROR"})
116
+ process.exit(1)
117
+ })
118
+
119
+ process.on("unhandledRejection", (error) => {
120
+ const failure = error instanceof Error ? error : new Error(String(error))
121
+ report("fatal", {message: failure.message, code: failure.code ?? "ERROR"})
122
+ process.exit(1)
123
+ })
@@ -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
+ }