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