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.
- package/.env.example +17 -5
- package/README.md +5 -5
- package/changelog.d/20260925143000-reverse-client-process-recovery.md +1 -0
- package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
- package/docs/architecture.md +11 -9
- package/docs/docker.md +5 -4
- package/docs/logging.md +131 -159
- package/docs/protocol.md +43 -7
- package/docs/reverse-gateway.md +75 -9
- package/docs/running.md +16 -13
- package/docs/security.md +16 -5
- package/docs/session-lifecycle.md +14 -3
- package/docs/socket-api.md +54 -20
- package/package.json +5 -1
- package/scripts/verify-zstd-interoperability.mjs +41 -0
- package/src/cli/socketduct.js +143 -0
- package/src/client/multiplexed-transport-set.js +12 -5
- package/src/client/virtual-socket.js +52 -0
- package/src/config/environment.js +56 -7
- package/src/index.js +64 -26
- package/src/logging/compressed-log-reader.js +256 -0
- package/src/logging/compressed-log-worker.js +430 -0
- package/src/logging/compressed-log-writer.js +538 -0
- package/src/logging/log-batch.js +72 -0
- package/src/logging/log-retention.js +241 -0
- package/src/logging/log-segment-index.js +176 -0
- package/src/logging/structured-logger.js +136 -30
- package/src/logging/zstd-frame-codec.js +73 -0
- package/src/reverse/client.js +372 -82
- package/src/reverse/disconnect-attribution.js +191 -0
- package/src/reverse/gateway.js +68 -18
- package/src/reverse/hub.js +215 -32
- package/src/reverse/runtime.js +5 -3
- package/src/spool/spool-file.js +136 -7
- package/tensorbuzz.yml +4 -2
- package/test/e2e/logger-shutdown-process.test.js +86 -0
- package/test/e2e/reverse-client-recovery-process.test.js +170 -0
- package/test/e2e/reverse-gateway-process.test.js +15 -14
- package/test/fixtures/reverse-client-recovery.mjs +123 -0
- package/test/integration/logging-cli.test.js +168 -0
- package/test/integration/logging-compressed-recovery.test.js +186 -0
- package/test/integration/reverse-gateway.test.js +597 -1
- package/test/integration/reverse-route-class.test.js +6 -1
- package/test/unit/client/multiplexed-transport-set.test.js +12 -1
- package/test/unit/config/docker-gateway-files.test.js +2 -0
- package/test/unit/config/environment.test.js +54 -14
- package/test/unit/index.test.js +3 -0
- package/test/unit/logging/compressed-log-worker.test.js +29 -0
- package/test/unit/logging/compressed-log-writer.test.js +183 -0
- package/test/unit/logging/log-retention.test.js +86 -0
- package/test/unit/logging/log-segment-index.test.js +104 -0
- package/test/unit/logging/structured-logger.test.js +14 -1
- package/test/unit/logging/zstd-frame-codec.test.js +24 -0
- package/test/unit/reverse/disconnect-attribution.test.js +82 -0
- 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,
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
assert(
|
|
276
|
-
const
|
|
277
|
-
|
|
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(
|
|
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
|
+
}
|