rollbridge 0.1.14 → 0.1.16

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.
@@ -0,0 +1,357 @@
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
+
14
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
15
+ const dummyAppPath = path.join(repoRoot, "test", "fixtures", "dummy-app.js")
16
+
17
+ test("detached daemon survives deletion of the release-local Rollbridge installation", async () => {
18
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-retention-"))
19
+ const releaseA = path.join(root, "releases", "A")
20
+ const releaseB = path.join(root, "releases", "B")
21
+ const socketPath = path.join(root, "control.sock")
22
+ const configPath = path.join(root, "rollbridge.js")
23
+ const logPath = path.join(root, "daemon.log")
24
+ const pidPath = path.join(root, "daemon.pid")
25
+ const runtimePath = path.join(root, "runtime")
26
+
27
+ try {
28
+ await Promise.all([prepareRelease(releaseA, true), prepareRelease(releaseB, true)])
29
+ await fs.writeFile(configPath, `export default ${JSON.stringify({
30
+ application: "runtime-retention-test",
31
+ control: {path: socketPath},
32
+ processes: [{
33
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
34
+ health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
35
+ id: "web",
36
+ policy: "proxied",
37
+ port: {from: 0, to: 0}
38
+ }],
39
+ proxy: {drainTimeoutMs: 100, forceStopTimeoutMs: 100, host: "127.0.0.1", port: 0}
40
+ }, null, 2)}\n`)
41
+
42
+ await runReleaseCli(releaseA, [
43
+ "deploy", "--ensure-daemon", "--config", configPath,
44
+ "--release-path", releaseA, "--release-id", "A",
45
+ "--daemon-log-path", logPath, "--daemon-pid-path", pidPath,
46
+ "--daemon-runtime-path", runtimePath
47
+ ])
48
+ await runReleaseCli(releaseB, [
49
+ "deploy", "--ensure-daemon", "--config", configPath,
50
+ "--release-path", releaseB, "--release-id", "B",
51
+ "--daemon-log-path", logPath, "--daemon-pid-path", pidPath,
52
+ "--daemon-runtime-path", runtimePath
53
+ ])
54
+
55
+ await fs.rm(path.join(releaseA, "node_modules"), {recursive: true})
56
+
57
+ const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
58
+ const proxyPort = /** @type {{port: number}} */ (status.proxy).port
59
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/deferred-runtime`)
60
+ const runtime = /** @type {{digest: string, format: number, path: string, version: string}} */ (status.daemonRuntime)
61
+
62
+ assert.equal(status.activeReleaseId, "B")
63
+ assert.equal(runtime.format, 1)
64
+ assert.match(runtime.digest, /^[a-f0-9]{64}$/)
65
+ assert.equal(path.dirname(runtime.path), runtimePath)
66
+ assert.ok(!runtime.path.startsWith(releaseA), `runtime must be outside release A: ${runtime.path}`)
67
+ assert.equal(response.status, 200)
68
+ assert.equal(await response.text(), "deferred runtime loaded\n")
69
+ } finally {
70
+ try {
71
+ await sendControlCommand({command: {command: "shutdown"}, path: socketPath})
72
+ } catch {
73
+ const pid = Number.parseInt(await fs.readFile(pidPath, "utf8").catch(() => ""), 10)
74
+ if (Number.isInteger(pid)) process.kill(pid, "SIGKILL")
75
+ }
76
+
77
+ await fs.rm(root, {force: true, recursive: true})
78
+ }
79
+ })
80
+
81
+ test("runtime preparation failure prevents daemon startup and deploy handoff", async () => {
82
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-failure-"))
83
+ const release = path.join(root, "release")
84
+ const socketPath = path.join(root, "control.sock")
85
+ const configPath = path.join(root, "rollbridge.js")
86
+ const invalidRuntimePath = path.join(root, "not-a-directory")
87
+
88
+ try {
89
+ await prepareRelease(release, false)
90
+ await fs.writeFile(invalidRuntimePath, "occupied\n")
91
+ await fs.writeFile(configPath, `export default ${JSON.stringify(basicConfig(socketPath), null, 2)}\n`)
92
+
93
+ await assert.rejects(
94
+ () => runReleaseCli(release, [
95
+ "deploy", "--ensure-daemon", "--config", configPath,
96
+ "--release-path", release, "--release-id", "blocked",
97
+ "--daemon-runtime-path", invalidRuntimePath
98
+ ]),
99
+ /EEXIST|not a directory|ENOTDIR/
100
+ )
101
+ await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
102
+ } finally {
103
+ await fs.rm(root, {force: true, recursive: true})
104
+ }
105
+ })
106
+
107
+ test("ensure-daemon refuses a responsive legacy daemon before deploy", async () => {
108
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-legacy-"))
109
+ const release = path.join(root, "release")
110
+ const socketPath = path.join(root, "control.sock")
111
+ const configPath = path.join(root, "rollbridge.js")
112
+ let deployReceived = false
113
+ const legacyDaemon = net.createServer((socket) => {
114
+ socket.setEncoding("utf8")
115
+ socket.on("data", (contents) => {
116
+ const command = JSON.parse(String(contents).trim())
117
+
118
+ if (command.command === "deploy") deployReceived = true
119
+ socket.end(`${JSON.stringify({activeReleaseId: null, application: "runtime-retention-test", releases: []})}\n`)
120
+ })
121
+ })
122
+
123
+ try {
124
+ await prepareRelease(release, false)
125
+ await fs.writeFile(configPath, `export default ${JSON.stringify(basicConfig(socketPath), null, 2)}\n`)
126
+ await new Promise((resolve, reject) => {
127
+ legacyDaemon.once("error", reject)
128
+ legacyDaemon.listen(socketPath, () => resolve(undefined))
129
+ })
130
+
131
+ await assert.rejects(
132
+ () => runReleaseCli(release, [
133
+ "deploy", "--ensure-daemon", "--config", configPath,
134
+ "--release-path", release, "--release-id", "must-not-deploy",
135
+ "--daemon-runtime-path", path.join(root, "runtime")
136
+ ]),
137
+ /legacy or mismatched runtime.*deploy was not sent/s
138
+ )
139
+
140
+ assert.equal(deployReceived, false)
141
+ } finally {
142
+ await new Promise((resolve) => legacyDaemon.close(resolve))
143
+ await fs.rm(root, {force: true, recursive: true})
144
+ }
145
+ })
146
+
147
+ test("ensure-daemon refuses a mismatched runtime attestation before deploy", async () => {
148
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-mismatch-"))
149
+ const release = path.join(root, "release")
150
+ const socketPath = path.join(root, "control.sock")
151
+ const configPath = path.join(root, "rollbridge.js")
152
+ let deployReceived = false
153
+ const daemon = net.createServer((socket) => {
154
+ socket.setEncoding("utf8")
155
+ socket.on("data", (contents) => {
156
+ const command = JSON.parse(String(contents).trim())
157
+
158
+ if (command.command === "deploy") deployReceived = true
159
+ socket.end(`${JSON.stringify({
160
+ activeReleaseId: "current",
161
+ application: "runtime-retention-test",
162
+ daemonRuntime: {digest: "0".repeat(64), format: 1, path: "/durable/runtime", version: "0.1.14"},
163
+ releases: []
164
+ })}\n`)
165
+ })
166
+ })
167
+
168
+ try {
169
+ await prepareRelease(release, false)
170
+ await fs.writeFile(configPath, `export default ${JSON.stringify(basicConfig(socketPath), null, 2)}\n`)
171
+ await new Promise((resolve, reject) => {
172
+ daemon.once("error", reject)
173
+ daemon.listen(socketPath, () => resolve(undefined))
174
+ })
175
+
176
+ await assert.rejects(
177
+ () => runReleaseCli(release, [
178
+ "deploy", "--ensure-daemon", "--config", configPath,
179
+ "--release-path", release, "--release-id", "must-not-deploy",
180
+ "--daemon-runtime-path", path.join(root, "runtime")
181
+ ]),
182
+ /legacy or mismatched runtime.*deploy was not sent/s
183
+ )
184
+ assert.equal(deployReceived, false)
185
+ } finally {
186
+ await new Promise((resolve) => daemon.close(resolve))
187
+ await fs.rm(root, {force: true, recursive: true})
188
+ }
189
+ })
190
+
191
+ test("concurrent startup loser re-attests the winner before sending deploy", async () => {
192
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-start-race-"))
193
+ const winnerRelease = path.join(root, "releases", "winner")
194
+ const loserRelease = path.join(root, "releases", "loser")
195
+ const socketPath = path.join(root, "control.sock")
196
+ const configPath = path.join(root, "rollbridge.js")
197
+ const loserPausedPath = path.join(root, "loser-paused")
198
+ const winnerPidPath = path.join(root, "winner.pid")
199
+ const loserPidPath = path.join(root, "loser.pid")
200
+
201
+ try {
202
+ await Promise.all([prepareRelease(winnerRelease, false), prepareRelease(loserRelease, false)])
203
+ await installStartupPause(loserRelease, loserPausedPath)
204
+ await fs.writeFile(configPath, `export default ${JSON.stringify({
205
+ application: "runtime-start-race-test",
206
+ control: {path: socketPath},
207
+ processes: [{
208
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
209
+ health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
210
+ id: "web",
211
+ policy: "proxied",
212
+ port: {from: 0, to: 0}
213
+ }],
214
+ proxy: {drainTimeoutMs: 100, forceStopTimeoutMs: 100, host: "127.0.0.1", port: 0}
215
+ }, null, 2)}\n`)
216
+
217
+ const loserDeploy = runReleaseCli(loserRelease, [
218
+ "deploy", "--ensure-daemon", "--config", configPath,
219
+ "--release-path", loserRelease, "--release-id", "loser",
220
+ "--daemon-log-path", path.join(root, "loser.log"), "--daemon-pid-path", loserPidPath,
221
+ "--daemon-runtime-path", path.join(root, "loser-runtime")
222
+ ])
223
+
224
+ await waitForFile(loserPausedPath)
225
+ await runReleaseCli(winnerRelease, [
226
+ "deploy", "--ensure-daemon", "--config", configPath,
227
+ "--release-path", winnerRelease, "--release-id", "winner",
228
+ "--daemon-log-path", path.join(root, "winner.log"), "--daemon-pid-path", winnerPidPath,
229
+ "--daemon-runtime-path", path.join(root, "winner-runtime")
230
+ ])
231
+
232
+ await assert.rejects(loserDeploy, /legacy or mismatched runtime.*deploy was not sent/s)
233
+ const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
234
+
235
+ assert.equal(status.activeReleaseId, "winner")
236
+ } finally {
237
+ try {
238
+ await sendControlCommand({command: {command: "shutdown"}, path: socketPath})
239
+ } catch {
240
+ // The winning daemon may have failed before accepting commands.
241
+ }
242
+
243
+ for (const pidPath of [winnerPidPath, loserPidPath]) {
244
+ const pid = Number.parseInt(await fs.readFile(pidPath, "utf8").catch(() => ""), 10)
245
+
246
+ if (Number.isInteger(pid)) {
247
+ try { process.kill(pid, "SIGKILL") } catch { /* The process already exited. */ }
248
+ }
249
+ }
250
+
251
+ await fs.rm(root, {force: true, recursive: true})
252
+ }
253
+ })
254
+
255
+ /**
256
+ * Creates a release-local Rollbridge package with production dependencies and,
257
+ * for release A, a daemon module that performs one deliberately deferred import.
258
+ * @param {string} releasePath - Release directory.
259
+ * @param {boolean} deferredImport - Whether to add the deferred daemon route.
260
+ * @returns {Promise<void>} Resolves when prepared.
261
+ */
262
+ async function prepareRelease(releasePath, deferredImport) {
263
+ const packagePath = path.join(releasePath, "node_modules", "rollbridge")
264
+
265
+ await fs.mkdir(path.dirname(packagePath), {recursive: true})
266
+ await fs.cp(repoRoot, packagePath, {
267
+ filter: (source) => {
268
+ const relative = path.relative(repoRoot, source)
269
+
270
+ return relative !== ".git" && relative !== "node_modules" && relative !== "test" && relative !== "tmp"
271
+ },
272
+ recursive: true
273
+ })
274
+
275
+ for (const dependency of ["commander", "eventemitter3", "follow-redirects", "http-proxy", "requires-port"]) {
276
+ await fs.symlink(path.join(repoRoot, "node_modules", dependency), path.join(releasePath, "node_modules", dependency), "dir")
277
+ }
278
+
279
+ if (!deferredImport) return
280
+
281
+ const daemonPath = path.join(packagePath, "src", "daemon.js")
282
+ const source = await fs.readFile(daemonPath, "utf8")
283
+ const marker = " proxyHttp(request, response) {\n"
284
+ const deferredRoute = `${marker} if (request.url === "/deferred-runtime") {\n void import("./deferred-runtime.js")\n .then(({default: body}) => { response.writeHead(200); response.end(body) })\n .catch((error) => { response.writeHead(500); response.end(String(error)) })\n return\n }\n\n`
285
+
286
+ assert.ok(source.includes(marker))
287
+ await fs.writeFile(daemonPath, source.replace(marker, deferredRoute))
288
+ await fs.writeFile(path.join(packagePath, "src", "deferred-runtime.js"), "export default \"deferred runtime loaded\\n\"\n")
289
+ }
290
+
291
+ /**
292
+ * Pauses a release after its initial missing-daemon probe so another runtime can win startup.
293
+ * @param {string} releasePath - Release directory.
294
+ * @param {string} pausedPath - Synchronization file.
295
+ * @returns {Promise<void>} Resolves after installing the test hook.
296
+ */
297
+ async function installStartupPause(releasePath, pausedPath) {
298
+ const cliPath = path.join(releasePath, "node_modules", "rollbridge", "src", "cli.js")
299
+ const source = await fs.readFile(cliPath, "utf8")
300
+ const marker = " await startDaemonProcess({\n"
301
+ const pause = ` await fsPromises.writeFile(${JSON.stringify(pausedPath)}, "paused\\n")\n await new Promise((resolve) => setTimeout(resolve, 750))\n\n${marker}`
302
+
303
+ assert.ok(source.includes(marker))
304
+ await fs.writeFile(cliPath, source.replace(marker, pause))
305
+ }
306
+
307
+ /**
308
+ * @param {string} filePath - File to await.
309
+ * @returns {Promise<void>} Resolves when the file exists.
310
+ */
311
+ async function waitForFile(filePath) {
312
+ const deadline = Date.now() + 5000
313
+
314
+ while (Date.now() < deadline) {
315
+ try {
316
+ await fs.stat(filePath)
317
+ return
318
+ } catch (error) {
319
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
320
+ }
321
+
322
+ await new Promise((resolve) => setTimeout(resolve, 20))
323
+ }
324
+
325
+ throw new Error(`Timed out waiting for ${filePath}`)
326
+ }
327
+
328
+ /**
329
+ * @param {string} socketPath - Control socket path.
330
+ * @returns {Record<string, import("../src/json.js").JsonValue>} Minimal config.
331
+ */
332
+ function basicConfig(socketPath) {
333
+ return {
334
+ application: "runtime-retention-test",
335
+ control: {path: socketPath},
336
+ processes: [{command: "true", id: "web", policy: "proxied", port: {from: 0, to: 0}}],
337
+ proxy: {host: "127.0.0.1", port: 0}
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Runs a release-local Rollbridge CLI to completion.
343
+ * @param {string} releasePath - Release directory.
344
+ * @param {string[]} args - CLI arguments.
345
+ * @returns {Promise<void>} Resolves on success.
346
+ */
347
+ async function runReleaseCli(releasePath, args) {
348
+ const binPath = path.join(releasePath, "node_modules", "rollbridge", "bin", "rollbridge")
349
+ const child = spawn(process.execPath, [binPath, ...args], {stdio: ["ignore", "pipe", "pipe"]})
350
+ let output = ""
351
+
352
+ child.stdout.setEncoding("utf8").on("data", (chunk) => { output += chunk })
353
+ child.stderr.setEncoding("utf8").on("data", (chunk) => { output += chunk })
354
+ const [code] = await once(child, "exit")
355
+
356
+ if (code !== 0) throw new Error(output)
357
+ }
@@ -1117,7 +1117,9 @@ test("deploy can ensure the daemon before sending the release command", async ()
1117
1117
  "--daemon-log-path",
1118
1118
  logPath,
1119
1119
  "--daemon-pid-path",
1120
- pidPath
1120
+ pidPath,
1121
+ "--daemon-runtime-path",
1122
+ path.join(fixture.root, "daemon-runtime")
1121
1123
  ])
1122
1124
 
1123
1125
  const status = await sendControlCommand({