socketduct 0.1.19 → 0.1.21
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 +3 -3
- package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
- package/changelog.d/20260925180000-manual-ack-durable-admission.md +1 -0
- package/docs/docker.md +5 -4
- package/docs/logging.md +131 -159
- package/docs/protocol.md +48 -7
- package/docs/reverse-gateway.md +83 -8
- package/docs/running.md +17 -13
- package/docs/security.md +7 -3
- package/docs/session-lifecycle.md +22 -0
- package/docs/socket-api.md +22 -17
- package/package.json +5 -1
- package/scripts/verify-zstd-interoperability.mjs +41 -0
- package/src/cli/socketduct.js +143 -0
- package/src/client/deferred-virtual-socket.js +42 -1
- package/src/client/multiplexed-connection.js +17 -0
- package/src/client/multiplexed-transport-set.js +43 -8
- package/src/client/multiplexed.js +1 -1
- package/src/client/virtual-socket.js +119 -4
- package/src/config/environment.js +56 -7
- package/src/index.js +65 -27
- 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 +245 -34
- package/src/reverse/disconnect-attribution.js +191 -0
- package/src/reverse/gateway.js +68 -9
- package/src/reverse/hub.js +215 -32
- package/src/reverse/runtime.js +5 -3
- package/src/spool/spool-file.js +92 -6
- package/tensorbuzz.yml +2 -1
- package/test/e2e/logger-shutdown-process.test.js +86 -0
- package/test/e2e/reverse-client-recovery-process.test.js +3 -0
- package/test/e2e/reverse-gateway-process.test.js +15 -14
- package/test/fixtures/reverse-client-recovery.mjs +14 -2
- package/test/integration/logging-cli.test.js +168 -0
- package/test/integration/logging-compressed-recovery.test.js +186 -0
- package/test/integration/multiplexed-transport-set.test.js +1 -5
- package/test/integration/reverse-gateway.test.js +334 -1
- package/test/integration/reverse-route-class.test.js +6 -1
- package/test/unit/client/multiplexed-transport-set.test.js +50 -3
- package/test/unit/client/virtual-socket.test.js +123 -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 +5 -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 +58 -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
|
+
}
|
|
@@ -154,6 +154,9 @@ test("SIGKILLed reverse client process recovers its durable stream while hub gat
|
|
|
154
154
|
assert.equal(recovered.openFrames, 0)
|
|
155
155
|
assert(/** @type {number} */ (recovered.generation) > /** @type {number} */ (connected.generation))
|
|
156
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)
|
|
157
160
|
await waitUntil(() => Buffer.concat(targetReceived).toString() === "before-killduring-kill", "replay strictly above gateway high-water")
|
|
158
161
|
|
|
159
162
|
replacement.child.send({type: "write", payload: Buffer.from("after-kill").toString("base64")})
|
|
@@ -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) => {
|
|
@@ -37,6 +37,7 @@ const client = new ReverseGatewayConnection({
|
|
|
37
37
|
|
|
38
38
|
const streamOptions = {
|
|
39
39
|
recover: true,
|
|
40
|
+
manualReceiveAck: true,
|
|
40
41
|
identifier: configuration.identifier,
|
|
41
42
|
sessionName: configuration.sessionName,
|
|
42
43
|
host: "127.0.0.1",
|
|
@@ -74,7 +75,8 @@ if (activeSocket !== undefined) {
|
|
|
74
75
|
identifier: configuration.identifier,
|
|
75
76
|
sessionName: configuration.sessionName,
|
|
76
77
|
host: "127.0.0.1",
|
|
77
|
-
port: configuration.targetPort
|
|
78
|
+
port: configuration.targetPort,
|
|
79
|
+
manualReceiveAck: true
|
|
78
80
|
})
|
|
79
81
|
installSocketEvents(activeSocket)
|
|
80
82
|
}
|
|
@@ -86,7 +88,17 @@ process.on("message", (message) => {
|
|
|
86
88
|
return
|
|
87
89
|
}
|
|
88
90
|
if (message?.type === "write") {
|
|
89
|
-
activeSocket.
|
|
91
|
+
void activeSocket.writeDurable(Buffer.from(message.payload, "base64")).then(
|
|
92
|
+
() => report("written", {code: null}),
|
|
93
|
+
(error) => report("written", {code: error?.code ?? "ERROR"})
|
|
94
|
+
)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
if (message?.type === "acknowledge") {
|
|
98
|
+
void activeSocket.acknowledgeReceive(message.bytes).then(
|
|
99
|
+
(sequence) => report("acknowledged", {sequence}),
|
|
100
|
+
(error) => report("acknowledge-error", {code: error?.code ?? "ERROR"})
|
|
101
|
+
)
|
|
90
102
|
return
|
|
91
103
|
}
|
|
92
104
|
if (message?.type === "observe-buffer") {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param-type, jsdoc/require-returns */
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
import {createHash, randomBytes} from "node:crypto"
|
|
4
|
+
import {mkdtemp, readdir, rm, stat, 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
|
+
|
|
9
|
+
import {createLogBatchId, inspectLogBatch} from "../../src/logging/log-batch.js"
|
|
10
|
+
import {readCompressedLogDirectory} from "../../src/logging/compressed-log-reader.js"
|
|
11
|
+
import {CompressedLogWorkerSink} from "../../src/logging/compressed-log-worker.js"
|
|
12
|
+
import {enforceLogRetention} from "../../src/logging/log-retention.js"
|
|
13
|
+
import {createIndexEntry, createIndexHeader, INDEX_ENTRY_BYTES, INDEX_HEADER_BYTES, uuidBytes} from "../../src/logging/log-segment-index.js"
|
|
14
|
+
import {compressLogFrame} from "../../src/logging/zstd-frame-codec.js"
|
|
15
|
+
|
|
16
|
+
test("recovery indexes a complete data-synced orphan without duplicating its batch", async (t) => {
|
|
17
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-compressed-orphan-"))
|
|
18
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
19
|
+
const fixture = await createRecoveryFixture(root, false)
|
|
20
|
+
const sink = new CompressedLogWorkerSink(fixture.options)
|
|
21
|
+
const recovered = await sink.start()
|
|
22
|
+
assert.equal(recovered.durableHighWater, "2")
|
|
23
|
+
assert.equal(recovered.orphanRecoveredCount, 1)
|
|
24
|
+
assert.equal(recovered.lastDurableBatch?.batchId, fixture.secondBatchId)
|
|
25
|
+
assert.equal((await stat(fixture.indexPath)).size, INDEX_HEADER_BYTES + 2 * INDEX_ENTRY_BYTES)
|
|
26
|
+
|
|
27
|
+
const duplicate = await sink.appendBatch(fixture.secondMessage)
|
|
28
|
+
assert.equal(duplicate.duplicate, true)
|
|
29
|
+
assert.equal((await stat(fixture.dataPath)).size, fixture.firstFrame.length + fixture.secondFrame.length)
|
|
30
|
+
await sink.close()
|
|
31
|
+
const result = await readCompressedLogDirectory(root)
|
|
32
|
+
assert.equal(result.incomplete, false)
|
|
33
|
+
assert.deepEqual(result.records.map((record) => record.eventSequence), ["1", "2"])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("recovery truncates only a torn final data frame and accepts its replay", async (t) => {
|
|
37
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-compressed-torn-"))
|
|
38
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
39
|
+
const fixture = await createRecoveryFixture(root, true)
|
|
40
|
+
const sink = new CompressedLogWorkerSink(fixture.options)
|
|
41
|
+
const recovered = await sink.start()
|
|
42
|
+
assert.equal(recovered.durableHighWater, "1")
|
|
43
|
+
assert.equal((await stat(fixture.dataPath)).size, fixture.firstFrame.length)
|
|
44
|
+
|
|
45
|
+
const replayed = await sink.appendBatch(fixture.secondMessage)
|
|
46
|
+
assert.equal(replayed.duplicate, false)
|
|
47
|
+
await sink.close()
|
|
48
|
+
const result = await readCompressedLogDirectory(root)
|
|
49
|
+
assert.equal(result.incomplete, false)
|
|
50
|
+
assert.deepEqual(result.records.map((record) => record.eventSequence), ["1", "2"])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("worker acknowledgements retain only the latest durable batch identity", async (t) => {
|
|
54
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-compressed-bounded-ack-"))
|
|
55
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
56
|
+
const writerEpoch = randomBytes(16)
|
|
57
|
+
const options = {directory: root, role: "reverse-hub", writerEpoch: writerEpoch.toString("base64url"), maxSegmentBytes: 4096, compressionLevel: 19}
|
|
58
|
+
const sink = new CompressedLogWorkerSink(options)
|
|
59
|
+
await sink.start()
|
|
60
|
+
let latest = await sink.appendBatch(batchMessage(writerEpoch, 1))
|
|
61
|
+
for (let sequence = 2; sequence <= 40; sequence += 1) latest = await sink.appendBatch(batchMessage(writerEpoch, sequence))
|
|
62
|
+
assert.equal(Object.hasOwn(latest, "durableBatchIds"), false)
|
|
63
|
+
assert.deepEqual(latest.lastDurableBatch, {
|
|
64
|
+
batchId: batchMessage(writerEpoch, 40).batchId,
|
|
65
|
+
firstSequence: "40",
|
|
66
|
+
lastSequence: "40"
|
|
67
|
+
})
|
|
68
|
+
assert.equal((await sink.appendBatch(batchMessage(writerEpoch, 40))).duplicate, true)
|
|
69
|
+
await sink.close()
|
|
70
|
+
|
|
71
|
+
const restarted = new CompressedLogWorkerSink(options)
|
|
72
|
+
const recovered = await restarted.start()
|
|
73
|
+
assert.equal(Object.hasOwn(recovered, "durableBatchIds"), false)
|
|
74
|
+
assert.deepEqual(recovered.lastDurableBatch, latest.lastDurableBatch)
|
|
75
|
+
await restarted.close()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("recovery accepts a retention-deleted prefix but rejects interior gaps", async (t) => {
|
|
79
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-compressed-retained-prefix-"))
|
|
80
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
81
|
+
const writerEpoch = randomBytes(16)
|
|
82
|
+
const options = {directory: root, role: "reverse-hub", writerEpoch: writerEpoch.toString("base64url"), maxSegmentBytes: 4096, compressionLevel: 19, retentionBytes: 1_000_000}
|
|
83
|
+
|
|
84
|
+
const segments = []
|
|
85
|
+
for (let sequence = 1; sequence <= 3; sequence += 1) {
|
|
86
|
+
const sink = new CompressedLogWorkerSink(options)
|
|
87
|
+
await sink.start()
|
|
88
|
+
await sink.appendBatch(batchMessage(writerEpoch, sequence))
|
|
89
|
+
segments.push(/** @type {string} */ (sink.status().segmentPath))
|
|
90
|
+
await sink.close()
|
|
91
|
+
}
|
|
92
|
+
assert.equal(segments.length, 3)
|
|
93
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
94
|
+
await utimes(/** @type {string} */ (segments[index]), new Date(1_000 + index), new Date(1_000 + index))
|
|
95
|
+
}
|
|
96
|
+
await enforceLogRetention({directory: root, maxAgeMs: 1, maxBytes: 1_000_000, now: () => new Date(1_003)})
|
|
97
|
+
assert.equal((await readdir(root)).filter((name) => name.endsWith(".jsonl.zst")).length, 1)
|
|
98
|
+
|
|
99
|
+
const recovered = new CompressedLogWorkerSink(options)
|
|
100
|
+
assert.equal((await recovered.start()).durableHighWater, "3")
|
|
101
|
+
assert.equal((await recovered.appendBatch(batchMessage(writerEpoch, 4))).durableHighWater, "4")
|
|
102
|
+
await recovered.close()
|
|
103
|
+
|
|
104
|
+
const gapDirectory = join(root, "interior-gap")
|
|
105
|
+
const gapEpoch = randomBytes(16)
|
|
106
|
+
const gapOptions = {...options, directory: gapDirectory, writerEpoch: gapEpoch.toString("base64url")}
|
|
107
|
+
const gapSegments = []
|
|
108
|
+
for (let sequence = 1; sequence <= 3; sequence += 1) {
|
|
109
|
+
const sink = new CompressedLogWorkerSink(gapOptions)
|
|
110
|
+
await sink.start()
|
|
111
|
+
await sink.appendBatch(batchMessage(gapEpoch, sequence))
|
|
112
|
+
gapSegments.push(/** @type {string} */ (sink.status().segmentPath))
|
|
113
|
+
await sink.close()
|
|
114
|
+
}
|
|
115
|
+
const missing = /** @type {string} */ (gapSegments[1])
|
|
116
|
+
await Promise.all([rm(missing), rm(`${missing}.idx`), rm(`${missing}.sealed`)])
|
|
117
|
+
await assert.rejects(() => new CompressedLogWorkerSink(gapOptions).start(), /interior sequence gap/)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {string} directory - Fixture directory. @param {boolean} torn - Whether to tear the orphan frame.
|
|
122
|
+
* @param torn
|
|
123
|
+
*/
|
|
124
|
+
async function createRecoveryFixture(directory, torn) {
|
|
125
|
+
const writerEpoch = randomBytes(16)
|
|
126
|
+
const writerEpochText = writerEpoch.toString("base64url")
|
|
127
|
+
const segmentUuidText = "123e4567-e89b-42d3-a456-426614174000"
|
|
128
|
+
const segmentUuid = uuidBytes(segmentUuidText)
|
|
129
|
+
const stem = `reverse-hub-${writerEpochText}-${segmentUuidText}.jsonl.zst`
|
|
130
|
+
const dataPath = join(directory, stem)
|
|
131
|
+
const indexPath = `${dataPath}.idx`
|
|
132
|
+
const firstRaw = record(writerEpochText, 1, "process.started")
|
|
133
|
+
const secondRaw = record(writerEpochText, 2, "runtime.status")
|
|
134
|
+
const firstFrame = await compressLogFrame(firstRaw)
|
|
135
|
+
const secondFrame = await compressLogFrame(secondRaw)
|
|
136
|
+
const first = inspectLogBatch(firstRaw, writerEpoch)
|
|
137
|
+
const firstEntry = createIndexEntry({
|
|
138
|
+
segmentUuid,
|
|
139
|
+
writerEpoch,
|
|
140
|
+
batchId: first.batchId,
|
|
141
|
+
firstSequence: first.firstSequence,
|
|
142
|
+
lastSequence: first.lastSequence,
|
|
143
|
+
dataOffset: 0n,
|
|
144
|
+
compressedLength: BigInt(firstFrame.length),
|
|
145
|
+
uncompressedLength: BigInt(firstRaw.length),
|
|
146
|
+
recordCount: 1,
|
|
147
|
+
minTimestamp: first.minTimestamp,
|
|
148
|
+
maxTimestamp: first.maxTimestamp,
|
|
149
|
+
frameSha256: createHash("sha256").update(firstFrame).digest()
|
|
150
|
+
})
|
|
151
|
+
await writeFile(dataPath, Buffer.concat([firstFrame, torn ? secondFrame.subarray(0, secondFrame.length - 3) : secondFrame]), {mode: 0o600})
|
|
152
|
+
await writeFile(indexPath, Buffer.concat([createIndexHeader({segmentUuid, writerEpoch}), firstEntry]), {mode: 0o600})
|
|
153
|
+
const secondBatchId = createLogBatchId(writerEpoch, 2n, 2n, secondRaw).toString("base64url")
|
|
154
|
+
return {
|
|
155
|
+
options: {directory, role: "reverse-hub", writerEpoch: writerEpochText, maxSegmentBytes: 4096, compressionLevel: 19},
|
|
156
|
+
dataPath,
|
|
157
|
+
indexPath,
|
|
158
|
+
firstFrame,
|
|
159
|
+
secondFrame,
|
|
160
|
+
secondBatchId,
|
|
161
|
+
secondMessage: {batchId: secondBatchId, firstSequence: "2", lastSequence: "2", jsonl: secondRaw}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {string} writerEpoch - Epoch. @param {number} sequence - Event sequence. @param {string} event - Event name.
|
|
167
|
+
* @param sequence
|
|
168
|
+
* @param event
|
|
169
|
+
*/
|
|
170
|
+
function record(writerEpoch, sequence, event) {
|
|
171
|
+
return Buffer.from(`${JSON.stringify({timestamp: new Date(1_795_000_000_000 + sequence).toISOString(), event, processId: 1, role: "reverse-hub", writerEpoch, eventSequence: String(sequence)})}\n`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {Buffer} writerEpoch - Epoch.
|
|
176
|
+
* @param {number} sequence - Event sequence.
|
|
177
|
+
*/
|
|
178
|
+
function batchMessage(writerEpoch, sequence) {
|
|
179
|
+
const jsonl = record(writerEpoch.toString("base64url"), sequence, "runtime.status")
|
|
180
|
+
return {
|
|
181
|
+
batchId: createLogBatchId(writerEpoch, BigInt(sequence), BigInt(sequence), jsonl).toString("base64url"),
|
|
182
|
+
firstSequence: String(sequence),
|
|
183
|
+
lastSequence: String(sequence),
|
|
184
|
+
jsonl
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -431,11 +431,7 @@ test("process replacement recovers a durable stream on its original named gatewa
|
|
|
431
431
|
t.after(() => { SessionSpool.prototype.readChunksAfter = originalReadChunksAfter })
|
|
432
432
|
replacement = new MultiplexedSocketductTransportSet(setOptions)
|
|
433
433
|
const recovered = replacement.recoverConnection({
|
|
434
|
-
|
|
435
|
-
host: "127.0.0.1",
|
|
436
|
-
port: targetPort,
|
|
437
|
-
identifier: "gateway_bound_stream_01",
|
|
438
|
-
sessionName: "gateway-bound"
|
|
434
|
+
identifier: "gateway_bound_stream_01"
|
|
439
435
|
})
|
|
440
436
|
await recoveryRead
|
|
441
437
|
await new Promise((resolve) => setImmediate(resolve))
|