socketduct 0.1.25 → 0.1.26

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 (56) hide show
  1. package/AGENTS.md +2 -2
  2. package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
  3. package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
  4. package/package.json +2 -2
  5. package/scripts/test.js +9 -14
  6. package/src/logging/compressed-log-writer.js +1 -1
  7. package/src/logging/zstd-frame-codec.js +15 -1
  8. package/test/e2e/gateway-process.test.js +93 -92
  9. package/test/e2e/logger-shutdown-process.test.js +40 -37
  10. package/test/e2e/reverse-client-recovery-process.test.js +107 -103
  11. package/test/e2e/reverse-gateway-process.test.js +328 -318
  12. package/test/integration/initial-target-recovery.test.js +333 -334
  13. package/test/integration/logging-cli.test.js +121 -119
  14. package/test/integration/logging-compressed-recovery.test.js +96 -95
  15. package/test/integration/multiplexed-compatibility.test.js +178 -177
  16. package/test/integration/multiplexed-corrections.test.js +360 -359
  17. package/test/integration/multiplexed-failures.test.js +253 -251
  18. package/test/integration/multiplexed-transport-set.test.js +334 -322
  19. package/test/integration/multiplexed-transport.test.js +135 -134
  20. package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
  21. package/test/integration/reverse-gateway.test.js +2309 -2218
  22. package/test/integration/reverse-resume-continuity.test.js +219 -218
  23. package/test/integration/reverse-route-class.test.js +358 -341
  24. package/test/integration/socketduct-http-agent.test.js +314 -312
  25. package/test/integration/socketduct-https-agent.test.js +516 -485
  26. package/test/integration/stream-compression.test.js +862 -824
  27. package/test/integration/tcp-forwarding.test.js +2550 -2447
  28. package/test/integration/tls-forwarding.test.js +593 -592
  29. package/test/unit/auth/auth.test.js +43 -44
  30. package/test/unit/auth/static-token-store.test.js +9 -8
  31. package/test/unit/client/connect.test.js +41 -40
  32. package/test/unit/client/deferred-virtual-socket.test.js +95 -94
  33. package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
  34. package/test/unit/client/multiplexed-transport-set.test.js +830 -748
  35. package/test/unit/client/socketduct-socket.test.js +768 -771
  36. package/test/unit/client/virtual-socket.test.js +198 -168
  37. package/test/unit/config/docker-gateway-files.test.js +224 -224
  38. package/test/unit/config/environment.test.js +472 -492
  39. package/test/unit/index.test.js +53 -52
  40. package/test/unit/logging/compressed-log-worker.test.js +23 -22
  41. package/test/unit/logging/compressed-log-writer.test.js +155 -154
  42. package/test/unit/logging/log-retention.test.js +52 -47
  43. package/test/unit/logging/log-segment-index.test.js +84 -81
  44. package/test/unit/logging/zstd-frame-codec.test.js +28 -19
  45. package/test/unit/protocol/compression.test.js +254 -298
  46. package/test/unit/protocol/frame.test.js +280 -276
  47. package/test/unit/protocol/v3-compression.test.js +21 -23
  48. package/test/unit/protocol/v3-frame.test.js +42 -41
  49. package/test/unit/release-configuration.test.js +15 -12
  50. package/test/unit/reverse/disconnect-attribution.test.js +65 -64
  51. package/test/unit/reverse-terminal-outcomes.test.js +53 -50
  52. package/test/unit/server/control-handler.test.js +232 -231
  53. package/test/unit/server/primary.test.js +51 -49
  54. package/test/unit/server/relay-runtime.test.js +280 -294
  55. package/test/unit/sessions/session.test.js +48 -62
  56. 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
  })