socketduct 0.1.15
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/.dockerignore +11 -0
- package/.env.example +131 -0
- package/AGENTS.md +168 -0
- package/Dockerfile +30 -0
- package/README.md +134 -0
- package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
- package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
- package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
- package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
- package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
- package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
- package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
- package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
- package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
- package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
- package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
- package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
- package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
- package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
- package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
- package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
- package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
- package/changelog.d/20260902231542-reverse-close-position.md +1 -0
- package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
- package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
- package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
- package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
- package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
- package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
- package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
- package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
- package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
- package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
- package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
- package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
- package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
- package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
- package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
- package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
- package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
- package/docker-compose.dev.yml +19 -0
- package/docker-compose.reverse-gateway.yml +36 -0
- package/docker-compose.yml +73 -0
- package/docs/architecture.md +264 -0
- package/docs/docker-topology.md +164 -0
- package/docs/docker.md +281 -0
- package/docs/logging.md +185 -0
- package/docs/plans/initial-implementation.md +205 -0
- package/docs/plans/socket-docker-integration.md +221 -0
- package/docs/protocol.md +558 -0
- package/docs/releasing.md +18 -0
- package/docs/reverse-gateway.md +186 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
- package/docs/running.md +215 -0
- package/docs/security.md +258 -0
- package/docs/session-lifecycle.md +127 -0
- package/docs/socket-api.md +448 -0
- package/eslint.config.js +36 -0
- package/package.json +44 -0
- package/scripts/deploy-gateway.js +194 -0
- package/scripts/test.js +28 -0
- package/src/auth/passwords.js +38 -0
- package/src/auth/static-token-store.js +41 -0
- package/src/auth/user-store.js +139 -0
- package/src/client/connect.js +76 -0
- package/src/client/deferred-virtual-socket.js +312 -0
- package/src/client/http-agent.js +101 -0
- package/src/client/https-agent.js +366 -0
- package/src/client/multiplexed-connection.js +1504 -0
- package/src/client/multiplexed-transport-set.js +917 -0
- package/src/client/multiplexed.js +3 -0
- package/src/client/socketduct-socket.js +1174 -0
- package/src/client/virtual-socket.js +256 -0
- package/src/config/environment.js +400 -0
- package/src/edge/tcp-edge.js +82 -0
- package/src/index.js +101 -0
- package/src/logging/structured-logger.js +745 -0
- package/src/protocol/compression.js +325 -0
- package/src/protocol/frame.js +341 -0
- package/src/protocol/v3-compression.js +44 -0
- package/src/protocol/v3-frame.js +209 -0
- package/src/proxy/multiplexed-relay.js +798 -0
- package/src/proxy/tcp-relay.js +1191 -0
- package/src/relay.js +3 -0
- package/src/reverse/client.js +721 -0
- package/src/reverse/common.js +88 -0
- package/src/reverse/gateway.js +663 -0
- package/src/reverse/hub.js +563 -0
- package/src/reverse/index.js +3 -0
- package/src/reverse/runtime.js +78 -0
- package/src/server/control-handler.js +170 -0
- package/src/server/primary.js +64 -0
- package/src/server/relay-runtime.js +124 -0
- package/src/server/worker.js +39 -0
- package/src/sessions/session.js +212 -0
- package/src/spool/spool-file.js +451 -0
- package/tensorbuzz.yml +36 -0
- package/test/e2e/gateway-process.test.js +262 -0
- package/test/e2e/reverse-gateway-process.test.js +285 -0
- package/test/fixtures/logging/enospc-matrix.mjs +238 -0
- package/test/fixtures/logging/enospc-recovery.mjs +146 -0
- package/test/fixtures/logging/process-writer.mjs +20 -0
- package/test/helpers/tls-certificates.js +115 -0
- package/test/integration/initial-target-recovery.test.js +518 -0
- package/test/integration/logging-recovery.test.js +163 -0
- package/test/integration/multiplexed-compatibility.test.js +254 -0
- package/test/integration/multiplexed-corrections.test.js +551 -0
- package/test/integration/multiplexed-failures.test.js +339 -0
- package/test/integration/multiplexed-transport-set.test.js +456 -0
- package/test/integration/multiplexed-transport.test.js +208 -0
- package/test/integration/reverse-gateway.test.js +1293 -0
- package/test/integration/socketduct-http-agent.test.js +435 -0
- package/test/integration/socketduct-https-agent.test.js +669 -0
- package/test/integration/stream-compression.test.js +1267 -0
- package/test/integration/tcp-forwarding.test.js +2878 -0
- package/test/integration/tls-forwarding.test.js +748 -0
- package/test/unit/auth/auth.test.js +64 -0
- package/test/unit/auth/static-token-store.test.js +14 -0
- package/test/unit/client/connect.test.js +58 -0
- package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
- package/test/unit/client/multiplexed-transport-set.test.js +906 -0
- package/test/unit/client/socketduct-socket.test.js +1124 -0
- package/test/unit/client/virtual-socket.test.js +98 -0
- package/test/unit/config/docker-gateway-files.test.js +274 -0
- package/test/unit/config/environment.test.js +507 -0
- package/test/unit/index.test.js +78 -0
- package/test/unit/logging/structured-logger.test.js +674 -0
- package/test/unit/protocol/compression.test.js +421 -0
- package/test/unit/protocol/frame.test.js +336 -0
- package/test/unit/protocol/v3-compression.test.js +31 -0
- package/test/unit/protocol/v3-frame.test.js +57 -0
- package/test/unit/release-configuration.test.js +21 -0
- package/test/unit/server/control-handler.test.js +291 -0
- package/test/unit/server/primary.test.js +59 -0
- package/test/unit/server/relay-runtime.test.js +434 -0
- package/test/unit/sessions/session.test.js +84 -0
- package/test/unit/spool/spool-file.test.js +295 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
import {createMockScope, describe, expect, it, withTestCleanup} from "@velocious/testing"
|
|
2
|
+
import {chmod, mkdtemp, mkdir, readFile, readdir, rename, rm, stat, utimes, writeFile} from "node:fs/promises"
|
|
3
|
+
import {tmpdir} from "node:os"
|
|
4
|
+
import {join} from "node:path"
|
|
5
|
+
|
|
6
|
+
import {createJsonlFileLogger, createStructuredLogger, errorDiagnostics, logDiagnostic} from "../../../src/logging/structured-logger.js"
|
|
7
|
+
|
|
8
|
+
const FIXED_TIME = "2026-09-05T12:34:56.789Z"
|
|
9
|
+
|
|
10
|
+
/** @param {string} root - Temporary directory. */
|
|
11
|
+
async function cleanup(root) {
|
|
12
|
+
await rm(root, {recursive: true, force: true})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} contents - JSONL.
|
|
17
|
+
* @returns {Array<Record<string, unknown>>} Parsed entries.
|
|
18
|
+
*/
|
|
19
|
+
function parseLines(contents) {
|
|
20
|
+
return contents.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("structured logger", {timeoutMs: 15000}, () => {
|
|
24
|
+
it("structured logger writes one allowlisted bounded JSON object per line", () => {
|
|
25
|
+
/** @type {string[]} */
|
|
26
|
+
const lines = []
|
|
27
|
+
const logger = createStructuredLogger({
|
|
28
|
+
role: "tensorbuzz-client",
|
|
29
|
+
processId: 4242,
|
|
30
|
+
now: () => new Date(FIXED_TIME),
|
|
31
|
+
writeLine: (/** @type {string} */ line) => lines.push(line)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
logger.log("transport.connected", {
|
|
35
|
+
generation: 7,
|
|
36
|
+
gatewayId: "g".repeat(100),
|
|
37
|
+
reconnect: true,
|
|
38
|
+
token: "lowentropysecret",
|
|
39
|
+
password: "alphabeticsecret",
|
|
40
|
+
authorization: "Bearer eyJhbGciOiJIUzI1NiJ9.private.signature",
|
|
41
|
+
url: "https://user:secret@example.test/private",
|
|
42
|
+
target: {host: "private.internal", port: 2375},
|
|
43
|
+
error: new Error("lowentropysecret eyJhbGciOiJIUzI1NiJ9.private.signature")
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
expect(lines.length).toBe(1)
|
|
47
|
+
const line = lines[0]
|
|
48
|
+
if (line === undefined) throw new Error("Expected a captured log record")
|
|
49
|
+
expect(line.endsWith("\n")).toBe(true)
|
|
50
|
+
const entry = JSON.parse(line)
|
|
51
|
+
expect(entry).toEqual({
|
|
52
|
+
timestamp: FIXED_TIME,
|
|
53
|
+
event: "transport.connected",
|
|
54
|
+
processId: 4242,
|
|
55
|
+
role: "tensorbuzz-client",
|
|
56
|
+
generation: 7,
|
|
57
|
+
gatewayId: "g".repeat(64),
|
|
58
|
+
reconnect: true
|
|
59
|
+
})
|
|
60
|
+
expect(Buffer.byteLength(line) <= 1024).toBeTruthy()
|
|
61
|
+
const serialized = lines.join("")
|
|
62
|
+
expect(serialized).not.toMatch(/lowentropysecret|alphabeticsecret|eyJhbGci|private\.internal|example\.test|2375/)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("structured logger rejects unknown events and maps untrusted error codes to a safe classification", () => {
|
|
66
|
+
/** @type {string[]} */
|
|
67
|
+
const lines = []
|
|
68
|
+
const logger = createStructuredLogger({
|
|
69
|
+
role: "embedded-client",
|
|
70
|
+
processId: 5,
|
|
71
|
+
now: () => new Date(FIXED_TIME),
|
|
72
|
+
writeLine: (/** @type {string} */ line) => lines.push(line)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
logger.log("attacker.lowentropysecret", {errorCode: "ECONNRESET"})
|
|
76
|
+
logger.log("virtual_stream.error", {
|
|
77
|
+
streamId: 9,
|
|
78
|
+
errorCode: "eyJhbGciOiJIUzI1NiJ9.private.signature",
|
|
79
|
+
errorMessage: "alphabeticsecret",
|
|
80
|
+
stack: "lowentropysecret"
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
expect(lines.length).toBe(1)
|
|
84
|
+
const line = lines[0]
|
|
85
|
+
if (line === undefined) throw new Error("Expected a captured log record")
|
|
86
|
+
expect(JSON.parse(line)).toEqual({
|
|
87
|
+
timestamp: FIXED_TIME,
|
|
88
|
+
event: "virtual_stream.error",
|
|
89
|
+
processId: 5,
|
|
90
|
+
role: "embedded-client",
|
|
91
|
+
streamId: 9,
|
|
92
|
+
errorCode: "UNKNOWN",
|
|
93
|
+
errorClass: "unknown"
|
|
94
|
+
})
|
|
95
|
+
expect(line).not.toMatch(/eyJhbGci|alphabeticsecret|lowentropysecret/)
|
|
96
|
+
const hostileError = new Proxy({}, {get: () => { throw new Error("alphabeticsecret") }})
|
|
97
|
+
expect(errorDiagnostics(hostileError)).toEqual({errorCode: "UNKNOWN", errorClass: "unknown"})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("retains actionable error details for virtual stream failures", () => {
|
|
101
|
+
/** @type {string[]} */
|
|
102
|
+
const lines = []
|
|
103
|
+
const logger = createStructuredLogger({
|
|
104
|
+
role: "embedded-client",
|
|
105
|
+
processId: 6,
|
|
106
|
+
now: () => new Date(FIXED_TIME),
|
|
107
|
+
writeLine: (/** @type {string} */ line) => lines.push(line)
|
|
108
|
+
})
|
|
109
|
+
const error = Object.assign(new Error("Remote Docker socket closed unexpectedly"), {code: "ECONNRESET"})
|
|
110
|
+
|
|
111
|
+
logDiagnostic(logger, "virtual_stream.error", {streamId: 10, ...errorDiagnostics(error)})
|
|
112
|
+
|
|
113
|
+
expect(lines.length).toBe(1)
|
|
114
|
+
const line = lines[0]
|
|
115
|
+
if (line === undefined) throw new Error("Expected a captured log record")
|
|
116
|
+
const entry = JSON.parse(line)
|
|
117
|
+
expect(entry.errorCode).toBe("ECONNRESET")
|
|
118
|
+
expect(entry.errorClass).toBe("network")
|
|
119
|
+
expect(entry.errorName).toBe("Error")
|
|
120
|
+
expect(entry.errorMessage).toBe("Remote Docker socket closed unexpectedly")
|
|
121
|
+
expect(entry.errorStack).toMatch(/Remote Docker socket closed unexpectedly/)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("preserves bounded failure classifications when error details exceed the JSONL limit", () => {
|
|
125
|
+
/** @type {string[]} */
|
|
126
|
+
const lines = []
|
|
127
|
+
const logger = createStructuredLogger({
|
|
128
|
+
role: "embedded-client",
|
|
129
|
+
processId: 7,
|
|
130
|
+
now: () => new Date(FIXED_TIME),
|
|
131
|
+
writeLine: (/** @type {string} */ line) => lines.push(line)
|
|
132
|
+
})
|
|
133
|
+
const error = Object.assign(new Error("Connection reset ".repeat(200)), {code: "ECONNRESET"})
|
|
134
|
+
|
|
135
|
+
logDiagnostic(logger, "virtual_stream.error", {streamId: 11, ...errorDiagnostics(error)})
|
|
136
|
+
|
|
137
|
+
expect(lines.length).toBe(1)
|
|
138
|
+
const line = lines[0]
|
|
139
|
+
if (line === undefined) throw new Error("Expected a captured log record")
|
|
140
|
+
const entry = JSON.parse(line)
|
|
141
|
+
expect(Buffer.byteLength(line) <= 1024).toBeTruthy()
|
|
142
|
+
expect(entry.errorCode).toBe("ECONNRESET")
|
|
143
|
+
expect(entry.errorClass).toBe("network")
|
|
144
|
+
expect(entry.streamId).toBe(11)
|
|
145
|
+
expect(entry.errorMessage).toMatch(/^Connection reset/)
|
|
146
|
+
expect(entry.errorStack).toBeUndefined()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("redacts credentials while retaining actionable error details", () => {
|
|
150
|
+
/** @type {string[]} */
|
|
151
|
+
const lines = []
|
|
152
|
+
const logger = createStructuredLogger({
|
|
153
|
+
role: "embedded-client",
|
|
154
|
+
processId: 8,
|
|
155
|
+
now: () => new Date(FIXED_TIME),
|
|
156
|
+
writeLine: (/** @type {string} */ line) => lines.push(line)
|
|
157
|
+
})
|
|
158
|
+
const error = Object.assign(new Error("Target https://credential-user:credential-secret@example.test rejected Bearer abc.def password=another-secret"), {code: "ECONNREFUSED"})
|
|
159
|
+
|
|
160
|
+
logDiagnostic(logger, "virtual_stream.error", {streamId: 13, ...errorDiagnostics(error)})
|
|
161
|
+
|
|
162
|
+
expect(lines.length).toBe(1)
|
|
163
|
+
const line = lines[0]
|
|
164
|
+
if (line === undefined) throw new Error("Expected a captured log record")
|
|
165
|
+
expect(line).toMatch(/Target https:\/\/\[REDACTED\]@example\.test rejected Bearer \[REDACTED\] password=\[REDACTED\]/)
|
|
166
|
+
expect(line).not.toMatch(/credential-user|credential-secret|abc\.def|another-secret/)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it("passes actionable error details to custom loggers", () => {
|
|
170
|
+
/** @type {Record<string, unknown>[]} */
|
|
171
|
+
const fields = []
|
|
172
|
+
/** @type {{log(event: string, fields?: Record<string, unknown>): void}} */
|
|
173
|
+
const logger = {
|
|
174
|
+
log(_event, eventFields = {}) {
|
|
175
|
+
fields.push(eventFields)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const error = Object.assign(new Error("Remote Docker socket closed unexpectedly"), {code: "ECONNRESET"})
|
|
179
|
+
|
|
180
|
+
logDiagnostic(logger, "virtual_stream.error", {
|
|
181
|
+
streamId: 12,
|
|
182
|
+
...errorDiagnostics(error)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
expect(fields.length).toBe(1)
|
|
186
|
+
const eventFields = fields[0]
|
|
187
|
+
if (eventFields === undefined) throw new Error("Expected captured diagnostic fields")
|
|
188
|
+
expect(eventFields.errorCode).toBe("ECONNRESET")
|
|
189
|
+
expect(eventFields.errorClass).toBe("network")
|
|
190
|
+
expect(eventFields.errorName).toBe("Error")
|
|
191
|
+
expect(eventFields.errorMessage).toBe("Remote Docker socket closed unexpectedly")
|
|
192
|
+
expect(eventFields.errorStack).toMatch(/Remote Docker socket closed unexpectedly/)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it("structured logger snapshots identity and writer options and fixes its failure classification", () => {
|
|
196
|
+
/** @type {string[]} */
|
|
197
|
+
const lines = []
|
|
198
|
+
/** @type {string[]} */
|
|
199
|
+
const replacedLines = []
|
|
200
|
+
/** @type {string[]} */
|
|
201
|
+
const reports = []
|
|
202
|
+
const options = {
|
|
203
|
+
role: "embedded-client",
|
|
204
|
+
processId: 44,
|
|
205
|
+
now: () => new Date(FIXED_TIME),
|
|
206
|
+
writeLine: (/** @type {string} */ line) => lines.push(line),
|
|
207
|
+
stderr: {write: (/** @type {string} */ message) => { reports.push(message); return true }},
|
|
208
|
+
failureCode: "alphabeticsecret"
|
|
209
|
+
}
|
|
210
|
+
const logger = createStructuredLogger(options)
|
|
211
|
+
options.role = "mutated-secret"
|
|
212
|
+
options.now = () => new Date("2040-01-01T00:00:00.000Z")
|
|
213
|
+
options.writeLine = (line) => replacedLines.push(line)
|
|
214
|
+
options.failureCode = "lowentropysecret"
|
|
215
|
+
|
|
216
|
+
logger.log("process.started", {runtimeMode: "summary"})
|
|
217
|
+
expect(lines.length).toBe(1)
|
|
218
|
+
expect(replacedLines.length).toBe(0)
|
|
219
|
+
const line = lines[0]
|
|
220
|
+
if (line === undefined) throw new Error("Expected a captured log record")
|
|
221
|
+
expect(JSON.parse(line)).toEqual({
|
|
222
|
+
timestamp: FIXED_TIME,
|
|
223
|
+
event: "process.started",
|
|
224
|
+
processId: 44,
|
|
225
|
+
role: "embedded-client",
|
|
226
|
+
runtimeMode: "summary"
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const failingOptions = {
|
|
230
|
+
role: "embedded-client",
|
|
231
|
+
writeLine: () => { throw new Error("alphabeticsecret") },
|
|
232
|
+
stderr: options.stderr,
|
|
233
|
+
failureCode: "lowentropysecret"
|
|
234
|
+
}
|
|
235
|
+
createStructuredLogger(failingOptions).log("process.started", {runtimeMode: "summary"})
|
|
236
|
+
expect(reports).toEqual(["Socketduct logger failure (LOGGER_WRITER_ERROR)\n"])
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it("file logger appends valid owner-only JSONL across logger instances", async () => withTestCleanup(async (after) => {
|
|
240
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-append-"))
|
|
241
|
+
after(() => cleanup(root))
|
|
242
|
+
const options = {directory: root, role: "reverse-hub", processId: 101, now: () => new Date(FIXED_TIME)}
|
|
243
|
+
|
|
244
|
+
createJsonlFileLogger(options).log("process.started", {runtimeMode: "reverse-hub"})
|
|
245
|
+
createJsonlFileLogger(options).log("runtime.status", {physicalConnections: 2, virtualStreams: 3})
|
|
246
|
+
|
|
247
|
+
const path = join(root, "reverse-hub-101.jsonl")
|
|
248
|
+
const contents = await readFile(path, "utf8")
|
|
249
|
+
expect(parseLines(contents).map(({event}) => event)).toEqual(["process.started", "runtime.status"])
|
|
250
|
+
expect((await stat(path)).mode & 0o777).toBe(0o600)
|
|
251
|
+
}))
|
|
252
|
+
|
|
253
|
+
describe("file logger preserves an incomplete tail before same or fresh instance appends", () => {
|
|
254
|
+
for (const maxFiles of [2, 5]) {
|
|
255
|
+
for (const freshInstance of [false, true]) {
|
|
256
|
+
it(`maxFiles=${maxFiles}, freshInstance=${freshInstance}`, async () => withTestCleanup(async (after) => {
|
|
257
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-tail-"))
|
|
258
|
+
after(() => cleanup(root))
|
|
259
|
+
const options = {directory: root, role: "relay", processId: 101, maxFiles, maxFileBytes: 1024, now: () => new Date(FIXED_TIME)}
|
|
260
|
+
const logger = createJsonlFileLogger(options)
|
|
261
|
+
logger.log("process.started", {runtimeMode: "relay"})
|
|
262
|
+
const active = join(root, "relay-101.jsonl")
|
|
263
|
+
await writeFile(active, '{"timestamp":"2026-', {flag: "a"})
|
|
264
|
+
const torn = await readFile(active)
|
|
265
|
+
|
|
266
|
+
const resumed = freshInstance ? createJsonlFileLogger(options) : logger
|
|
267
|
+
resumed.log("transport.connected", {generation: 1})
|
|
268
|
+
resumed.log("runtime.status", {physicalConnections: 1})
|
|
269
|
+
|
|
270
|
+
expect((await readdir(root)).sort()).toEqual(["relay-101.jsonl", "relay-101.jsonl.1"])
|
|
271
|
+
expect((await readFile(`${active}.1`)).equals(torn)).toBeTrue()
|
|
272
|
+
const contents = await readFile(active, "utf8")
|
|
273
|
+
expect(contents.endsWith("\n")).toBeTruthy()
|
|
274
|
+
expect(parseLines(contents).map(({event}) => event)).toEqual(["transport.connected", "runtime.status"])
|
|
275
|
+
for (const path of [active, `${active}.1`]) {
|
|
276
|
+
const stats = await stat(path)
|
|
277
|
+
expect(stats.size <= 1024).toBeTruthy()
|
|
278
|
+
expect(stats.mode & 0o777).toBe(0o600)
|
|
279
|
+
}
|
|
280
|
+
}))
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it("file logger preserves an incomplete tail and pauses with maxFiles one", async () => withTestCleanup(async (after) => {
|
|
286
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-tail-single-"))
|
|
287
|
+
after(() => cleanup(root))
|
|
288
|
+
const active = join(root, "relay-101.jsonl")
|
|
289
|
+
const torn = Buffer.from('{"event":"process.started"}\n{"timestamp":"2026-')
|
|
290
|
+
await writeFile(active, torn, {mode: 0o600})
|
|
291
|
+
/** @type {string[]} */
|
|
292
|
+
const reports = []
|
|
293
|
+
const logger = createJsonlFileLogger({
|
|
294
|
+
directory: root,
|
|
295
|
+
role: "relay",
|
|
296
|
+
processId: 101,
|
|
297
|
+
maxFiles: 1,
|
|
298
|
+
maxFileBytes: 1024,
|
|
299
|
+
stderr: {write: (/** @type {string} */ message) => reports.push(message)}
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
for (let generation = 1; generation <= 3; generation += 1) {
|
|
303
|
+
await expect(() => logger.log("transport.connected", {generation})).not.toThrow()
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
expect((await readFile(active)).equals(torn)).toBeTrue()
|
|
307
|
+
expect(await readdir(root)).toEqual(["relay-101.jsonl"])
|
|
308
|
+
expect(reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
|
|
309
|
+
}))
|
|
310
|
+
|
|
311
|
+
it("file logger preserves an oversized incomplete tail before startup pruning", async () => withTestCleanup(async (after) => {
|
|
312
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-tail-pruning-"))
|
|
313
|
+
after(() => cleanup(root))
|
|
314
|
+
const active = join(root, "relay-101.jsonl")
|
|
315
|
+
const torn = Buffer.from(`${"{}\n".repeat(342)}{"timestamp":"2026-`)
|
|
316
|
+
await writeFile(active, torn, {mode: 0o600})
|
|
317
|
+
/** @type {string[]} */
|
|
318
|
+
const reports = []
|
|
319
|
+
const logger = createJsonlFileLogger({
|
|
320
|
+
directory: root,
|
|
321
|
+
role: "relay",
|
|
322
|
+
processId: 101,
|
|
323
|
+
maxFiles: 2,
|
|
324
|
+
maxFileBytes: 1024,
|
|
325
|
+
stderr: {write: (/** @type {string} */ message) => reports.push(message)}
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
logger.log("process.started", {runtimeMode: "relay"})
|
|
329
|
+
logger.log("runtime.status", {physicalConnections: 1})
|
|
330
|
+
|
|
331
|
+
expect((await readFile(active)).equals(torn)).toBeTrue()
|
|
332
|
+
expect(await readdir(root)).toEqual(["relay-101.jsonl"])
|
|
333
|
+
expect(reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
|
|
334
|
+
}))
|
|
335
|
+
|
|
336
|
+
describe("file logger pauses incomplete-tail recovery on real permission or rotation failures", () => {
|
|
337
|
+
for (const failure of ["unreadable-file", "unsafe-file-mode", "unwritable-directory", "unsafe-directory-mode", "blocked-retention"]) {
|
|
338
|
+
it(failure, async () => withTestCleanup(async (after) => {
|
|
339
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-tail-failure-"))
|
|
340
|
+
const active = join(root, "relay-101.jsonl")
|
|
341
|
+
after(async () => {
|
|
342
|
+
await chmod(root, 0o700)
|
|
343
|
+
await cleanup(root)
|
|
344
|
+
})
|
|
345
|
+
const torn = Buffer.from('{}\n{"timestamp":"2026-')
|
|
346
|
+
await writeFile(active, torn, {mode: 0o600})
|
|
347
|
+
if (failure === "unreadable-file") await chmod(active, 0o200)
|
|
348
|
+
if (failure === "unsafe-file-mode") await chmod(active, 0o644)
|
|
349
|
+
if (failure === "unwritable-directory") await chmod(root, 0o500)
|
|
350
|
+
if (failure === "unsafe-directory-mode") await chmod(root, 0o755)
|
|
351
|
+
if (failure === "blocked-retention") {
|
|
352
|
+
await mkdir(`${active}.1`)
|
|
353
|
+
await writeFile(join(`${active}.1`, "blocker"), "fixture")
|
|
354
|
+
}
|
|
355
|
+
if (failure === "unwritable-directory") {
|
|
356
|
+
await expect(rename(active, `${active}.1`)).rejects.toMatchObject({code: "EACCES"})
|
|
357
|
+
await expect(writeFile(join(root, "create-probe"), "fixture", {flag: "wx"})).rejects.toMatchObject({code: "EACCES"})
|
|
358
|
+
}
|
|
359
|
+
/** @type {string[]} */
|
|
360
|
+
const reports = []
|
|
361
|
+
const logger = createJsonlFileLogger({
|
|
362
|
+
directory: root, role: "relay", processId: 101, maxFiles: 2,
|
|
363
|
+
stderr: {write: (/** @type {string} */ message) => reports.push(message)}
|
|
364
|
+
})
|
|
365
|
+
for (let generation = 0; generation < 3; generation += 1) {
|
|
366
|
+
await expect(() => logger.log("transport.connected", {generation})).not.toThrow()
|
|
367
|
+
}
|
|
368
|
+
await chmod(root, 0o700)
|
|
369
|
+
await chmod(active, 0o600)
|
|
370
|
+
expect((await readFile(active)).equals(torn)).toBeTruthy()
|
|
371
|
+
expect(reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
|
|
372
|
+
if (failure === "blocked-retention") await rm(`${active}.1`, {recursive: true})
|
|
373
|
+
|
|
374
|
+
logger.log("transport.connected", {generation: 3})
|
|
375
|
+
expect((await readFile(`${active}.1`)).equals(torn)).toBeTruthy()
|
|
376
|
+
expect(parseLines(await readFile(active, "utf8")).map(({generation}) => generation)).toEqual([3])
|
|
377
|
+
expect(reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
|
|
378
|
+
}))
|
|
379
|
+
}
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it("file logger preserves retained evidence when clean active creation is blocked", async () => withTestCleanup(async (after) => {
|
|
383
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-create-failure-"))
|
|
384
|
+
after(() => cleanup(root))
|
|
385
|
+
const active = join(root, "relay-101.jsonl")
|
|
386
|
+
const torn = Buffer.from('{}\n{"timestamp":"2026-')
|
|
387
|
+
await writeFile(`${active}.1`, torn, {mode: 0o600})
|
|
388
|
+
await mkdir(active, {mode: 0o700})
|
|
389
|
+
await expect(writeFile(active, "fixture", {flag: "a"})).rejects.toMatchObject({code: "EISDIR"})
|
|
390
|
+
/** @type {string[]} */
|
|
391
|
+
const reports = []
|
|
392
|
+
const logger = createJsonlFileLogger({
|
|
393
|
+
directory: root, role: "relay", processId: 101, maxFiles: 2,
|
|
394
|
+
stderr: {write: (/** @type {string} */ message) => reports.push(message)}
|
|
395
|
+
})
|
|
396
|
+
for (let generation = 0; generation < 3; generation += 1) logger.log("transport.connected", {generation})
|
|
397
|
+
expect((await readFile(`${active}.1`)).equals(torn)).toBeTruthy()
|
|
398
|
+
expect(reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
|
|
399
|
+
await rm(active, {recursive: true})
|
|
400
|
+
logger.log("transport.connected", {generation: 3})
|
|
401
|
+
expect((await readFile(`${active}.1`)).equals(torn)).toBeTruthy()
|
|
402
|
+
expect(parseLines(await readFile(active, "utf8")).map(({generation}) => generation)).toEqual([3])
|
|
403
|
+
}))
|
|
404
|
+
|
|
405
|
+
it("file logger verifies the mode of a newly created active file before writing", async () => withTestCleanup(async (after) => {
|
|
406
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-create-mode-"))
|
|
407
|
+
after(() => cleanup(root))
|
|
408
|
+
const active = join(root, "relay-101.jsonl")
|
|
409
|
+
/** @type {string[]} */
|
|
410
|
+
const reports = []
|
|
411
|
+
const logger = createJsonlFileLogger({
|
|
412
|
+
directory: root, role: "relay", processId: 101,
|
|
413
|
+
stderr: {write: (/** @type {string} */ message) => reports.push(message)}
|
|
414
|
+
})
|
|
415
|
+
const previousMask = process.umask(0o777)
|
|
416
|
+
try {
|
|
417
|
+
logger.log("process.started", {runtimeMode: "relay"})
|
|
418
|
+
} finally {
|
|
419
|
+
process.umask(previousMask)
|
|
420
|
+
}
|
|
421
|
+
expect((await stat(active)).mode & 0o777).toBe(0)
|
|
422
|
+
expect((await stat(active)).size).toBe(0)
|
|
423
|
+
expect(reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
|
|
424
|
+
await chmod(active, 0o600)
|
|
425
|
+
logger.log("transport.connected", {generation: 1})
|
|
426
|
+
expect(parseLines(await readFile(active, "utf8")).map(({event}) => event)).toEqual(["transport.connected"])
|
|
427
|
+
}))
|
|
428
|
+
|
|
429
|
+
describe("repeated incomplete tails respect numbered retention at the minimum size limit", () => {
|
|
430
|
+
for (const maxFiles of [1, 2, 5]) {
|
|
431
|
+
it(`maxFiles=${maxFiles}`, async () => withTestCleanup(async (after) => {
|
|
432
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-tail-caps-"))
|
|
433
|
+
after(() => cleanup(root))
|
|
434
|
+
const active = join(root, "relay-101.jsonl")
|
|
435
|
+
/** @type {string[]} */
|
|
436
|
+
const reports = []
|
|
437
|
+
const logger = createJsonlFileLogger({
|
|
438
|
+
directory: root, role: "relay", processId: 101, maxFiles, maxFileBytes: 1024,
|
|
439
|
+
stderr: {write: (/** @type {string} */ message) => reports.push(message)}
|
|
440
|
+
})
|
|
441
|
+
logger.log("process.started", {runtimeMode: "relay"})
|
|
442
|
+
for (let generation = 1; generation <= (maxFiles === 1 ? 1 : 8); generation += 1) {
|
|
443
|
+
const size = (await stat(active)).size
|
|
444
|
+
await writeFile(active, "{".padEnd(1000 - size, "x"), {flag: "a"})
|
|
445
|
+
const torn = await readFile(active)
|
|
446
|
+
for (let attempt = 0; attempt < 3; attempt += 1) logger.log("transport.connected", {generation})
|
|
447
|
+
expect((await readFile(maxFiles === 1 ? active : `${active}.1`)).equals(torn)).toBeTruthy()
|
|
448
|
+
const files = await readdir(root)
|
|
449
|
+
expect(files.length).toBe(Math.min(generation + 1, maxFiles))
|
|
450
|
+
for (const file of files) expect((await stat(join(root, file))).size <= 1024).toBeTruthy()
|
|
451
|
+
if (maxFiles > 1) expect(parseLines(await readFile(active, "utf8")).map((entry) => entry.generation)).toEqual([generation, generation, generation])
|
|
452
|
+
}
|
|
453
|
+
expect(reports).toEqual(maxFiles === 1 ? ["Socketduct logger failure (LOGGER_IO_ERROR)\n"] : [])
|
|
454
|
+
}))
|
|
455
|
+
}
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
it("oversized sanitized records fall back to a bounded envelope", () => {
|
|
459
|
+
/** @type {string[]} */
|
|
460
|
+
const lines = []
|
|
461
|
+
const logger = createStructuredLogger({role: "relay", writeLine: (line) => lines.push(line)})
|
|
462
|
+
const counters = Object.fromEntries("activeTargets activeTunnels attachedSessions authenticatedConnections bufferedBytes bufferedTargetBytes clientPhysicalConnections connectionGeneration connectionIdentities controlQueuedBytes dataQueuedBytes detachedSessions gatewayPhysicalConnections gatewayRegistrations healthyGateways openFailures pendingOpens physicalAccepts physicalConnections physicalConnects rawDataBytes registeredGateways rejectedOpens rejectedRegistrations retainedClientIdentities targetOpens virtualStreams wireDataBytes".split(" ").map((field) => [field, Number.MAX_SAFE_INTEGER]))
|
|
463
|
+
logger.log("runtime.status", {...counters, password: "private-fixture-password"})
|
|
464
|
+
expect(lines.length).toBe(1)
|
|
465
|
+
const line = lines[0]
|
|
466
|
+
if (line === undefined) throw new Error("Expected a captured log record")
|
|
467
|
+
expect(Buffer.byteLength(line) <= 1024).toBeTruthy()
|
|
468
|
+
expect(Object.keys(JSON.parse(line))).toEqual(["timestamp", "event", "processId", "role"])
|
|
469
|
+
expect(line).not.toMatch(/private-fixture-password/)
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it("file logger leaves newline-terminated historical corruption unchanged", async () => withTestCleanup(async (after) => {
|
|
473
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-historical-"))
|
|
474
|
+
after(() => cleanup(root))
|
|
475
|
+
const active = join(root, "relay-101.jsonl")
|
|
476
|
+
const historical = '{"timestamp":"torn{"timestamp":"joined"}\n'
|
|
477
|
+
await writeFile(active, historical, {mode: 0o600})
|
|
478
|
+
createJsonlFileLogger({directory: root, role: "relay", processId: 101}).log("transport.connected", {generation: 1})
|
|
479
|
+
const contents = await readFile(active, "utf8")
|
|
480
|
+
expect(contents.slice(0, historical.length)).toBe(historical)
|
|
481
|
+
expect(JSON.parse(contents.slice(historical.length)).event).toBe("transport.connected")
|
|
482
|
+
expect(await readdir(root)).toEqual(["relay-101.jsonl"])
|
|
483
|
+
}))
|
|
484
|
+
|
|
485
|
+
it("file logger rotates deterministically and bounds total retained files", async () => withTestCleanup(async (after) => {
|
|
486
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-rotate-"))
|
|
487
|
+
after(() => cleanup(root))
|
|
488
|
+
const logger = createJsonlFileLogger({
|
|
489
|
+
directory: root,
|
|
490
|
+
role: "relay",
|
|
491
|
+
processId: 202,
|
|
492
|
+
maxFileBytes: 1024,
|
|
493
|
+
maxFiles: 3,
|
|
494
|
+
now: () => new Date(FIXED_TIME)
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
for (let index = 0; index < 40; index += 1) {
|
|
498
|
+
logger.log("runtime.status", {
|
|
499
|
+
physicalConnections: index,
|
|
500
|
+
authenticatedConnections: index,
|
|
501
|
+
virtualStreams: index,
|
|
502
|
+
queuedBytes: index * 10,
|
|
503
|
+
bufferedBytes: index * 20
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const files = (await readdir(root)).sort()
|
|
508
|
+
expect(files).toEqual(["relay-202.jsonl", "relay-202.jsonl.1", "relay-202.jsonl.2"])
|
|
509
|
+
for (const file of files) {
|
|
510
|
+
const path = join(root, file)
|
|
511
|
+
expect((await stat(path)).size <= 1024).toBeTruthy()
|
|
512
|
+
for (const entry of parseLines(await readFile(path, "utf8"))) expect(typeof entry.event).toBe("string")
|
|
513
|
+
}
|
|
514
|
+
}))
|
|
515
|
+
|
|
516
|
+
it("file logger enforces lower bounds after retention configuration changes", async () => withTestCleanup(async (after) => {
|
|
517
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-reconfigure-"))
|
|
518
|
+
after(() => cleanup(root))
|
|
519
|
+
const active = join(root, "relay-303.jsonl")
|
|
520
|
+
await writeFile(active, "{}\n".repeat(330), {mode: 0o600})
|
|
521
|
+
await writeFile(`${active}.1`, "{}\n".repeat(400), {mode: 0o600})
|
|
522
|
+
await writeFile(`${active}.2`, "{}\n", {mode: 0o600})
|
|
523
|
+
await writeFile(`${active}.9`, "{}\n", {mode: 0o600})
|
|
524
|
+
|
|
525
|
+
const logger = createJsonlFileLogger({directory: root, role: "relay", processId: 303, maxFileBytes: 1024, maxFiles: 2})
|
|
526
|
+
logger.log("process.started", {runtimeMode: "relay"})
|
|
527
|
+
|
|
528
|
+
const files = (await readdir(root)).sort()
|
|
529
|
+
expect(files).toEqual(["relay-303.jsonl", "relay-303.jsonl.1"])
|
|
530
|
+
for (const file of files) expect((await stat(join(root, file))).size <= 1024).toBeTruthy()
|
|
531
|
+
}))
|
|
532
|
+
|
|
533
|
+
it("file logger isolates active paths by role and process", async () => withTestCleanup(async (after) => {
|
|
534
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-paths-"))
|
|
535
|
+
after(() => cleanup(root))
|
|
536
|
+
|
|
537
|
+
createJsonlFileLogger({directory: root, role: "reverse-hub", processId: 10}).log("process.started", {runtimeMode: "reverse-hub"})
|
|
538
|
+
createJsonlFileLogger({directory: root, role: "reverse-gateway", processId: 10}).log("process.started", {runtimeMode: "reverse-gateway"})
|
|
539
|
+
createJsonlFileLogger({directory: root, role: "reverse-hub", processId: 11}).log("process.started", {runtimeMode: "reverse-hub"})
|
|
540
|
+
|
|
541
|
+
expect((await readdir(root)).sort()).toEqual([
|
|
542
|
+
"reverse-gateway-10.jsonl",
|
|
543
|
+
"reverse-hub-10.jsonl",
|
|
544
|
+
"reverse-hub-11.jsonl"
|
|
545
|
+
])
|
|
546
|
+
}))
|
|
547
|
+
|
|
548
|
+
it("file logger caps only definitely exited same-role process families across PID reuse", async () => withTestCleanup(async (after) => {
|
|
549
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-obsolete-"))
|
|
550
|
+
after(() => cleanup(root))
|
|
551
|
+
const currentPid = process.pid
|
|
552
|
+
const firstExitedPid = currentPid + 10_001
|
|
553
|
+
const secondExitedPid = currentPid + 10_002
|
|
554
|
+
const thirdExitedPid = currentPid + 10_003
|
|
555
|
+
const exitedPids = new Set([firstExitedPid, secondExitedPid, thirdExitedPid])
|
|
556
|
+
const reusedPid = currentPid + 10_004
|
|
557
|
+
const livePid = currentPid + 10_005
|
|
558
|
+
const unknownPid = currentPid + 10_006
|
|
559
|
+
const timestamp = new Date("2026-01-01T00:00:00.000Z")
|
|
560
|
+
/** @type {string[]} */
|
|
561
|
+
const exitedFiles = []
|
|
562
|
+
|
|
563
|
+
for (const [offset, processId] of [...exitedPids].entries()) {
|
|
564
|
+
for (const rotation of ["", ".1"]) {
|
|
565
|
+
const file = `relay-${processId}.jsonl${rotation}`
|
|
566
|
+
await writeFile(join(root, file), "{}\n")
|
|
567
|
+
await utimes(join(root, file), timestamp, new Date(timestamp.getTime() + (2 + offset * 2 + (rotation === "" ? 1 : 0)) * 1000))
|
|
568
|
+
exitedFiles.push(file)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const reusedFiles = [`relay-${reusedPid}.jsonl`, `relay-${reusedPid}.jsonl.1`]
|
|
572
|
+
for (const [offset, file] of reusedFiles.entries()) {
|
|
573
|
+
await writeFile(join(root, file), "{}\n")
|
|
574
|
+
await utimes(join(root, file), timestamp, new Date(timestamp.getTime() + offset * 1000))
|
|
575
|
+
}
|
|
576
|
+
const liveFiles = [`relay-${livePid}.jsonl`, `relay-${livePid}.jsonl.1`]
|
|
577
|
+
const unknownFiles = [`relay-${unknownPid}.jsonl`, `relay-${unknownPid}.jsonl.1`]
|
|
578
|
+
const otherRoleFile = `reverse-hub-${currentPid + 10_007}.jsonl`
|
|
579
|
+
for (const file of [...liveFiles, ...unknownFiles, otherRoleFile]) await writeFile(join(root, file), "{}\n")
|
|
580
|
+
|
|
581
|
+
let reusedProbes = 0
|
|
582
|
+
const mocks = createMockScope()
|
|
583
|
+
after(() => mocks.restoreAll())
|
|
584
|
+
mocks.spyOn(process, "kill").mockImplementation((/** @type {number} */ processId, /** @type {string | number | undefined} */ signal) => {
|
|
585
|
+
expect(signal).toBe(0)
|
|
586
|
+
if (exitedPids.has(processId)) throw Object.assign(new Error("alphabeticsecret"), {code: "ESRCH"})
|
|
587
|
+
if (processId === reusedPid) {
|
|
588
|
+
reusedProbes += 1
|
|
589
|
+
if (reusedProbes === 1) throw Object.assign(new Error("exited before reuse"), {code: "ESRCH"})
|
|
590
|
+
return true
|
|
591
|
+
}
|
|
592
|
+
if (processId === livePid) return true
|
|
593
|
+
if (processId === unknownPid) throw Object.assign(new Error("lowentropysecret"), {code: "EPERM"})
|
|
594
|
+
throw new Error(`unexpected process probe: ${processId}`)
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
/** @type {string[]} */
|
|
598
|
+
const reports = []
|
|
599
|
+
const logger = createJsonlFileLogger({
|
|
600
|
+
directory: root,
|
|
601
|
+
role: "relay",
|
|
602
|
+
processId: currentPid,
|
|
603
|
+
maxFiles: 3,
|
|
604
|
+
stderr: {write: (/** @type {string} */ message) => { reports.push(message); return true }}
|
|
605
|
+
})
|
|
606
|
+
await expect(() => logger.log("process.started", {runtimeMode: "relay"})).not.toThrow()
|
|
607
|
+
|
|
608
|
+
const files = (await readdir(root)).sort()
|
|
609
|
+
expect(files.filter((file) => exitedFiles.includes(file))).toEqual([
|
|
610
|
+
`relay-${secondExitedPid}.jsonl`,
|
|
611
|
+
`relay-${thirdExitedPid}.jsonl`,
|
|
612
|
+
`relay-${thirdExitedPid}.jsonl.1`
|
|
613
|
+
].sort())
|
|
614
|
+
expect(reusedFiles.filter((file) => files.includes(file))).toEqual(reusedFiles)
|
|
615
|
+
expect(liveFiles.filter((file) => files.includes(file))).toEqual(liveFiles)
|
|
616
|
+
expect(unknownFiles.filter((file) => files.includes(file))).toEqual(unknownFiles)
|
|
617
|
+
expect(files.includes(otherRoleFile)).toBeTruthy()
|
|
618
|
+
expect(files.includes(`relay-${currentPid}.jsonl`)).toBeTruthy()
|
|
619
|
+
expect(reusedProbes >= 2).toBeTruthy()
|
|
620
|
+
expect(reports).toEqual([])
|
|
621
|
+
}))
|
|
622
|
+
|
|
623
|
+
it("file and custom writer failures are isolated and reported once without error text", async () => withTestCleanup(async (after) => {
|
|
624
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-failure-"))
|
|
625
|
+
after(() => cleanup(root))
|
|
626
|
+
const blocker = join(root, "not-a-directory")
|
|
627
|
+
await writeFile(blocker, "occupied")
|
|
628
|
+
/** @type {string[]} */
|
|
629
|
+
const reports = []
|
|
630
|
+
const stderr = {write: (/** @type {string} */ message) => { reports.push(String(message)); return true }}
|
|
631
|
+
const fileLogger = createJsonlFileLogger({directory: join(blocker, "logs"), role: "relay", stderr})
|
|
632
|
+
|
|
633
|
+
await expect(() => fileLogger.log("process.started", {runtimeMode: "relay"})).not.toThrow()
|
|
634
|
+
await expect(() => fileLogger.log("runtime.status", {physicalConnections: 1})).not.toThrow()
|
|
635
|
+
expect(reports.length).toBe(1)
|
|
636
|
+
const report = reports[0]
|
|
637
|
+
if (report === undefined) throw new Error("Expected a captured log record")
|
|
638
|
+
expect(report).toMatch(/^Socketduct logger failure \(LOGGER_IO_ERROR\)\n$/)
|
|
639
|
+
expect(report).not.toMatch(/not-a-directory|ENOTDIR|occupied/)
|
|
640
|
+
|
|
641
|
+
/** @type {string[]} */
|
|
642
|
+
const customReports = []
|
|
643
|
+
const custom = createStructuredLogger({
|
|
644
|
+
role: "embedded-client",
|
|
645
|
+
stderr: {write: (/** @type {string} */ message) => { customReports.push(String(message)); return true }},
|
|
646
|
+
writeLine: () => { throw new Error("alphabeticsecret eyJhbGciOiJIUzI1NiJ9.private.signature") }
|
|
647
|
+
})
|
|
648
|
+
await expect(() => custom.log("transport.connected", {generation: 1})).not.toThrow()
|
|
649
|
+
await expect(() => custom.log("transport.disconnected", {generation: 1})).not.toThrow()
|
|
650
|
+
expect(customReports).toEqual(["Socketduct logger failure (LOGGER_WRITER_ERROR)\n"])
|
|
651
|
+
|
|
652
|
+
/** @type {string[]} */
|
|
653
|
+
const asyncReports = []
|
|
654
|
+
const asyncCustom = createStructuredLogger({
|
|
655
|
+
role: "embedded-client",
|
|
656
|
+
stderr: {write: (/** @type {string} */ message) => { asyncReports.push(String(message)); return true }},
|
|
657
|
+
writeLine: async () => { throw new Error("lowentropysecret") }
|
|
658
|
+
})
|
|
659
|
+
asyncCustom.log("transport.connected", {generation: 1})
|
|
660
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
661
|
+
expect(asyncReports).toEqual(["Socketduct logger failure (LOGGER_WRITER_ERROR)\n"])
|
|
662
|
+
}))
|
|
663
|
+
|
|
664
|
+
it("file logger validates bounded rotation and safe role configuration", async () => withTestCleanup(async (after) => {
|
|
665
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-validation-"))
|
|
666
|
+
after(() => cleanup(root))
|
|
667
|
+
await mkdir(join(root, "logs"))
|
|
668
|
+
|
|
669
|
+
await expect(() => createJsonlFileLogger({directory: join(root, "logs"), role: "../escape"})).toThrow(/role/)
|
|
670
|
+
await expect(() => createJsonlFileLogger({directory: join(root, "logs"), role: "relay", maxFileBytes: 100})).toThrow(/maxFileBytes/)
|
|
671
|
+
await expect(() => createJsonlFileLogger({directory: join(root, "logs"), role: "relay", maxFiles: 0})).toThrow(/maxFiles/)
|
|
672
|
+
await expect(() => createJsonlFileLogger({directory: join(root, "logs"), role: "relay", maxFiles: 101})).toThrow(/maxFiles/)
|
|
673
|
+
}))
|
|
674
|
+
})
|