socketduct 0.1.25 → 0.1.27

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 (58) hide show
  1. package/AGENTS.md +2 -2
  2. package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
  3. package/changelog.d/20261025070000-reclaim-unreadable-retention-lock.md +14 -0
  4. package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
  5. package/package.json +2 -2
  6. package/scripts/test.js +9 -14
  7. package/src/logging/compressed-log-writer.js +1 -1
  8. package/src/logging/log-retention.js +36 -1
  9. package/src/logging/zstd-frame-codec.js +15 -1
  10. package/test/e2e/gateway-process.test.js +93 -92
  11. package/test/e2e/logger-shutdown-process.test.js +40 -37
  12. package/test/e2e/reverse-client-recovery-process.test.js +107 -103
  13. package/test/e2e/reverse-gateway-process.test.js +328 -318
  14. package/test/integration/initial-target-recovery.test.js +333 -334
  15. package/test/integration/logging-cli.test.js +121 -119
  16. package/test/integration/logging-compressed-recovery.test.js +96 -95
  17. package/test/integration/multiplexed-compatibility.test.js +178 -177
  18. package/test/integration/multiplexed-corrections.test.js +360 -359
  19. package/test/integration/multiplexed-failures.test.js +253 -251
  20. package/test/integration/multiplexed-transport-set.test.js +334 -322
  21. package/test/integration/multiplexed-transport.test.js +135 -134
  22. package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
  23. package/test/integration/reverse-gateway.test.js +2309 -2218
  24. package/test/integration/reverse-resume-continuity.test.js +219 -218
  25. package/test/integration/reverse-route-class.test.js +358 -341
  26. package/test/integration/socketduct-http-agent.test.js +314 -312
  27. package/test/integration/socketduct-https-agent.test.js +516 -485
  28. package/test/integration/stream-compression.test.js +862 -824
  29. package/test/integration/tcp-forwarding.test.js +2550 -2447
  30. package/test/integration/tls-forwarding.test.js +593 -592
  31. package/test/unit/auth/auth.test.js +43 -44
  32. package/test/unit/auth/static-token-store.test.js +9 -8
  33. package/test/unit/client/connect.test.js +41 -40
  34. package/test/unit/client/deferred-virtual-socket.test.js +95 -94
  35. package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
  36. package/test/unit/client/multiplexed-transport-set.test.js +830 -748
  37. package/test/unit/client/socketduct-socket.test.js +768 -771
  38. package/test/unit/client/virtual-socket.test.js +198 -168
  39. package/test/unit/config/docker-gateway-files.test.js +224 -224
  40. package/test/unit/config/environment.test.js +472 -492
  41. package/test/unit/index.test.js +53 -52
  42. package/test/unit/logging/compressed-log-worker.test.js +23 -22
  43. package/test/unit/logging/compressed-log-writer.test.js +155 -154
  44. package/test/unit/logging/log-retention.test.js +71 -48
  45. package/test/unit/logging/log-segment-index.test.js +84 -81
  46. package/test/unit/logging/zstd-frame-codec.test.js +28 -19
  47. package/test/unit/protocol/compression.test.js +254 -298
  48. package/test/unit/protocol/frame.test.js +280 -276
  49. package/test/unit/protocol/v3-compression.test.js +21 -23
  50. package/test/unit/protocol/v3-frame.test.js +42 -41
  51. package/test/unit/release-configuration.test.js +15 -12
  52. package/test/unit/reverse/disconnect-attribution.test.js +65 -64
  53. package/test/unit/reverse-terminal-outcomes.test.js +53 -50
  54. package/test/unit/server/control-handler.test.js +232 -231
  55. package/test/unit/server/primary.test.js +51 -49
  56. package/test/unit/server/relay-runtime.test.js +280 -294
  57. package/test/unit/sessions/session.test.js +48 -62
  58. package/test/unit/spool/spool-file.test.js +401 -385
