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
@@ -1,12 +1,11 @@
1
1
  /* eslint-disable jsdoc/require-returns */
2
- import assert from "node:assert/strict"
2
+ import {describe, expect, it, withTestCleanup} from "@velocious/testing"
3
3
  import {fork} from "node:child_process"
4
4
  import {once} from "node:events"
5
5
  import {mkdtemp, readFile, rm} from "node:fs/promises"
6
6
  import {createServer} from "node:net"
7
7
  import {tmpdir} from "node:os"
8
8
  import {join} from "node:path"
9
- import test from "node:test"
10
9
 
11
10
  import {StaticTokenStore} from "../../src/auth/static-token-store.js"
12
11
  import {OutboundReverseGateway, startReverseGatewayHub} from "../../src/reverse/index.js"
@@ -21,7 +20,9 @@ async function listen(server) {
21
20
  server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
22
21
  })
23
22
  const address = server.address()
24
- assert(address !== null && typeof address !== "string")
23
+ if (address === null || typeof address === "string") {
24
+ throw new Error("Server did not bind to a TCP port")
25
+ }
25
26
  return address.port
26
27
  }
27
28
 
@@ -63,111 +64,114 @@ async function waitForMessage(processState, type) {
63
64
  return message
64
65
  }
65
66
 
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))) })
67
+ describe("reverse client process recovery", () => {
68
+ it("SIGKILLed reverse client process recovers its durable stream while hub gateway and target survive", async () => withTestCleanup(async (after) => {
69
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-client-process-recovery-"))
70
+ after(() => rm(root, {recursive: true, force: true}))
71
+ const certificates = generateTestCertificates(root)
72
+ const spoolDirectory = join(root, "client-spool")
73
+ let targetAccepts = 0
74
+ /** @type {import('node:net').Socket | undefined} */
75
+ let targetSocket
76
+ const targetSockets = new Set()
77
+ /** @type {Buffer[]} */
78
+ const targetReceived = []
79
+ const target = createServer((socket) => {
80
+ targetAccepts += 1
81
+ targetSocket = socket
82
+ targetSockets.add(socket)
83
+ socket.once("close", () => targetSockets.delete(socket))
84
+ socket.on("data", (chunk) => targetReceived.push(Buffer.from(chunk)))
85
+ })
86
+ const targetPort = await listen(target)
87
+ after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
86
88
 
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()
89
+ const hub = await startReverseGatewayHub({
90
+ tokens: new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-process-token"}]),
91
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
92
+ authorizeGateway: () => true,
93
+ authorizeClientGateway: () => true,
94
+ authorizeOpen: () => true,
95
+ resumeWindowMs: 4_000
96
+ })
97
+ after(() => hub.close())
98
+ const gateway = new OutboundReverseGateway({
99
+ gatewayId: "o2",
100
+ connectionId: "process_recovery_gateway",
101
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
102
+ tls: {caFile: certificates.caCertFile, servername: "localhost"},
103
+ spoolDirectory: join(root, "gateway-spool"),
104
+ authorizeTarget: () => true,
105
+ reconnectDelayMs: 10,
106
+ maxReconnectDelayMs: 20
107
+ })
108
+ after(() => gateway.close())
109
+ await gateway.start()
108
110
 
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)
111
+ const configuration = {
112
+ gatewayId: "o2",
113
+ connectionId: "process_recovery_client",
114
+ hubPort: hub.port,
115
+ token: "client-process-token",
116
+ caFile: certificates.caCertFile,
117
+ spoolDirectory,
118
+ identifier: "process_recovery_stream",
119
+ sessionName: "durable-process-stream",
120
+ targetPort,
121
+ initialGeneration: 100
122
+ }
123
+ const original = startClient({...configuration, phase: "original"})
124
+ after(() => { if (original.child.exitCode === null) original.child.kill("SIGKILL") })
125
+ const connected = await waitForMessage(original, "connected")
126
+ original.child.send({type: "write", payload: Buffer.from("before-kill").toString("base64")})
127
+ await waitForMessage(original, "written")
128
+ await waitUntil(() => Buffer.concat(targetReceived).toString() === "before-kill", "original target request")
129
+ expect(targetAccepts).toBe(1)
130
+ if (targetSocket === undefined) throw new Error("Expected a live target socket")
129
131
 
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")
132
+ original.child.send({type: "hold-next-data"})
133
+ await waitForMessage(original, "data-hold-armed")
134
+ original.child.send({type: "write", payload: Buffer.from("during-kill").toString("base64")})
135
+ await waitForMessage(original, "data-held")
136
+ await waitUntil(() => original.messages.filter((message) => message.type === "written").length === 2, "durably spooled held request")
137
+ expect(Buffer.concat(targetReceived).toString()).toBe("before-kill")
136
138
 
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
139
+ const replayedTargetBytes = Buffer.from("accepted-but-not-durably-acked")
140
+ original.child.send({type: "observe-buffer", bytes: replayedTargetBytes.length})
141
+ targetSocket.write(replayedTargetBytes)
142
+ await waitForMessage(original, "buffered")
143
+ original.child.send({type: "report-acks"})
144
+ const acknowledgements = await waitForMessage(original, "acks")
145
+ // Unread process-local bytes must not be durably acknowledged.
146
+ expect(acknowledgements.ackFrames).toBe(0)
147
+ await waitUntil(() => gateway.status().spoolBytes === replayedTargetBytes.length, "gateway retains unacknowledged target bytes")
148
+ const originalExit = once(original.child, "exit")
149
+ original.child.kill("SIGKILL")
150
+ await originalExit
148
151
 
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
- replacement.child.send({type: "acknowledge", bytes: replayedTargetBytes.length})
158
- const acknowledged = await waitForMessage(replacement, "acknowledged")
159
- assert.equal(acknowledged.sequence, replayedTargetBytes.length)
160
- await waitUntil(() => Buffer.concat(targetReceived).toString() === "before-killduring-kill", "replay strictly above gateway high-water")
152
+ const replacement = startClient({...configuration, phase: "replacement"})
153
+ after(() => { if (replacement.child.exitCode === null) replacement.child.kill("SIGKILL") })
154
+ const recovered = await waitForMessage(replacement, "recovered")
155
+ const replay = await waitForMessage(replacement, "data")
156
+ expect(Buffer.from(/** @type {string} */ (replay.payload), "base64").toString()).toBe(replayedTargetBytes.toString())
157
+ expect(recovered.openFrames).toBe(0)
158
+ expect(/** @type {number} */ (recovered.generation) > /** @type {number} */ (connected.generation)).toBeTruthy()
159
+ expect(targetAccepts).toBe(1)
160
+ replacement.child.send({type: "acknowledge", bytes: replayedTargetBytes.length})
161
+ const acknowledged = await waitForMessage(replacement, "acknowledged")
162
+ expect(acknowledged.sequence).toBe(replayedTargetBytes.length)
163
+ await waitUntil(() => Buffer.concat(targetReceived).toString() === "before-killduring-kill", "replay strictly above gateway high-water")
161
164
 
162
- replacement.child.send({type: "write", payload: Buffer.from("after-kill").toString("base64")})
163
- await waitForMessage(replacement, "written")
164
- await waitUntil(() => Buffer.concat(targetReceived).toString() === "before-killduring-killafter-kill", "recovered target request")
165
- assert.equal(targetAccepts, 1)
166
- const persisted = await readFile(join(spoolDirectory, configuration.identifier, "recovery.json"), "utf8")
167
- assert.doesNotMatch(persisted, /client-process-token|gateway-token/)
165
+ replacement.child.send({type: "write", payload: Buffer.from("after-kill").toString("base64")})
166
+ await waitForMessage(replacement, "written")
167
+ await waitUntil(() => Buffer.concat(targetReceived).toString() === "before-killduring-killafter-kill", "recovered target request")
168
+ expect(targetAccepts).toBe(1)
169
+ const persisted = await readFile(join(spoolDirectory, configuration.identifier, "recovery.json"), "utf8")
170
+ expect(persisted).not.toMatch(/client-process-token|gateway-token/)
168
171
 
169
- const replacementExit = once(replacement.child, "exit")
170
- replacement.child.send({type: "close"})
171
- await replacementExit
172
- assert.equal(replacement.child.exitCode, 0, replacement.stderr())
172
+ const replacementExit = once(replacement.child, "exit")
173
+ replacement.child.send({type: "close"})
174
+ await replacementExit
175
+ if (replacement.child.exitCode !== 0) throw new Error(`Expected exit code 0: ${replacement.stderr()}`)
176
+ }))
173
177
  })