rollbridge 0.1.28 → 0.1.30
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/AGENTS.md +14 -0
- package/README.md +69 -14
- package/TODO.md +5 -2
- package/changelog.d/20260828-atomic-owner-replacement.md +22 -0
- package/changelog.d/20260828-durable-owner-recovery.md +7 -0
- package/changelog.d/20260828-same-owner-jobs-generations.md +6 -0
- package/docs/cli.md +43 -21
- package/docs/config.md +71 -18
- package/docs/logging.md +8 -3
- package/docs/tensorbuzz-runbook.md +7 -6
- package/docs/troubleshooting.md +28 -12
- package/docs/velocious.md +11 -4
- package/docs/workers.md +8 -2
- package/examples/tensorbuzz.com.js +12 -4
- package/package.json +1 -1
- package/src/cli.js +209 -36
- package/src/config.js +8 -2
- package/src/control-client.js +118 -1
- package/src/daemon.js +939 -53
- package/src/guardian-client.js +434 -0
- package/src/managed-process.js +45 -15
- package/src/process-guardian.js +601 -0
- package/src/release-group.js +190 -15
- package/src/state-store.js +1 -1
- package/test/config-validation.test.js +22 -0
- package/test/fixtures/pre-split3-daemon-runner.js +30 -0
- package/test/fixtures/pre-split3-daemon.js +1336 -0
- package/test/fixtures/pre-split3-guardian-client.js +293 -0
- package/test/fixtures/pre-split3-process-guardian.js +292 -0
- package/test/fixtures/service-app.js +32 -2
- package/test/guardian-client.test.js +304 -0
- package/test/owner-recovery.test.js +950 -0
- package/test/owner-replacement.test.js +772 -0
- package/test/release-runtime-retention.test.js +1 -1
- package/test/rollbridge.test.js +178 -5
- package/test/shutdown-completion.test.js +1 -1
- package/test/state-store.test.js +12 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict"
|
|
4
|
+
import {spawn} from "node:child_process"
|
|
5
|
+
import {once} from "node:events"
|
|
6
|
+
import fs from "node:fs/promises"
|
|
7
|
+
import net from "node:net"
|
|
8
|
+
import os from "node:os"
|
|
9
|
+
import path from "node:path"
|
|
10
|
+
import test from "node:test"
|
|
11
|
+
import {fileURLToPath} from "node:url"
|
|
12
|
+
import {sendControlCommand} from "../src/control-client.js"
|
|
13
|
+
import {isLegacyGuardianPrepareDiagnostic} from "../src/daemon.js"
|
|
14
|
+
import GuardianClient from "../src/guardian-client.js"
|
|
15
|
+
import {findAvailablePort} from "../src/port-allocator.js"
|
|
16
|
+
|
|
17
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
18
|
+
const binPath = path.join(repoRoot, "bin", "rollbridge")
|
|
19
|
+
const dummyAppPath = path.join(repoRoot, "test", "fixtures", "dummy-app.js")
|
|
20
|
+
const legacyDaemonPath = path.join(repoRoot, "test", "fixtures", "pre-split3-daemon-runner.js")
|
|
21
|
+
|
|
22
|
+
test("first pre-split package upgrade is explicitly disruptive and later replacements are atomic", async () => {
|
|
23
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-legacy-"))
|
|
24
|
+
const socketPath = path.join(root, "rollbridge.sock")
|
|
25
|
+
const nextSocketPath = path.join(root, "next.sock")
|
|
26
|
+
const statePath = path.join(root, "state.json")
|
|
27
|
+
const configPath = path.join(root, "rollbridge.cjs")
|
|
28
|
+
const daemonPidPath = path.join(root, "daemon.pid")
|
|
29
|
+
const releasePath = path.join(root, "v1")
|
|
30
|
+
const runtimePath = path.join(root, "runtime")
|
|
31
|
+
const firstPackagePath = path.join(root, "first-package")
|
|
32
|
+
const secondPackagePath = path.join(root, "second-package")
|
|
33
|
+
const proxyPort = await findAvailablePort({host: "127.0.0.1", range: {from: 25000, to: 25999}, usedPorts: new Set()})
|
|
34
|
+
let owner
|
|
35
|
+
let interruptedConnection
|
|
36
|
+
let retainedConnection
|
|
37
|
+
let blockedUpgradeGuardian
|
|
38
|
+
let currentControlPath = socketPath
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
await fs.mkdir(releasePath)
|
|
42
|
+
await makeFifo(path.join(releasePath, "worker.fifo"))
|
|
43
|
+
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, proxyPort, statePath}))
|
|
44
|
+
owner = spawn(process.execPath, [legacyDaemonPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
45
|
+
await waitForLog(owner, "control socket listening")
|
|
46
|
+
assert.ok(owner.pid)
|
|
47
|
+
await fs.writeFile(daemonPidPath, `${owner.pid}\n`)
|
|
48
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: socketPath})
|
|
49
|
+
const legacyStatus = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
50
|
+
const legacyWorkerPid = releaseProcessPid(legacyStatus, "v1", "worker")
|
|
51
|
+
|
|
52
|
+
interruptedConnection = await openWebSocket(proxyPort)
|
|
53
|
+
const interrupted = once(interruptedConnection, "close")
|
|
54
|
+
await prepareCandidatePackage(firstPackagePath)
|
|
55
|
+
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: true, proxyPort, statePath}))
|
|
56
|
+
const mismatchedUpgrade = await runEnsureDaemon({configPath, daemonPidPath, logPath: path.join(root, "mismatch.log"), packagePath: firstPackagePath, runtimePath})
|
|
57
|
+
|
|
58
|
+
assert.equal(mismatchedUpgrade.code, 1)
|
|
59
|
+
assert.match(await fs.readFile(path.join(root, "mismatch.log"), "utf8"), /legacy guardian bridge requires the incumbent config identity unchanged/)
|
|
60
|
+
assert.equal(interruptedConnection.destroyed, false, "config mismatch must leave the legacy listener serving")
|
|
61
|
+
assert.equal(releaseProcessPid(await sendControlCommand({command: {command: "status"}, path: socketPath}), "v1", "worker"), legacyWorkerPid)
|
|
62
|
+
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, proxyPort, statePath}))
|
|
63
|
+
blockedUpgradeGuardian = net.createServer()
|
|
64
|
+
|
|
65
|
+
await listenUnix(blockedUpgradeGuardian, `${statePath}.split3-guardian.sock`)
|
|
66
|
+
const blockedUpgrade = await runEnsureDaemon({configPath, daemonPidPath, logPath: path.join(root, "blocked.log"), packagePath: firstPackagePath, runtimePath})
|
|
67
|
+
|
|
68
|
+
assert.equal(blockedUpgrade.code, 1)
|
|
69
|
+
assert.match(await fs.readFile(path.join(root, "blocked.log"), "utf8"), /Legacy upgrade guardian socket .* already exists; refusing legacy upgrade/)
|
|
70
|
+
assert.equal(interruptedConnection.destroyed, false, "candidate preparation failure must leave the legacy listener serving")
|
|
71
|
+
assert.equal(releaseProcessPid(await sendControlCommand({command: {command: "status"}, path: socketPath}), "v1", "worker"), legacyWorkerPid)
|
|
72
|
+
await closeServer(blockedUpgradeGuardian)
|
|
73
|
+
const firstUpgrade = await run(process.execPath, [
|
|
74
|
+
path.join(firstPackagePath, "bin", "rollbridge"), "ensure-daemon", "--config", configPath,
|
|
75
|
+
"--daemon-runtime-path", runtimePath, "--daemon-log-path", path.join(root, "first.log"),
|
|
76
|
+
"--daemon-pid-path", daemonPidPath, "--daemon-start-timeout-ms", "3000"
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
assert.equal(firstUpgrade.code, 0, `${firstUpgrade.stderr}\n${await fs.readFile(path.join(root, "first.log"), "utf8")}`)
|
|
80
|
+
assert.deepEqual(JSON.parse(firstUpgrade.stdout).ownerTransition, {
|
|
81
|
+
disruptive: true,
|
|
82
|
+
mode: "legacy-first-upgrade",
|
|
83
|
+
reason: "pre-split guardian and daemon lacked atomic replacement protocol"
|
|
84
|
+
})
|
|
85
|
+
await interrupted
|
|
86
|
+
const bridged = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
87
|
+
|
|
88
|
+
assert.deepEqual(bridged.ownerTransition, {
|
|
89
|
+
disruptive: true,
|
|
90
|
+
mode: "legacy-first-upgrade",
|
|
91
|
+
reason: "pre-split guardian and daemon lacked atomic replacement protocol"
|
|
92
|
+
})
|
|
93
|
+
assert.equal(releaseProcessPid(bridged, "v1", "worker"), legacyWorkerPid)
|
|
94
|
+
assert.equal(bridged.activeReleaseId, "v1")
|
|
95
|
+
|
|
96
|
+
retainedConnection = await openWebSocket(proxyPort)
|
|
97
|
+
let retainedConnectionClosed = false
|
|
98
|
+
retainedConnection.once("close", () => { retainedConnectionClosed = true })
|
|
99
|
+
await prepareCandidatePackage(secondPackagePath)
|
|
100
|
+
await writeConfig(configPath, config({controlPath: nextSocketPath, extraCompanion: true, proxyPort, statePath}))
|
|
101
|
+
const secondUpgrade = await run(process.execPath, [
|
|
102
|
+
path.join(secondPackagePath, "bin", "rollbridge"), "ensure-daemon", "--config", configPath,
|
|
103
|
+
"--daemon-runtime-path", runtimePath, "--daemon-log-path", path.join(root, "second.log"),
|
|
104
|
+
"--daemon-pid-path", daemonPidPath, "--daemon-start-timeout-ms", "3000"
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
assert.equal(secondUpgrade.code, 0, `${secondUpgrade.stderr}\n${await fs.readFile(path.join(root, "second.log"), "utf8")}`)
|
|
108
|
+
currentControlPath = nextSocketPath
|
|
109
|
+
assert.equal(retainedConnectionClosed, false, "protocol-capable replacement must retain established proxy connections")
|
|
110
|
+
const replaced = await sendControlCommand({command: {command: "status"}, path: nextSocketPath})
|
|
111
|
+
|
|
112
|
+
assert.equal(releaseProcessPid(replaced, "v1", "worker"), legacyWorkerPid)
|
|
113
|
+
assert.equal(replaced.activeReleaseId, "v1")
|
|
114
|
+
retainedConnection.destroy()
|
|
115
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: nextSocketPath})
|
|
116
|
+
await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
|
|
117
|
+
await shutdown
|
|
118
|
+
} finally {
|
|
119
|
+
interruptedConnection?.destroy()
|
|
120
|
+
retainedConnection?.destroy()
|
|
121
|
+
if (blockedUpgradeGuardian?.listening) await closeServer(blockedUpgradeGuardian).catch(() => undefined)
|
|
122
|
+
if (owner && owner.exitCode === null && owner.signalCode === null) owner.kill("SIGKILL")
|
|
123
|
+
try {
|
|
124
|
+
const status = await sendControlCommand({command: {command: "status"}, path: currentControlPath})
|
|
125
|
+
|
|
126
|
+
if (status.activeReleaseId) {
|
|
127
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: currentControlPath}).catch(() => undefined)
|
|
128
|
+
await Promise.all([fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => undefined), shutdown])
|
|
129
|
+
}
|
|
130
|
+
} catch (_error) {
|
|
131
|
+
// The exact fixture daemon is already stopped or failed before publishing control.
|
|
132
|
+
}
|
|
133
|
+
await stopGuardian(statePath)
|
|
134
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test("ensure-daemon owns and reports the exact candidate exit before readiness", async () => {
|
|
139
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-candidate-exit-"))
|
|
140
|
+
const configPath = path.join(root, "rollbridge.cjs")
|
|
141
|
+
const evidencePath = path.join(root, "candidate.json")
|
|
142
|
+
const packagePath = path.join(root, "candidate-package")
|
|
143
|
+
const runtimePath = path.join(root, "runtime")
|
|
144
|
+
const socketPath = path.join(root, "rollbridge.sock")
|
|
145
|
+
const statePath = path.join(root, "state.json")
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
|
|
149
|
+
await prepareCandidatePackage(packagePath)
|
|
150
|
+
await installCandidateExit(packagePath, evidencePath, 47)
|
|
151
|
+
const ensured = await run(process.execPath, [
|
|
152
|
+
path.join(packagePath, "bin", "rollbridge"), "ensure-daemon", "--config", configPath,
|
|
153
|
+
"--daemon-runtime-path", runtimePath, "--daemon-log-path", path.join(root, "daemon.log"),
|
|
154
|
+
"--daemon-pid-path", path.join(root, "daemon.pid"), "--daemon-start-timeout-ms", "3000"
|
|
155
|
+
])
|
|
156
|
+
const candidate = JSON.parse(await fs.readFile(evidencePath, "utf8"))
|
|
157
|
+
|
|
158
|
+
assert.equal(candidate.ppid, ensured.pid, "the recorded process must be the candidate spawned by this exact ensuring CLI")
|
|
159
|
+
assert.notEqual(candidate.pid, ensured.pid)
|
|
160
|
+
assert.deepEqual(candidate.argv.slice(2), ["daemon", "--config", configPath])
|
|
161
|
+
assert.equal(ensured.code, 1)
|
|
162
|
+
assert.match(ensured.stderr, new RegExp(`Rollbridge daemon candidate ${candidate.pid} exited before readiness \\(code 47, signal none\\)`))
|
|
163
|
+
assert.doesNotMatch(ensured.stderr, /did not become ready within/)
|
|
164
|
+
} finally {
|
|
165
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test("legacy disruptive bridge rejects non-protocol guardian failures exactly", () => {
|
|
170
|
+
assert.equal(isLegacyGuardianPrepareDiagnostic("Guardian prepare-owner-replacement requires a process key"), true)
|
|
171
|
+
assert.equal(isLegacyGuardianPrepareDiagnostic("Unknown guardian command: prepare-owner-replacement"), true)
|
|
172
|
+
for (const diagnostic of [
|
|
173
|
+
"Unknown guardian command: deploy",
|
|
174
|
+
"Unknown guardian command: prepare-owner-replacement ",
|
|
175
|
+
"Guardian authentication failed",
|
|
176
|
+
"connect ECONNREFUSED /tmp/guardian.sock",
|
|
177
|
+
"Process guardian connection closed while awaiting prepare-owner-replacement",
|
|
178
|
+
"Guardian owner authority mismatch",
|
|
179
|
+
"Malformed guardian response"
|
|
180
|
+
]) assert.equal(isLegacyGuardianPrepareDiagnostic(diagnostic), false, diagnostic)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test("ensure-daemon atomically replaces incompatible config, socket, and package authority", async () => {
|
|
184
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-"))
|
|
185
|
+
const oldSocketPath = path.join(root, "old.sock")
|
|
186
|
+
const newSocketPath = path.join(root, "new.sock")
|
|
187
|
+
const statePath = path.join(root, "state.json")
|
|
188
|
+
const configPath = path.join(root, "rollbridge.cjs")
|
|
189
|
+
const runtimePath = path.join(root, "runtime")
|
|
190
|
+
const packagePath = path.join(root, "candidate-package")
|
|
191
|
+
const v1Path = path.join(root, "v1")
|
|
192
|
+
const v2Path = path.join(root, "v2")
|
|
193
|
+
let owner
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
|
|
197
|
+
await makeFifo(path.join(v1Path, "worker.fifo"))
|
|
198
|
+
await makeFifo(path.join(v2Path, "worker.fifo"))
|
|
199
|
+
await writeConfig(configPath, config({controlPath: oldSocketPath, extraCompanion: false, statePath}))
|
|
200
|
+
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
201
|
+
await waitForLog(owner, "control socket listening")
|
|
202
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: oldSocketPath})
|
|
203
|
+
const before = await sendControlCommand({command: {command: "status"}, path: oldSocketPath})
|
|
204
|
+
const oldRuntime = /** @type {{digest: string}} */ (before.daemonRuntime)
|
|
205
|
+
const workerPid = releaseProcessPid(before, "v1", "worker")
|
|
206
|
+
|
|
207
|
+
await prepareCandidatePackage(packagePath, {dropCommitResponse: true})
|
|
208
|
+
await writeConfig(configPath, config({controlPath: newSocketPath, extraCompanion: true, statePath}))
|
|
209
|
+
const ensured = await run(process.execPath, [
|
|
210
|
+
path.join(packagePath, "bin", "rollbridge"), "ensure-daemon", "--config", configPath,
|
|
211
|
+
"--daemon-runtime-path", runtimePath, "--daemon-log-path", path.join(root, "daemon.log"),
|
|
212
|
+
"--daemon-pid-path", path.join(root, "daemon.pid"), "--daemon-start-timeout-ms", "3000"
|
|
213
|
+
])
|
|
214
|
+
|
|
215
|
+
const daemonLog = await fs.readFile(path.join(root, "daemon.log"), "utf8")
|
|
216
|
+
|
|
217
|
+
assert.equal(ensured.code, 0, `${ensured.stderr}\n${daemonLog}`)
|
|
218
|
+
const transferred = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
|
|
219
|
+
const newRuntime = /** @type {{digest: string, path: string}} */ (transferred.daemonRuntime)
|
|
220
|
+
|
|
221
|
+
assert.equal(transferred.activeReleaseId, "v1")
|
|
222
|
+
assert.deepEqual(transferred.releaseReferences, [{releaseId: "v1", releasePath: v1Path}])
|
|
223
|
+
assert.equal(releaseProcessPid(transferred, "v1", "worker"), workerPid)
|
|
224
|
+
assert.notEqual(newRuntime.digest, oldRuntime.digest)
|
|
225
|
+
assert.equal(path.dirname(newRuntime.path), runtimePath)
|
|
226
|
+
|
|
227
|
+
await fs.rm(packagePath, {force: true, recursive: true})
|
|
228
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: newSocketPath})
|
|
229
|
+
const deployed = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
|
|
230
|
+
|
|
231
|
+
assert.equal(deployed.activeReleaseId, "v2")
|
|
232
|
+
assert.deepEqual(deployed.releaseReferences, [
|
|
233
|
+
{releaseId: "v1", releasePath: v1Path},
|
|
234
|
+
{releaseId: "v2", releasePath: v2Path}
|
|
235
|
+
])
|
|
236
|
+
assert.equal(releaseProcessPid(deployed, "v1", "worker"), workerPid)
|
|
237
|
+
|
|
238
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
|
|
239
|
+
await Promise.all([
|
|
240
|
+
fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n"),
|
|
241
|
+
fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
|
|
242
|
+
])
|
|
243
|
+
await shutdown
|
|
244
|
+
} finally {
|
|
245
|
+
if (owner && owner.exitCode === null && owner.signalCode === null) owner.kill("SIGKILL")
|
|
246
|
+
await stopGuardian(statePath)
|
|
247
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test("replacement refuses to overwrite an unrelated live final control socket and preserves the owner", async () => {
|
|
252
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-fence-"))
|
|
253
|
+
const oldSocketPath = path.join(root, "old.sock")
|
|
254
|
+
const occupiedSocketPath = path.join(root, "occupied.sock")
|
|
255
|
+
const statePath = path.join(root, "state.json")
|
|
256
|
+
const configPath = path.join(root, "rollbridge.cjs")
|
|
257
|
+
const blockerSockets = new Set()
|
|
258
|
+
const blocker = net.createServer((socket) => {
|
|
259
|
+
blockerSockets.add(socket)
|
|
260
|
+
socket.once("close", () => blockerSockets.delete(socket))
|
|
261
|
+
socket.end(`${JSON.stringify({application: "unrelated", status: "success"})}\n`)
|
|
262
|
+
})
|
|
263
|
+
let owner
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
await fs.mkdir(path.join(root, "v1"))
|
|
267
|
+
await makeFifo(path.join(root, "v1", "worker.fifo"))
|
|
268
|
+
await writeConfig(configPath, config({controlPath: oldSocketPath, extraCompanion: false, statePath}))
|
|
269
|
+
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
270
|
+
await waitForLog(owner, "control socket listening")
|
|
271
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: path.join(root, "v1"), revision: "v1"}, path: oldSocketPath})
|
|
272
|
+
await new Promise((resolve, reject) => blocker.listen(occupiedSocketPath, () => resolve(undefined)).once("error", reject))
|
|
273
|
+
await writeConfig(configPath, config({controlPath: occupiedSocketPath, extraCompanion: true, statePath}))
|
|
274
|
+
|
|
275
|
+
const replacement = await run(process.execPath, [
|
|
276
|
+
binPath, "ensure-daemon", "--config", configPath,
|
|
277
|
+
"--daemon-runtime-path", path.join(root, "runtime"), "--daemon-log-path", path.join(root, "daemon.log"),
|
|
278
|
+
"--daemon-pid-path", path.join(root, "daemon.pid"), "--daemon-start-timeout-ms", "1500"
|
|
279
|
+
])
|
|
280
|
+
|
|
281
|
+
assert.notEqual(replacement.code, 0)
|
|
282
|
+
assert.match(await fs.readFile(path.join(root, "daemon.log"), "utf8"), /final control socket.*already answers another live process/)
|
|
283
|
+
assert.equal((await sendControlCommand({command: {command: "status"}, path: oldSocketPath})).activeReleaseId, "v1")
|
|
284
|
+
|
|
285
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: oldSocketPath})
|
|
286
|
+
await fs.writeFile(path.join(root, "v1", "worker.fifo"), "drained\n")
|
|
287
|
+
await shutdown
|
|
288
|
+
} finally {
|
|
289
|
+
if (owner && owner.exitCode === null && owner.signalCode === null) owner.kill("SIGKILL")
|
|
290
|
+
for (const socket of blockerSockets) socket.destroy()
|
|
291
|
+
if (blocker.listening) await new Promise((resolve) => blocker.close(() => resolve(undefined)))
|
|
292
|
+
await stopGuardian(statePath)
|
|
293
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test("a committed replacement crash converges from stale public state", async () => {
|
|
298
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-crash-"))
|
|
299
|
+
const oldSocketPath = path.join(root, "old.sock")
|
|
300
|
+
const newSocketPath = path.join(root, "new.sock")
|
|
301
|
+
const statePath = path.join(root, "state.json")
|
|
302
|
+
const configPath = path.join(root, "rollbridge.cjs")
|
|
303
|
+
const releasePath = path.join(root, "v1")
|
|
304
|
+
let owner
|
|
305
|
+
let candidate
|
|
306
|
+
let recovered
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
await fs.mkdir(releasePath)
|
|
310
|
+
await makeFifo(path.join(releasePath, "worker.fifo"))
|
|
311
|
+
await writeConfig(configPath, config({controlPath: oldSocketPath, extraCompanion: false, statePath}))
|
|
312
|
+
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
313
|
+
await waitForLog(owner, "control socket listening")
|
|
314
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: oldSocketPath})
|
|
315
|
+
const staleState = await fs.readFile(statePath, "utf8")
|
|
316
|
+
|
|
317
|
+
await writeConfig(configPath, config({controlPath: newSocketPath, extraCompanion: true, statePath}))
|
|
318
|
+
candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
319
|
+
await waitForLog(candidate, "owner replacement committed")
|
|
320
|
+
candidate.kill("SIGKILL")
|
|
321
|
+
await once(candidate, "exit")
|
|
322
|
+
await fs.writeFile(statePath, staleState)
|
|
323
|
+
|
|
324
|
+
recovered = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
325
|
+
await waitForLog(recovered, "owner replacement committed")
|
|
326
|
+
const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
|
|
327
|
+
|
|
328
|
+
assert.equal(status.activeReleaseId, "v1")
|
|
329
|
+
assert.deepEqual(status.releaseReferences, [{releaseId: "v1", releasePath}])
|
|
330
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
|
|
331
|
+
await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
|
|
332
|
+
await shutdown
|
|
333
|
+
} finally {
|
|
334
|
+
for (const child of [owner, candidate, recovered]) {
|
|
335
|
+
if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
|
|
336
|
+
}
|
|
337
|
+
await stopGuardian(statePath)
|
|
338
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test("replacement transfers an unchanged fixed proxy listener without reusePort", async () => {
|
|
343
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-fixed-proxy-"))
|
|
344
|
+
const oldSocketPath = path.join(root, "old.sock")
|
|
345
|
+
const newSocketPath = path.join(root, "new.sock")
|
|
346
|
+
const statePath = path.join(root, "state.json")
|
|
347
|
+
const configPath = path.join(root, "rollbridge.cjs")
|
|
348
|
+
const releasePath = path.join(root, "v1")
|
|
349
|
+
const proxyPort = await findAvailablePort({host: "127.0.0.1", range: {from: 24000, to: 24999}, usedPorts: new Set()})
|
|
350
|
+
let owner
|
|
351
|
+
let candidate
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
await fs.mkdir(releasePath)
|
|
355
|
+
await makeFifo(path.join(releasePath, "worker.fifo"))
|
|
356
|
+
await writeConfig(configPath, config({controlPath: oldSocketPath, extraCompanion: false, proxyPort, statePath}))
|
|
357
|
+
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
358
|
+
await waitForLog(owner, "control socket listening")
|
|
359
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: oldSocketPath})
|
|
360
|
+
await writeConfig(configPath, config({controlPath: newSocketPath, extraCompanion: true, proxyPort, statePath}))
|
|
361
|
+
|
|
362
|
+
candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
363
|
+
const output = await collectUntilExitOrLog(candidate, "owner replacement committed")
|
|
364
|
+
|
|
365
|
+
assert.equal(output.message, "owner replacement committed", output.output)
|
|
366
|
+
const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
|
|
367
|
+
const proxy = /** @type {{port: number}} */ (status.proxy)
|
|
368
|
+
|
|
369
|
+
assert.equal(proxy.port, proxyPort)
|
|
370
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
|
|
371
|
+
await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
|
|
372
|
+
await shutdown
|
|
373
|
+
} finally {
|
|
374
|
+
for (const child of [owner, candidate]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
|
|
375
|
+
await stopGuardian(statePath)
|
|
376
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
test("replacement publishes an unchanged control path only after incumbent retirement", async () => {
|
|
381
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-same-control-"))
|
|
382
|
+
const socketPath = path.join(root, "rollbridge.sock")
|
|
383
|
+
const statePath = path.join(root, "state.json")
|
|
384
|
+
const configPath = path.join(root, "rollbridge.cjs")
|
|
385
|
+
const releasePath = path.join(root, "v1")
|
|
386
|
+
let owner
|
|
387
|
+
let candidate
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
await fs.mkdir(releasePath)
|
|
391
|
+
await makeFifo(path.join(releasePath, "worker.fifo"))
|
|
392
|
+
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
|
|
393
|
+
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
394
|
+
await waitForLog(owner, "control socket listening")
|
|
395
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: socketPath})
|
|
396
|
+
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: true, statePath}))
|
|
397
|
+
|
|
398
|
+
candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
399
|
+
const output = await collectUntilExitOrLog(candidate, "owner replacement committed")
|
|
400
|
+
|
|
401
|
+
assert.equal(output.message, "owner replacement committed", output.output)
|
|
402
|
+
assert.equal((await sendControlCommand({command: {command: "status"}, path: socketPath})).activeReleaseId, "v1")
|
|
403
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: socketPath})
|
|
404
|
+
await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
|
|
405
|
+
await shutdown
|
|
406
|
+
} finally {
|
|
407
|
+
for (const child of [owner, candidate]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
|
|
408
|
+
await stopGuardian(statePath)
|
|
409
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
410
|
+
}
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test("prepared replacement fences incumbent mutations until abort", async () => {
|
|
414
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-cas-"))
|
|
415
|
+
const socketPath = path.join(root, "rollbridge.sock")
|
|
416
|
+
const statePath = path.join(root, "state.json")
|
|
417
|
+
const configPath = path.join(root, "rollbridge.cjs")
|
|
418
|
+
const v1Path = path.join(root, "v1")
|
|
419
|
+
const v2Path = path.join(root, "v2")
|
|
420
|
+
let owner
|
|
421
|
+
let transactionClient
|
|
422
|
+
let v2Started = false
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
|
|
426
|
+
await Promise.all([makeFifo(path.join(v1Path, "worker.fifo")), makeFifo(path.join(v2Path, "worker.fifo"))])
|
|
427
|
+
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
|
|
428
|
+
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
429
|
+
await waitForLog(owner, "control socket listening")
|
|
430
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: socketPath})
|
|
431
|
+
const state = JSON.parse(await fs.readFile(statePath, "utf8"))
|
|
432
|
+
const authority = {configDigest: state.recovery.configDigest, runtime: state.daemonRuntime}
|
|
433
|
+
|
|
434
|
+
transactionClient = new GuardianClient(state.recovery.guardian)
|
|
435
|
+
await transactionClient.connect()
|
|
436
|
+
const prepared = await transactionClient.prepareOwnerReplacement(authority, {...authority, configDigest: "candidate-authority"})
|
|
437
|
+
|
|
438
|
+
await assert.rejects(
|
|
439
|
+
sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: socketPath})
|
|
440
|
+
.then((response) => {
|
|
441
|
+
v2Started = true
|
|
442
|
+
return response
|
|
443
|
+
}),
|
|
444
|
+
/replacement.*prepared|mutation.*fenced/i
|
|
445
|
+
)
|
|
446
|
+
const retained = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
447
|
+
assert.equal(retained.activeReleaseId, "v1")
|
|
448
|
+
assert.deepEqual(retained.releaseReferences, [{releaseId: "v1", releasePath: v1Path}])
|
|
449
|
+
await transactionClient.abortOwnerReplacement(prepared.replacementId)
|
|
450
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: socketPath})
|
|
451
|
+
v2Started = true
|
|
452
|
+
const afterAbort = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
453
|
+
|
|
454
|
+
assert.equal(afterAbort.activeReleaseId, "v2")
|
|
455
|
+
assert.deepEqual(afterAbort.releaseReferences, [
|
|
456
|
+
{releaseId: "v1", releasePath: v1Path},
|
|
457
|
+
{releaseId: "v2", releasePath: v2Path}
|
|
458
|
+
])
|
|
459
|
+
} finally {
|
|
460
|
+
transactionClient?.disconnect()
|
|
461
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
462
|
+
if (owner && owner.exitCode === null && owner.signalCode === null) {
|
|
463
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: socketPath}).catch(() => undefined)
|
|
464
|
+
const drains = [fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n").catch(() => undefined)]
|
|
465
|
+
|
|
466
|
+
if (v2Started) drains.push(fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n").catch(() => undefined))
|
|
467
|
+
await Promise.all([...drains, shutdown])
|
|
468
|
+
if (owner.exitCode === null && owner.signalCode === null) owner.kill("SIGKILL")
|
|
469
|
+
}
|
|
470
|
+
await stopGuardian(statePath)
|
|
471
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
472
|
+
}
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* @param {{controlPath: string, extraCompanion: boolean, proxyPort?: number, statePath: string}} options - Fixture options.
|
|
477
|
+
* @returns {Record<string, import("../src/json.js").JsonValue>} Raw fixture config.
|
|
478
|
+
*/
|
|
479
|
+
function config({controlPath, extraCompanion, proxyPort = 0, statePath}) {
|
|
480
|
+
const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`
|
|
481
|
+
const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ ([
|
|
482
|
+
{
|
|
483
|
+
command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
|
|
484
|
+
id: "worker",
|
|
485
|
+
lifecycle: {drainCommand: "read released < \"$ROLLBRIDGE_RELEASE_PATH/worker.fifo\"", drainTimeoutMs: 60000},
|
|
486
|
+
nonBlockingDrain: true,
|
|
487
|
+
policy: "companion"
|
|
488
|
+
},
|
|
489
|
+
{command, health: {intervalMs: 25, path: "/ping", timeoutMs: 3000}, id: "web", policy: "proxied", port: {from: 0, to: 0}}
|
|
490
|
+
])
|
|
491
|
+
|
|
492
|
+
if (extraCompanion) processes.splice(1, 0, {command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`, id: "metrics", policy: "companion"})
|
|
493
|
+
return {
|
|
494
|
+
application: "owner-replacement-test",
|
|
495
|
+
control: {path: controlPath},
|
|
496
|
+
ownerRecovery: {reconnectGraceMs: 3000},
|
|
497
|
+
processes,
|
|
498
|
+
proxy: {drainTimeoutMs: 100, forceStopTimeoutMs: 100, host: "127.0.0.1", port: proxyPort},
|
|
499
|
+
statePath
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* @param {string} destination - Candidate package path.
|
|
505
|
+
* @param {{dropCommitResponse?: boolean}} [options] - Fault injection.
|
|
506
|
+
* @returns {Promise<void>} Package preparation completion.
|
|
507
|
+
*/
|
|
508
|
+
async function prepareCandidatePackage(destination, options = {}) {
|
|
509
|
+
const {dropCommitResponse = false} = options
|
|
510
|
+
|
|
511
|
+
await fs.mkdir(destination, {recursive: true})
|
|
512
|
+
await Promise.all([
|
|
513
|
+
fs.cp(path.join(repoRoot, "bin"), path.join(destination, "bin"), {recursive: true}),
|
|
514
|
+
fs.cp(path.join(repoRoot, "src"), path.join(destination, "src"), {recursive: true}),
|
|
515
|
+
fs.cp(path.join(repoRoot, "node_modules"), path.join(destination, "node_modules"), {dereference: true, recursive: true}),
|
|
516
|
+
fs.copyFile(path.join(repoRoot, "package.json"), path.join(destination, "package.json"))
|
|
517
|
+
])
|
|
518
|
+
await fs.appendFile(path.join(destination, "src", "cli.js"), "\n// distinct owner-replacement candidate closure\n")
|
|
519
|
+
if (dropCommitResponse) {
|
|
520
|
+
const clientPath = path.join(destination, "src", "control-client.js")
|
|
521
|
+
const source = await fs.readFile(clientPath, "utf8")
|
|
522
|
+
const marker = "export async function sendControlCommand({command, path}) {\n"
|
|
523
|
+
const injected = `${marker} if (command.command === "commit-owner-replacement") {\n return await new Promise((resolve, reject) => {\n const socket = net.createConnection(path)\n socket.once("error", reject)\n socket.once("data", () => { socket.destroy(); reject(new Error("injected lost commit response")) })\n socket.once("connect", () => socket.write(\`${"${JSON.stringify(command)}"}\\n\`))\n })\n }\n\n`
|
|
524
|
+
const sessionMarker = " this.socket.write(`${JSON.stringify(command)}\\n`)\n return await response\n"
|
|
525
|
+
const sessionInjection = " this.socket.write(`${JSON.stringify(command)}\\n`)\n const result = await response\n if (command.command === \"commit-owner-replacement\") throw new Error(\"injected lost commit response\")\n return result\n"
|
|
526
|
+
|
|
527
|
+
assert.ok(source.includes(marker))
|
|
528
|
+
assert.ok(source.includes(sessionMarker))
|
|
529
|
+
await fs.writeFile(clientPath, source.replace(marker, injected).replace(sessionMarker, sessionInjection))
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Replaces only the copied package's daemon entry with an exact early-exit fixture.
|
|
535
|
+
* @param {string} packagePath - Copied candidate package root.
|
|
536
|
+
* @param {string} evidencePath - Exact candidate identity record.
|
|
537
|
+
* @param {number} exitCode - Distinct candidate exit code.
|
|
538
|
+
* @returns {Promise<void>} Fixture installation completion.
|
|
539
|
+
*/
|
|
540
|
+
async function installCandidateExit(packagePath, evidencePath, exitCode) {
|
|
541
|
+
const binPath = path.join(packagePath, "bin", "rollbridge")
|
|
542
|
+
const source = `#!/usr/bin/env node
|
|
543
|
+
import fs from "node:fs"
|
|
544
|
+
|
|
545
|
+
if (process.argv[2] === "daemon") {
|
|
546
|
+
fs.writeFileSync(${JSON.stringify(evidencePath)}, JSON.stringify({argv: process.argv, pid: process.pid, ppid: process.ppid}))
|
|
547
|
+
process.exit(${exitCode})
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const {runCli} = await import("../src/cli.js")
|
|
551
|
+
await runCli(process.argv)
|
|
552
|
+
`
|
|
553
|
+
|
|
554
|
+
await fs.writeFile(binPath, source, {mode: 0o755})
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* @param {{configPath: string, daemonPidPath: string, logPath: string, packagePath: string, runtimePath: string}} options - Ensure fixture paths.
|
|
559
|
+
* @returns {Promise<{code: number, stderr: string, stdout: string}>} Ensure result.
|
|
560
|
+
*/
|
|
561
|
+
async function runEnsureDaemon({configPath, daemonPidPath, logPath, packagePath, runtimePath}) {
|
|
562
|
+
return await run(process.execPath, [
|
|
563
|
+
path.join(packagePath, "bin", "rollbridge"), "ensure-daemon", "--config", configPath,
|
|
564
|
+
"--daemon-runtime-path", runtimePath, "--daemon-log-path", logPath,
|
|
565
|
+
"--daemon-pid-path", daemonPidPath, "--daemon-start-timeout-ms", "3000"
|
|
566
|
+
])
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* @param {net.Server} server - Unix server.
|
|
571
|
+
* @param {string} socketPath - Unix socket path.
|
|
572
|
+
* @returns {Promise<void>} Listen completion.
|
|
573
|
+
*/
|
|
574
|
+
async function listenUnix(server, socketPath) {
|
|
575
|
+
await new Promise((resolve, reject) => {
|
|
576
|
+
server.once("error", reject)
|
|
577
|
+
server.listen(socketPath, () => resolve(undefined))
|
|
578
|
+
})
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* @param {net.Server} server - Server to close.
|
|
583
|
+
* @returns {Promise<void>} Close completion.
|
|
584
|
+
*/
|
|
585
|
+
async function closeServer(server) {
|
|
586
|
+
await new Promise((resolve) => server.close(() => resolve(undefined)))
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* @param {string} fifoPath - FIFO path.
|
|
591
|
+
* @returns {Promise<void>} FIFO creation completion.
|
|
592
|
+
*/
|
|
593
|
+
async function makeFifo(fifoPath) {
|
|
594
|
+
const child = spawn("mkfifo", [fifoPath])
|
|
595
|
+
assert.equal((await once(child, "exit"))[0], 0)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* @param {Record<string, import("../src/json.js").JsonValue>} status - Status.
|
|
600
|
+
* @param {string} releaseId - Release id.
|
|
601
|
+
* @param {string} processId - Process id.
|
|
602
|
+
* @returns {number} Managed process pid.
|
|
603
|
+
*/
|
|
604
|
+
function releaseProcessPid(status, releaseId, processId) {
|
|
605
|
+
const releases = /** @type {{releaseId: string, processes: {id: string, pid?: number}[]}[]} */ (status.releases)
|
|
606
|
+
const pid = releases.find((release) => release.releaseId === releaseId)?.processes.find((processStatus) => processStatus.id === processId)?.pid
|
|
607
|
+
|
|
608
|
+
if (typeof pid !== "number") throw new Error(`Missing ${processId} pid for release ${releaseId}`)
|
|
609
|
+
return pid
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* @param {string} command - Executable.
|
|
614
|
+
* @param {string[]} args - Arguments.
|
|
615
|
+
* @returns {Promise<{code: number, pid: number, stderr: string, stdout: string}>} Child result.
|
|
616
|
+
*/
|
|
617
|
+
async function run(command, args) {
|
|
618
|
+
const child = spawn(command, args, {stdio: ["ignore", "pipe", "pipe"]})
|
|
619
|
+
let stdout = ""
|
|
620
|
+
let stderr = ""
|
|
621
|
+
|
|
622
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk })
|
|
623
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk })
|
|
624
|
+
const [code] = await once(child, "exit")
|
|
625
|
+
assert.ok(child.pid)
|
|
626
|
+
return {code, pid: child.pid, stderr, stdout}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* @param {import("node:child_process").ChildProcess} child - Daemon.
|
|
631
|
+
* @param {string} message - Log message.
|
|
632
|
+
* @returns {Promise<void>} Resolves after the matching log event.
|
|
633
|
+
*/
|
|
634
|
+
async function waitForLog(child, message) {
|
|
635
|
+
assert.ok(child.stdout)
|
|
636
|
+
assert.ok(child.stderr)
|
|
637
|
+
child.stdout.setEncoding("utf8")
|
|
638
|
+
child.stderr.setEncoding("utf8")
|
|
639
|
+
await new Promise((resolve, reject) => {
|
|
640
|
+
let buffer = ""
|
|
641
|
+
let errors = ""
|
|
642
|
+
const onExit = () => finish(new Error(`daemon exited before ${message}: ${errors}`))
|
|
643
|
+
const onData = (/** @type {string} */ chunk) => {
|
|
644
|
+
buffer += chunk
|
|
645
|
+
for (const line of buffer.split("\n")) {
|
|
646
|
+
if (line && JSON.parse(line).message === message) return finish(undefined)
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const finish = (/** @type {Error | undefined} */ error) => {
|
|
650
|
+
child.off("exit", onExit)
|
|
651
|
+
child.stdout?.off("data", onData)
|
|
652
|
+
child.stderr?.off("data", onError)
|
|
653
|
+
if (error) reject(error)
|
|
654
|
+
else resolve(undefined)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
child.once("exit", onExit)
|
|
658
|
+
child.stdout?.on("data", onData)
|
|
659
|
+
const onError = (/** @type {string} */ chunk) => { errors += chunk }
|
|
660
|
+
|
|
661
|
+
child.stderr?.on("data", onError)
|
|
662
|
+
})
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* @param {import("node:child_process").ChildProcess} child - Daemon.
|
|
667
|
+
* @param {string} message - Expected log message.
|
|
668
|
+
* @returns {Promise<{message?: string, output: string}>} Exit output or matched message.
|
|
669
|
+
*/
|
|
670
|
+
async function collectUntilExitOrLog(child, message) {
|
|
671
|
+
assert.ok(child.stdout)
|
|
672
|
+
assert.ok(child.stderr)
|
|
673
|
+
const stdout = child.stdout
|
|
674
|
+
const stderr = child.stderr
|
|
675
|
+
|
|
676
|
+
stdout.setEncoding("utf8")
|
|
677
|
+
stderr.setEncoding("utf8")
|
|
678
|
+
let output = ""
|
|
679
|
+
|
|
680
|
+
return await new Promise((resolve) => {
|
|
681
|
+
const onData = (/** @type {string} */ chunk) => {
|
|
682
|
+
output += chunk
|
|
683
|
+
for (const line of output.split("\n")) {
|
|
684
|
+
if (!line) continue
|
|
685
|
+
try {
|
|
686
|
+
if (JSON.parse(line).message === message) return finish(message)
|
|
687
|
+
} catch (_error) {
|
|
688
|
+
// Non-JSON stderr remains part of the assertion diagnostic.
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const finish = (/** @type {string | undefined} */ matched) => {
|
|
693
|
+
child.off("exit", onExit)
|
|
694
|
+
stdout.off("data", onData)
|
|
695
|
+
stderr.off("data", onData)
|
|
696
|
+
resolve({message: matched, output})
|
|
697
|
+
}
|
|
698
|
+
const onExit = () => finish(undefined)
|
|
699
|
+
|
|
700
|
+
child.once("exit", onExit)
|
|
701
|
+
stdout.on("data", onData)
|
|
702
|
+
stderr.on("data", onData)
|
|
703
|
+
})
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Opens a live WebSocket through the Rollbridge proxy without a client dependency.
|
|
708
|
+
* @param {number} port - Proxy port.
|
|
709
|
+
* @returns {Promise<net.Socket>} Upgraded socket.
|
|
710
|
+
*/
|
|
711
|
+
async function openWebSocket(port) {
|
|
712
|
+
const socket = net.createConnection({host: "127.0.0.1", port})
|
|
713
|
+
|
|
714
|
+
await once(socket, "connect")
|
|
715
|
+
socket.write([
|
|
716
|
+
"GET /socket HTTP/1.1",
|
|
717
|
+
"Host: 127.0.0.1",
|
|
718
|
+
"Connection: Upgrade",
|
|
719
|
+
"Upgrade: websocket",
|
|
720
|
+
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
|
|
721
|
+
"Sec-WebSocket-Version: 13",
|
|
722
|
+
"\r\n"
|
|
723
|
+
].join("\r\n"))
|
|
724
|
+
const [response] = await once(socket, "data")
|
|
725
|
+
|
|
726
|
+
assert.match(String(response), /^HTTP\/1\.1 101 /)
|
|
727
|
+
return socket
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* @param {string} configPath - Config path.
|
|
732
|
+
* @param {Record<string, import("../src/json.js").JsonValue>} value - Config.
|
|
733
|
+
* @returns {Promise<void>} Config write completion.
|
|
734
|
+
*/
|
|
735
|
+
async function writeConfig(configPath, value) {
|
|
736
|
+
await fs.writeFile(configPath, `module.exports = ${JSON.stringify(value, null, 2)}\n`)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** @param {string} statePath - State path. */
|
|
740
|
+
async function stopGuardian(statePath) {
|
|
741
|
+
try {
|
|
742
|
+
const state = JSON.parse(await fs.readFile(statePath, "utf8"))
|
|
743
|
+
const identity = state.recovery?.guardian
|
|
744
|
+
const pid = identity?.pid
|
|
745
|
+
|
|
746
|
+
if (identity?.socketPath && identity.token) {
|
|
747
|
+
const client = new GuardianClient(identity)
|
|
748
|
+
|
|
749
|
+
try {
|
|
750
|
+
await client.connect()
|
|
751
|
+
for (const entry of await client.inventory()) {
|
|
752
|
+
if (entry.status.pid) {
|
|
753
|
+
try { process.kill(-entry.status.pid, "SIGKILL") } catch (error) {
|
|
754
|
+
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ESRCH") throw error
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
} finally {
|
|
759
|
+
client.disconnect()
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (typeof pid === "number") {
|
|
764
|
+
const command = (await fs.readFile(`/proc/${pid}/cmdline`, "utf8")).replaceAll("\0", " ")
|
|
765
|
+
|
|
766
|
+
if (!command.includes("process-guardian.js") || !command.includes(statePath)) throw new Error(`Refusing to stop unverified fixture guardian pid ${pid}`)
|
|
767
|
+
process.kill(pid, "SIGKILL")
|
|
768
|
+
}
|
|
769
|
+
} catch (error) {
|
|
770
|
+
if (!error || typeof error !== "object" || !("code" in error) || !["ENOENT", "ESRCH"].includes(String(error.code))) throw error
|
|
771
|
+
}
|
|
772
|
+
}
|