package/AGENTS.md CHANGED
@@ -160,9 +160,9 @@ docs/
160
160
  ## Definition of done for implementation PRs
161
161
 
162
162
  - Add a release-note fragment for every user-visible change, bug fix, security fix, or protocol change under `changelog.d/YYYYMMDDHHMMSS-short-slug.md`. Keep it to one concise, user-facing sentence; do not edit generated release notes directly.
163
- - Tests added or updated.
163
+ - Tests added or updated — test modules declare `describe`/`it`/`expect` from `@velocious/testing`; `node:test`/`node:assert` are not used in test modules.
164
164
  - Relevant docs updated.
165
- - `npm test` passes.
165
+ - `npm test` passes (every `test/**/*.test.js` module runs through the `velocious-test` runner).
166
166
  - Lint/typecheck passes once configured.
167
167
  - Security-sensitive changes mention how credentials/tokens are protected.
168
168
  - No open proxy behavior introduced.
@@ -0,0 +1 @@
1
+ Diagnostic log frames now default to zstd level 9 instead of 19: zstd context memory scales steeply with the level (~1-2 MB per compressed batch at 9 versus ~90 MB at 19), and per-batch contexts accumulated into a multi-GB resident/peak-RSS ratchet in long-lived processes that no JS-level accounting or GC pressure sees.
@@ -0,0 +1,14 @@
1
+ # Reclaim unreadable retention locks
2
+
3
+ Retention now reclaims a `.socketduct-retention.lock` whose owner record this
4
+ process cannot read instead of failing the whole run. A lock written by a
5
+ privileged one-off process (a root repair or cleanup) is mode 0600 and owned by
6
+ another uid, so every later retention attempt raised `EACCES` from
7
+ `readOwnerFile`. That error propagated out of the durable log sink at startup and
8
+ took down the server process that owns the sink.
9
+
10
+ The lock directory is private and peer writers share this process's uid, so an
11
+ unreadable record cannot belong to a live peer of the runtime. Retention now
12
+ unlinks such a file and retries the atomic link. If the file cannot be removed,
13
+ retention fails with `LOG_RETENTION_LOCK_UNREADABLE`, which names the lock path
14
+ and the owning uid instead of surfacing a bare `EACCES`.
@@ -35,12 +35,14 @@ npm run lint
35
35
  git diff --check
36
36
  ```
37
37
 
38
- Run the two focused modules sequentially with the published `@velocious/testing`
39
- runner. `npm test` is the CI entry point: it discovers untouched legacy modules
40
- for Node's runner and then invokes the owning runner separately for each migrated
41
- logger module. `npm test -- --list` prints both selections without running tests.
42
- Do not run the full suite or shards locally during a correction pass; CI owns
43
- that coverage. Test cleanup uses the package's supported FIFO `withTestCleanup`.
38
+ Run the focused modules sequentially with the published `@velocious/testing`
39
+ runner. `npm test` is the CI entry point: it discovers every module under
40
+ `test/**/*.test.js` and runs each one through the `velocious-test` runner in
41
+ sorted order. `npm test -- --list` prints the discovered modules without running
42
+ tests, and `npm test -- <path-or-args>` forwards arguments to a focused runner
43
+ invocation. Do not run the full suite or shards locally during a correction
44
+ pass; CI owns that coverage. Test cleanup uses the package's supported FIFO
45
+ `withTestCleanup`.
44
46
 
45
47
  The process tests create and remove only their own private home subdirectories;
46
48
  the canonical `/home/dev` bind persists across child-process exits. They reuse
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socketduct",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "Resilient authenticated tunnel/proxy for keeping socket streams alive across flaky links.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -38,7 +38,7 @@
38
38
  "devDependencies": {
39
39
  "@eslint/js": "^10.0.1",
40
40
  "@types/node": "^24.0.0",
41
- "@velocious/testing": "0.0.10",
41
+ "@velocious/testing": "^0.0.18",
42
42
  "awaitery": "^1.0.21",
43
43
  "eslint": "^10.0.3",
44
44
  "eslint-plugin-jsdoc": "^62.7.1",
package/scripts/test.js CHANGED
@@ -1,24 +1,19 @@
1
1
  import {spawnSync} from "node:child_process"
2
2
  import {globSync} from "node:fs"
3
3
 
4
- const migrated = ["test/unit/logging/structured-logger.test.js", "test/integration/logging-recovery.test.js"]
4
+ const args = process.argv.slice(2)
5
5
  const files = globSync("test/**/*.test.js").sort()
6
- for (const path of migrated) {
7
- if (!files.includes(path)) throw new Error(`Missing migrated test module: ${path}`)
8
- }
9
- const legacy = files.filter((path) => !migrated.includes(path))
10
- if (legacy.length === 0) throw new Error("No legacy test modules discovered")
11
- const commands = [
12
- {runner: process.execPath, args: ["--test", ...legacy]},
13
- ...migrated.map((path) => ({runner: "velocious-test", args: [path]}))
14
- ]
6
+ if (files.length === 0) throw new Error("No test modules discovered")
15
7
 
16
- if (process.argv.length === 3 && process.argv[2] === "--list") {
17
- console.log(JSON.stringify({legacy, migrated, commands}, null, 2))
8
+ if (args.length === 1 && args[0] === "--list") {
9
+ console.log(JSON.stringify({files}, null, 2))
18
10
  } else {
19
- if (process.argv.length !== 2) throw new Error("Only --list is supported; use the owning runner for focused tests")
11
+ const commands = args.length === 0
12
+ ? files.map((path) => ({args: [path], label: path}))
13
+ : [{args, label: args.join(" ")}]
20
14
  for (const command of commands) {
21
- const result = spawnSync(command.runner, command.args, {stdio: "inherit"})
15
+ if (commands.length > 1) console.log(`\n=== ${command.label} ===`)
16
+ const result = spawnSync("velocious-test", command.args, {stdio: "inherit"})
22
17
  if (result.error) throw result.error
23
18
  if (result.status !== 0) {
24
19
  process.exitCode = result.status ?? 1
@@ -36,7 +36,7 @@ const NONCRITICAL_EVENTS = new Set(["runtime.status", "transport.connecting", "t
36
36
  * @property {number} [maxSegmentBytes] - Maximum compressed segment size.
37
37
  * @property {number} [maxQueueBytes] - Shared producer/worker allocation bound.
38
38
  * @property {number} [criticalReserveBytes] - Queue bytes reserved for critical events.
39
- * @property {number} [compressionLevel] - Explicit zstd level, default 19.
39
+ * @property {number} [compressionLevel] - Explicit zstd level, default 9. High levels (12+) allocate tens of MB of zstd context memory per compressed batch; see `DEFAULT_LOG_COMPRESSION_LEVEL`.
40
40
  * @property {number} [retentionDays] - Closed-segment maximum age, default 30 days.
41
41
  * @property {number} [retentionBytes] - Directory quota, default 4 GiB.
42
42
  * @property {{write(chunk: string): unknown}} [stderr] - Secret-free health reporting destination.
@@ -94,7 +94,20 @@ async function acquireRetentionLock(directory, lockPath) {
94
94
  for (let attempt = 0; attempt < 3; attempt += 1) {
95
95
  const created = await createOwnerFile(directory, lockPath, owner)
96
96
  if (created !== undefined) return created
97
- const existing = await readOwnerFile(lockPath)
97
+ let existing
98
+ try {
99
+ existing = await readOwnerFile(lockPath)
100
+ } catch (error) {
101
+ // A lock this process cannot read cannot belong to a live peer: peers write
102
+ // their owner record mode 0600 under their own uid into this private
103
+ // directory. Such a file is left behind by a privileged one-off process
104
+ // (a root-run repair or cleanup), and because its record is unreadable here
105
+ // it would block retention for every later run - failing the durable log
106
+ // sink and, with it, the server process that owns the sink.
107
+ if (!isCode(error, "EACCES") && !isCode(error, "EPERM")) throw error
108
+ await reclaimUnreadableLock(directory, lockPath)
109
+ continue
110
+ }
98
111
  if (existing === undefined) continue
99
112
  if (await ownerIsLive(existing.owner)) return undefined
100
113
  const current = await lstat(lockPath).catch((error) => { if (isCode(error, "ENOENT")) return undefined; throw error })
@@ -106,6 +119,28 @@ async function acquireRetentionLock(directory, lockPath) {
106
119
  return undefined
107
120
  }
108
121
 
122
+ /**
123
+ * Removes a retention lock that this process cannot read, so the caller can retry
124
+ * the atomic link. Unlink is attempted rather than deferred: the directory is
125
+ * private, and a lock whose owner record is unreadable here cannot be held by a
126
+ * peer of this runtime.
127
+ * @param {string} directory - Private log directory.
128
+ * @param {string} lockPath - Unreadable lock path.
129
+ */
130
+ async function reclaimUnreadableLock(directory, lockPath) {
131
+ const stats = await lstat(lockPath).catch((error) => { if (isCode(error, "ENOENT")) return undefined; throw error })
132
+ if (stats === undefined) return
133
+ if (!stats.isFile()) throw Object.assign(new Error("Unsafe retention lock"), {code: "LOG_RETENTION_LOCK_INVALID"})
134
+ await unlink(lockPath).catch((error) => {
135
+ if (isCode(error, "ENOENT")) return
136
+ throw Object.assign(
137
+ new Error(`Retention lock ${lockPath} is owned by uid ${stats.uid} and cannot be reclaimed by uid ${process.getuid?.()}: ${error instanceof Error ? error.message : String(error)}`),
138
+ {cause: error, code: "LOG_RETENTION_LOCK_UNREADABLE"}
139
+ )
140
+ })
141
+ await syncDirectory(directory)
142
+ }
143
+
109
144
  /**
110
145
  * @param {string} directory - Directory.
111
146
  * @param {string} lockPath - Destination.
@@ -2,7 +2,21 @@
2
2
  import {promisify} from "node:util"
3
3
  import {constants, zstdCompress, zstdDecompress} from "node:zlib"
4
4
 
5
- export const DEFAULT_LOG_COMPRESSION_LEVEL = 19
5
+ /**
6
+ * Default zstd level for per-batch diagnostic log frames.
7
+ *
8
+ * Every batch is compressed as its own frame, and a fresh zstd context's
9
+ * memory scales steeply with the level: level 9 allocates ~1-2 MB per call,
10
+ * level 12 tens of MB, and level 19 roughly 90 MB. Long-lived processes flush
11
+ * log batches continuously, so a high level turns those per-call context
12
+ * allocations into a multi-GB resident/peak-RSS ratchet that no JS-level
13
+ * accounting sees (the memory is freed by the compressor but retained by the
14
+ * allocator while the producing worker's isolate never pressures GC). JSONL
15
+ * diagnostics already compress 50x+ at level 9, and level 19 buys only ~15%
16
+ * smaller frames at ~20x the CPU and ~50x the per-call memory, so the default
17
+ * stays in the bounded-memory range. Raise it per process only deliberately.
18
+ */
19
+ export const DEFAULT_LOG_COMPRESSION_LEVEL = 9
6
20
 
7
21
  const compress = promisify(zstdCompress)
8
22
  const decompress = promisify(zstdDecompress)
@@ -1,10 +1,9 @@
1
- import assert from "node:assert/strict"
1
+ import {describe, expect, it, withTestCleanup} from "@velocious/testing"
2
2
  import {spawn} from "node:child_process"
3
3
  import {mkdtemp, rm} from "node:fs/promises"
4
4
  import {createConnection, createServer} from "node:net"
5
5
  import {tmpdir} from "node:os"
6
6
  import {join} from "node:path"
7
- import test from "node:test"
8
7
 
9
8
  import {startTcpEdge} from "../../src/edge/tcp-edge.js"
10
9
  import {encodeFrame, FrameDecoder} from "../../src/protocol/frame.js"
@@ -159,104 +158,106 @@ async function stopProcess(child) {
159
158
  }), PROCESS_TIMEOUT_MS, "Timed out waiting for relay shutdown")
160
159
  }
161
160
 
162
- test("e2e process gateway forwards edge traffic to an allowlisted target", async () => {
163
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-e2e-spool-"))
164
- const targetServer = createServer((socket) => {
165
- socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("echo:"), chunk])))
166
- })
167
- const targetPort = await listen(targetServer)
168
- const relayPort = await reservePort()
169
- const relay = await startRelayProcess({
170
- SOCKETDUCT_MODE: "relay",
171
- SOCKETDUCT_RELAY_HOST: "127.0.0.1",
172
- SOCKETDUCT_RELAY_PORT: String(relayPort),
173
- SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:e2e-token-not-placeholder",
174
- SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetPort}`
175
- })
176
-
177
- const edge = await startTcpEdge({
178
- relayHost: "127.0.0.1",
179
- relayPort,
180
- token: "e2e-token-not-placeholder",
181
- sessionName: "e2e process gateway",
182
- target: {host: "127.0.0.1", port: targetPort},
183
- spool: new SessionSpool({rootDir: tempDir}),
184
- reconnectDelayMs: 10
185
- })
186
-
187
- test.after(async () => {
188
- await edge.close()
189
- await stopProcess(relay.process)
190
- await closeServer(targetServer)
191
- await rm(tempDir, {recursive: true, force: true})
192
- })
193
-
194
- const appSocket = createConnection({host: "127.0.0.1", port: edge.port})
195
- await waitConnect(appSocket)
196
- appSocket.write("hello-tensorbuzz")
197
- assert.equal((await readOnce(appSocket)).toString("utf8"), "echo:hello-tensorbuzz")
198
- appSocket.destroy()
199
- })
161
+ describe("e2e process gateway", () => {
162
+ it("e2e process gateway forwards edge traffic to an allowlisted target", async () => withTestCleanup(async (after) => {
163
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-e2e-spool-"))
164
+ const targetServer = createServer((socket) => {
165
+ socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("echo:"), chunk])))
166
+ })
167
+ const targetPort = await listen(targetServer)
168
+ const relayPort = await reservePort()
169
+ const relay = await startRelayProcess({
170
+ SOCKETDUCT_MODE: "relay",
171
+ SOCKETDUCT_RELAY_HOST: "127.0.0.1",
172
+ SOCKETDUCT_RELAY_PORT: String(relayPort),
173
+ SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:e2e-token-not-placeholder",
174
+ SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetPort}`
175
+ })
200
176
 
201
- test("e2e process gateway denies non-allowlisted target requests", async () => {
202
- const allowedTarget = createServer((socket) => {
203
- socket.on("data", (chunk) => socket.write(chunk))
204
- })
205
- const allowedPort = await listen(allowedTarget)
206
- const blockedPort = await reservePort()
207
- const relayPort = await reservePort()
208
- const relay = await startRelayProcess({
209
- SOCKETDUCT_MODE: "relay",
210
- SOCKETDUCT_RELAY_HOST: "127.0.0.1",
211
- SOCKETDUCT_RELAY_PORT: String(relayPort),
212
- SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:e2e-token-not-placeholder",
213
- SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${allowedPort}`
214
- })
177
+ const edge = await startTcpEdge({
178
+ relayHost: "127.0.0.1",
179
+ relayPort,
180
+ token: "e2e-token-not-placeholder",
181
+ sessionName: "e2e process gateway",
182
+ target: {host: "127.0.0.1", port: targetPort},
183
+ spool: new SessionSpool({rootDir: tempDir}),
184
+ reconnectDelayMs: 10
185
+ })
215
186
 
216
- test.after(async () => {
217
- await stopProcess(relay.process)
218
- await closeServer(allowedTarget)
219
- })
187
+ after(async () => {
188
+ await edge.close()
189
+ await stopProcess(relay.process)
190
+ await closeServer(targetServer)
191
+ await rm(tempDir, {recursive: true, force: true})
192
+ })
220
193
 
221
- const tunnel = createConnection({host: "127.0.0.1", port: relayPort})
222
- const decoder = new FrameDecoder()
223
- await waitConnect(tunnel)
224
- tunnel.write(encodeFrame({
225
- type: "session.create",
226
- token: "e2e-token-not-placeholder",
227
- name: "blocked process gateway target",
228
- target: {host: "127.0.0.1", port: blockedPort},
229
- framing: [2]
194
+ const appSocket = createConnection({host: "127.0.0.1", port: edge.port})
195
+ await waitConnect(appSocket)
196
+ appSocket.write("hello-tensorbuzz")
197
+ expect((await readOnce(appSocket)).toString("utf8")).toBe("echo:hello-tensorbuzz")
198
+ appSocket.destroy()
230
199
  }))
231
200
 
232
- const [frame] = decoder.push(await readOnce(tunnel))
233
- assert.equal(frame?.header.type, "error")
234
- assert.equal(frame?.header.code, "TARGET_UNAUTHORIZED")
235
- tunnel.destroy()
236
- })
237
-
238
- test("e2e process gateway rejects placeholder tokens before listening", async () => {
239
- const relayPort = await reservePort()
240
- const child = spawn(process.execPath, ["src/index.js"], {
241
- cwd: process.cwd(),
242
- env: {
243
- ...process.env,
201
+ it("e2e process gateway denies non-allowlisted target requests", async () => withTestCleanup(async (after) => {
202
+ const allowedTarget = createServer((socket) => {
203
+ socket.on("data", (chunk) => socket.write(chunk))
204
+ })
205
+ const allowedPort = await listen(allowedTarget)
206
+ const blockedPort = await reservePort()
207
+ const relayPort = await reservePort()
208
+ const relay = await startRelayProcess({
244
209
  SOCKETDUCT_MODE: "relay",
245
210
  SOCKETDUCT_RELAY_HOST: "127.0.0.1",
246
211
  SOCKETDUCT_RELAY_PORT: String(relayPort),
247
- SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:replace-with-generated-token",
248
- SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:443"
249
- },
250
- stdio: ["ignore", "pipe", "pipe"]
251
- })
252
- let output = ""
253
- child.stdout.on("data", (chunk) => { output += chunk.toString("utf8") })
254
- child.stderr.on("data", (chunk) => { output += chunk.toString("utf8") })
212
+ SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:e2e-token-not-placeholder",
213
+ SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${allowedPort}`
214
+ })
215
+
216
+ after(async () => {
217
+ await stopProcess(relay.process)
218
+ await closeServer(allowedTarget)
219
+ })
255
220
 
256
- const exitCode = await withTimeout(new Promise((resolve) => {
257
- child.once("exit", (code) => resolve(code))
258
- }), PROCESS_TIMEOUT_MS, "Timed out waiting for placeholder-token process exit")
221
+ const tunnel = createConnection({host: "127.0.0.1", port: relayPort})
222
+ const decoder = new FrameDecoder()
223
+ await waitConnect(tunnel)
224
+ tunnel.write(encodeFrame({
225
+ type: "session.create",
226
+ token: "e2e-token-not-placeholder",
227
+ name: "blocked process gateway target",
228
+ target: {host: "127.0.0.1", port: blockedPort},
229
+ framing: [2]
230
+ }))
231
+
232
+ const [frame] = decoder.push(await readOnce(tunnel))
233
+ expect(frame?.header.type).toBe("error")
234
+ expect(frame?.header.code).toBe("TARGET_UNAUTHORIZED")
235
+ tunnel.destroy()
236
+ }))
259
237
 
260
- assert.notEqual(exitCode, 0)
261
- assert.match(output, /placeholder tokens/)
238
+ it("e2e process gateway rejects placeholder tokens before listening", async () => {
239
+ const relayPort = await reservePort()
240
+ const child = spawn(process.execPath, ["src/index.js"], {
241
+ cwd: process.cwd(),
242
+ env: {
243
+ ...process.env,
244
+ SOCKETDUCT_MODE: "relay",
245
+ SOCKETDUCT_RELAY_HOST: "127.0.0.1",
246
+ SOCKETDUCT_RELAY_PORT: String(relayPort),
247
+ SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:replace-with-generated-token",
248
+ SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:443"
249
+ },
250
+ stdio: ["ignore", "pipe", "pipe"]
251
+ })
252
+ let output = ""
253
+ child.stdout.on("data", (chunk) => { output += chunk.toString("utf8") })
254
+ child.stderr.on("data", (chunk) => { output += chunk.toString("utf8") })
255
+
256
+ const exitCode = await withTimeout(new Promise((resolve) => {
257
+ child.once("exit", (code) => resolve(code))
258
+ }), PROCESS_TIMEOUT_MS, "Timed out waiting for placeholder-token process exit")
259
+
260
+ expect(exitCode).not.toBe(0)
261
+ expect(output).toMatch(/placeholder tokens/)
262
+ })
262
263
  })
@@ -1,52 +1,53 @@
1
1
  /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
2
- import assert from "node:assert/strict"
2
+ import {describe, expect, it, withTestCleanup} from "@velocious/testing"
3
3
  import {spawn} from "node:child_process"
4
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"
8
- import test from "node:test"
9
8
 
10
9
  import {readCompressedLogDirectory} from "../../src/logging/compressed-log-reader.js"
11
10
 
12
11
  const TIMEOUT_MS = 8_000
13
12
 
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
- })
13
+ describe("logger shutdown", () => {
14
+ it("SIGTERM durably flushes queued critical lifecycle records before exit", async () => withTestCleanup(async (after) => {
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
+ after(async () => {
38
+ if (child.exitCode === null) child.kill("SIGKILL")
39
+ await rm(root, {recursive: true, force: true})
40
+ })
41
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})
42
+ await waitUntil(() => output.includes('"event":"relay.started"'), child, () => output)
43
+ expect(child.kill("SIGTERM")).toBe(true)
44
+ const exit = await withTimeout(new Promise((resolve) => child.once("exit", (code, signal) => resolve({code, signal}))), "process exit")
45
+ expect(exit).toEqual({code: 0, signal: null})
46
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"])
47
+ const result = await readCompressedLogDirectory(directory)
48
+ expect(result.incomplete).toBe(false)
49
+ expect(result.records.map((record) => record.event)).toEqual(["process.started", "process.stopped", "log_batch_accepted", "log_frame_committed", "log_closed"])
50
+ }))
50
51
  })
51
52
 
52
53
  /** @returns {Promise<number>} A released loopback port. */
@@ -57,7 +58,9 @@ async function reservePort() {
57
58
  server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
58
59
  })
59
60
  const address = server.address()
60
- assert(address !== null && typeof address !== "string")
61
+ if (address === null || typeof address === "string") {
62
+ throw new Error("Server did not bind to a TCP port")
63
+ }
61
64
  await new Promise((resolve) => server.close(() => resolve(undefined)))
62
65
  return address.port
63
66
  }