rollbridge 0.1.15 → 0.1.17

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/README.md CHANGED
@@ -162,10 +162,13 @@ owns cleaning up on-disk release directories.
162
162
  releaseRetention: {keep: 5, maxAgeMs: 86400000}
163
163
  ```
164
164
 
165
- Set `statePath` to have the daemon persist its state to a file (active/draining
166
- releases, process pids, counters, recent events). On the next startup it reads
167
- any leftover file and reports managed processes still alive from a daemon that
168
- didn't shut down cleanly advisory orphan detection. After a crash, run
165
+ Set `statePath` to have the daemon persist secret-safe recovery state to a file
166
+ (active/draining releases, process pids, counters, sanitized recent events).
167
+ Commands, environment mappings, child command lines, and captured process output
168
+ are deliberately excluded; those diagnostics remain available through the live
169
+ status/log/event APIs. On the next startup Rollbridge reads any leftover file and
170
+ reports managed processes still alive from a daemon that didn't shut down cleanly
171
+ — advisory orphan detection. After a crash, run
169
172
  `rollbridge recover` to list those leftovers and `rollbridge recover --force` to
170
173
  stop them before restarting the daemon. A clean `shutdown` removes the file. See
171
174
  [`docs/config.md`](docs/config.md#statepath).
package/docs/config.md CHANGED
@@ -90,10 +90,13 @@ release records; the deploy tool still owns on-disk release directories.
90
90
 
91
91
  ## `statePath`
92
92
 
93
- When set, the daemon persists a state snapshot — the active and draining
94
- releases, each managed process's metadata (including pid), restart counters, and
95
- recent events — to this file (atomically, on changes and every few seconds). On a
96
- clean `shutdown` the file is removed.
93
+ When set, the daemon persists a secret-safe state snapshot — the active and
94
+ draining releases, each managed process's recovery metadata (including pid),
95
+ restart counters, and recent structured events — to this file (atomically, on
96
+ changes and every few seconds). Process commands, working directories,
97
+ environment mappings, child command lines, and retained stdout/stderr are never
98
+ persisted. They remain available from the live `status`, `logs`, and `events`
99
+ APIs while the daemon is running. On a clean `shutdown` the file is removed.
97
100
 
98
101
  On the **next startup**, the daemon reads any leftover file and reports managed
99
102
  processes whose pids are still alive — likely orphans from a daemon that crashed
package/docs/logging.md CHANGED
@@ -36,6 +36,12 @@ events. It is distinct from the two in-memory views:
36
36
  - `rollbridge events` — the recent structured daemon event history (the most
37
37
  recent 1000 events), the same events written to the log file.
38
38
 
39
+ When `statePath` is configured, its recovery snapshot is intentionally not a
40
+ log archive: process commands, environment mappings, child command lines, and
41
+ captured stdout/stderr are excluded. Use the live APIs above or the configured
42
+ daemon log for those diagnostics, and protect that log according to the
43
+ sensitivity of application output.
44
+
39
45
  Both are cleared when the daemon restarts; the log file persists.
40
46
 
41
47
  ## Rotation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
package/src/daemon.js CHANGED
@@ -721,8 +721,9 @@ export default class RollbridgeDaemon {
721
721
  if (!this.statePath || this.stopping) return
722
722
 
723
723
  const statePath = this.statePath
724
- const status = this.status()
725
- const snapshot = {...status, events: this.eventLog.recent(), persistedAt: new Date().toISOString()}
724
+ const status = /** @type {Record<string, JsonValue>} */ (secretSafeStateValue(this.status()))
725
+ const events = secretSafeStateValue(this.eventLog.recent())
726
+ const snapshot = {...status, events, persistedAt: new Date().toISOString()}
726
727
 
727
728
  // Serialize writes (and track the tail) so shutdown can wait for an in-flight write before
728
729
  // clearing the file — otherwise a write started before shutdown could recreate it afterward.
@@ -851,6 +852,30 @@ function stringOrUndefined(value) {
851
852
  return value
852
853
  }
853
854
 
855
+ const SECRET_BEARING_STATE_KEYS = new Set(["children", "command", "cwd", "env", "environment", "logs", "output"])
856
+
857
+ /**
858
+ * Removes process definitions and captured output from a value before it reaches statePath.
859
+ * The live status/events APIs retain those diagnostics in memory; persistent state is only a
860
+ * secret-safe recovery aid and must not become a second process log or configuration store.
861
+ * @param {JsonValue} value - JSON value to sanitize.
862
+ * @returns {JsonValue} A secret-safe copy.
863
+ */
864
+ function secretSafeStateValue(value) {
865
+ if (Array.isArray(value)) return value.map((entry) => secretSafeStateValue(entry))
866
+ if (!value || typeof value !== "object") return value
867
+
868
+ /** @type {Record<string, JsonValue>} */
869
+ const safe = {}
870
+
871
+ for (const [key, entry] of Object.entries(value)) {
872
+ if (SECRET_BEARING_STATE_KEYS.has(key)) continue
873
+ safe[key] = secretSafeStateValue(entry)
874
+ }
875
+
876
+ return safe
877
+ }
878
+
854
879
  /**
855
880
  * @param {JsonValue} value - Value.
856
881
  * @param {string} key - Key.
@@ -2,7 +2,7 @@
2
2
 
3
3
  import {EventEmitter} from "node:events"
4
4
  import {spawn} from "node:child_process"
5
- import {processGroupMembers} from "./process-memory.js"
5
+ import {processGroupHasLiveMembers, processGroupMembers} from "./process-memory.js"
6
6
 
7
7
  /**
8
8
  * @typedef {import("./json.js").JsonValue} JsonValue
@@ -353,6 +353,9 @@ export default class ManagedProcess extends EventEmitter {
353
353
  return
354
354
  }
355
355
 
356
+ const pgid = child.pid
357
+ const exitPromise = this.exitPromise
358
+
356
359
  this.state = "stopping"
357
360
 
358
361
  const {drainCommand, drainTimeoutMs, quietCommand, stopCommand} = this.lifecycle
@@ -370,19 +373,21 @@ export default class ManagedProcess extends EventEmitter {
370
373
  }
371
374
 
372
375
  // 3. Stop whatever is still running, then SIGKILL if it outlasts the graceful window.
373
- if (this.child) {
374
- if (stopCommand) await this.runHook(stopCommand, hookTimeoutMs, "stop command")
375
- else this.killProcessGroup(this.stopSignal)
376
+ if (this.processGroupExists(pgid)) {
377
+ if (stopCommand) await this.runHook(stopCommand, hookTimeoutMs, "stop command", pgid)
378
+ else this.killProcessGroup(this.stopSignal, pgid)
376
379
 
377
380
  const timeoutMs = options.timeoutMs ?? this.stopTimeoutMs
378
381
 
379
- if (this.child && !(await this.waitForExit(timeoutMs))) {
380
- this.logger("process stop timed out; sending SIGKILL", {id: this.id, pid: this.pid})
381
- this.killProcessGroup("SIGKILL")
382
- await this.waitForExit(5000)
382
+ if (!(await this.waitForProcessGroupExit(pgid, timeoutMs))) {
383
+ this.logger("process stop timed out; sending SIGKILL", {id: this.id, pid: pgid})
384
+ this.killProcessGroup("SIGKILL", pgid)
385
+ await this.waitForProcessGroupExit(pgid, 5000)
383
386
  }
384
387
  }
385
388
 
389
+ if (exitPromise) await exitPromise
390
+
386
391
  this.state = "stopped"
387
392
  }
388
393
 
@@ -400,9 +405,10 @@ export default class ManagedProcess extends EventEmitter {
400
405
  * @param {string} command - Shell command to run.
401
406
  * @param {number} timeoutMs - Maximum time to wait for the hook before killing it.
402
407
  * @param {string} label - Hook name, for log messages.
408
+ * @param {number | undefined} [pid] - Process-group leader exposed to the hook.
403
409
  * @returns {Promise<void>} Resolves when the hook exits, errors, or times out.
404
410
  */
405
- async runHook(command, timeoutMs, label) {
411
+ async runHook(command, timeoutMs, label, pid = this.pid) {
406
412
  await new Promise((resolve) => {
407
413
  let settled = false
408
414
  const finish = () => { if (!settled) { settled = true; resolve(undefined) } }
@@ -414,7 +420,7 @@ export default class ManagedProcess extends EventEmitter {
414
420
  hook = spawn(command, {
415
421
  cwd: this.cwd,
416
422
  detached: true,
417
- env: {...process.env, ...this.env, ROLLBRIDGE_PID: this.pid ? String(this.pid) : ""},
423
+ env: {...process.env, ...this.env, ROLLBRIDGE_PID: pid ? String(pid) : ""},
418
424
  shell: true,
419
425
  stdio: "ignore"
420
426
  })
@@ -461,19 +467,55 @@ export default class ManagedProcess extends EventEmitter {
461
467
 
462
468
  /**
463
469
  * @param {string} signal - Signal name to send (the configured stop signal, or "SIGKILL").
470
+ * @param {number | undefined} [pgid] - Process group id (the current child pid by default).
464
471
  * @returns {void}
465
472
  */
466
- killProcessGroup(signal) {
467
- if (!this.child || !this.child.pid) return
473
+ killProcessGroup(signal, pgid = this.pid) {
474
+ if (!pgid) return
468
475
 
469
476
  try {
470
- process.kill(-this.child.pid, signal)
477
+ process.kill(-pgid, signal)
471
478
  } catch (error) {
472
479
  if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return
473
480
  throw error
474
481
  }
475
482
  }
476
483
 
484
+ /**
485
+ * @param {number} pgid - Process group id.
486
+ * @returns {boolean} True until the process group no longer exists.
487
+ */
488
+ processGroupExists(pgid) {
489
+ const hasLiveMembers = processGroupHasLiveMembers(pgid)
490
+
491
+ if (hasLiveMembers !== undefined) return hasLiveMembers
492
+
493
+ try {
494
+ process.kill(-pgid, 0)
495
+
496
+ return true
497
+ } catch (error) {
498
+ if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false
499
+ throw error
500
+ }
501
+ }
502
+
503
+ /**
504
+ * @param {number} pgid - Process group id.
505
+ * @param {StopTimeoutMs} timeoutMs - Timeout.
506
+ * @returns {Promise<boolean>} True once the process group no longer exists.
507
+ */
508
+ async waitForProcessGroupExit(pgid, timeoutMs) {
509
+ const deadline = timeoutMs === "indefinite" ? undefined : Date.now() + timeoutMs
510
+
511
+ while (this.processGroupExists(pgid)) {
512
+ if (deadline !== undefined && Date.now() >= deadline) return false
513
+ await new Promise((resolve) => setTimeout(resolve, 10))
514
+ }
515
+
516
+ return true
517
+ }
518
+
477
519
  /**
478
520
  * @param {StopTimeoutMs} timeoutMs - Timeout.
479
521
  * @returns {Promise<boolean>} True when the process exited before timeout.
@@ -42,6 +42,34 @@ export function processGroupMembers(pgid) {
42
42
  return members
43
43
  }
44
44
 
45
+ /**
46
+ * Reports whether a process group has a member that can still run. Defunct members remain
47
+ * visible to kill(2) until their parent reaps them, but cannot handle signals or do work.
48
+ * @param {number} pgid - Process-group id.
49
+ * @param {string} [procPath] - Procfs root (overridable for deterministic tests).
50
+ * @returns {boolean | undefined} Whether a live member exists, or undefined without procfs.
51
+ */
52
+ export function processGroupHasLiveMembers(pgid, procPath = "/proc") {
53
+ /** @type {string[]} */
54
+ let entries
55
+
56
+ try {
57
+ entries = fs.readdirSync(procPath)
58
+ } catch {
59
+ return undefined
60
+ }
61
+
62
+ for (const entry of entries) {
63
+ if (!/^\d+$/.test(entry)) continue
64
+
65
+ const stat = processStat(entry, procPath)
66
+
67
+ if (stat?.pgrp === pgid && stat.state !== "Z" && stat.state !== "X") return true
68
+ }
69
+
70
+ return false
71
+ }
72
+
45
73
  /**
46
74
  * Measures the total resident memory (RSS) of a managed process group.
47
75
  * @param {number} pgid - Process-group id (the detached spawn's pid).
@@ -72,10 +100,19 @@ function commandName(pid) {
72
100
  * @returns {number | undefined} The process-group id, or undefined when the process is gone.
73
101
  */
74
102
  function processGroupId(pid) {
103
+ return processStat(pid, "/proc")?.pgrp
104
+ }
105
+
106
+ /**
107
+ * @param {string} pid - Process id.
108
+ * @param {string} procPath - Procfs root.
109
+ * @returns {{pgrp: number, state: string} | undefined} Parsed process state and group.
110
+ */
111
+ function processStat(pid, procPath) {
75
112
  let stat
76
113
 
77
114
  try {
78
- stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8")
115
+ stat = fs.readFileSync(`${procPath}/${pid}/stat`, "utf8")
79
116
  } catch {
80
117
  return undefined
81
118
  }
@@ -86,9 +123,10 @@ function processGroupId(pid) {
86
123
 
87
124
  if (commEnd < 0) return undefined
88
125
 
89
- const pgrp = Number(stat.slice(commEnd + 2).split(" ")[2])
126
+ const fields = stat.slice(commEnd + 2).split(" ")
127
+ const pgrp = Number(fields[2])
90
128
 
91
- return Number.isInteger(pgrp) ? pgrp : undefined
129
+ return Number.isInteger(pgrp) ? {pgrp, state: fields[0]} : undefined
92
130
  }
93
131
 
94
132
  /**
@@ -20,6 +20,8 @@ test("daemon bootstrap requires complete, safe, absolute inputs before binding l
20
20
  {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1"], message: /must be provided together/},
21
21
  {args: ["--config", "relative/config.js", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123"], message: /--config must be an absolute path/},
22
22
  {args: ["--config", "CONFIG", "--release-path", "relative/release", "--release-id", "v1", "--revision", "abc123"], message: /--release-path must be an absolute path/},
23
+ {args: ["--config", "CONFIG", "--release-path", "RELEASE_UNNORMALIZED", "--release-id", "v1", "--revision", "abc123"], message: /--release-path must be normalized/},
24
+ {args: ["--config", "CONFIG", "--release-path", "RELEASE_MISSING", "--release-id", "v1", "--revision", "abc123"], message: /--release-path is not accessible/},
23
25
  {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "unsafe id", "--revision", "abc123"], message: /--release-id/},
24
26
  {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "unsafe revision"], message: /--revision/}
25
27
  ]
@@ -27,7 +29,13 @@ test("daemon bootstrap requires complete, safe, absolute inputs before binding l
27
29
  for (const testCase of cases) {
28
30
  await t.test(testCase.message.source, async () => {
29
31
  const fixture = await createFixture()
30
- const args = testCase.args.map((arg) => arg === "CONFIG" ? fixture.configPath : arg === "RELEASE" ? fixture.root : arg)
32
+ const args = testCase.args.map((arg) => {
33
+ if (arg === "CONFIG") return fixture.configPath
34
+ if (arg === "RELEASE") return fixture.root
35
+ if (arg === "RELEASE_UNNORMALIZED") return `${fixture.root}/child/..`
36
+ if (arg === "RELEASE_MISSING") return path.join(fixture.root, "missing")
37
+ return arg
38
+ })
31
39
 
32
40
  try {
33
41
  const result = await runDaemon(args)
@@ -292,6 +292,41 @@ test("a configured stopCommand is used instead of the stop signal", async () =>
292
292
  }
293
293
  })
294
294
 
295
+ test("stopCommand receives the retained process group id after the shell exits", async () => {
296
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rollbridge-hooks-"))
297
+ const pidPath = path.join(dir, "stop-pid")
298
+ const managed = new ManagedProcess({
299
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")} & wait`,
300
+ cwd: undefined,
301
+ env: {},
302
+ id: "worker",
303
+ lifecycle: {
304
+ drainTimeoutMs: 0,
305
+ quietCommand: "kill -TERM $ROLLBRIDGE_PID; sleep 0.1",
306
+ stopCommand: `echo $ROLLBRIDGE_PID > ${JSON.stringify(pidPath)}; kill -TERM -$ROLLBRIDGE_PID`
307
+ },
308
+ logger: () => {},
309
+ outputLines: 50,
310
+ restartDelayMs: 10,
311
+ shouldRestart: () => false,
312
+ stopSignal: "SIGTERM",
313
+ stopTimeoutMs: 1000
314
+ })
315
+
316
+ try {
317
+ await managed.start()
318
+
319
+ const pgid = managed.pid
320
+
321
+ await managed.stop()
322
+
323
+ assert.equal(fs.readFileSync(pidPath, "utf8").trim(), String(pgid))
324
+ } finally {
325
+ await managed.stop()
326
+ fs.rmSync(dir, {force: true, recursive: true})
327
+ }
328
+ })
329
+
295
330
  test("a failing lifecycle hook is logged but does not fail the stop", async () => {
296
331
  /** @type {string[]} */
297
332
  const messages = []
@@ -414,6 +449,48 @@ test("indefinite stop waits for the process to exit without SIGKILL", async () =
414
449
  }
415
450
  })
416
451
 
452
+ test("stop waits for process group descendants after the detached shell exits", async () => {
453
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rollbridge-process-group-"))
454
+ const readyPath = path.join(dir, "ready")
455
+ const latePath = path.join(dir, "late")
456
+ const descendant = [
457
+ "const fs = require('node:fs')",
458
+ `process.on('SIGTERM', () => setTimeout(() => { fs.writeFileSync(${JSON.stringify(latePath)}, 'late'); process.exit(0) }, 300))`,
459
+ `fs.writeFileSync(${JSON.stringify(readyPath)}, 'ready')`,
460
+ "setInterval(() => {}, 1000)"
461
+ ].join("; ")
462
+ const managed = new ManagedProcess({
463
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(descendant)} & wait`,
464
+ cwd: undefined,
465
+ env: {},
466
+ id: "worker",
467
+ logger: () => {},
468
+ outputLines: 50,
469
+ restartDelayMs: 10,
470
+ shouldRestart: () => false,
471
+ stopSignal: "SIGTERM",
472
+ stopTimeoutMs: 2000
473
+ })
474
+
475
+ try {
476
+ await managed.start()
477
+ await waitFor(() => fs.existsSync(readyPath))
478
+
479
+ const startedAt = Date.now()
480
+
481
+ await managed.stop()
482
+
483
+ const elapsedMs = Date.now() - startedAt
484
+
485
+ assert.ok(elapsedMs >= 250, `stop resolved after only ${elapsedMs}ms`)
486
+ assert.ok(elapsedMs < 1500, `stop took ${elapsedMs}ms`)
487
+ assert.equal(fs.readFileSync(latePath, "utf8"), "late")
488
+ } finally {
489
+ await managed.stop()
490
+ fs.rmSync(dir, {force: true, recursive: true})
491
+ }
492
+ })
493
+
417
494
  test("a memory restart respawns and is counted when the supervisor still wants the process", async () => {
418
495
  const managed = buildLongLived(() => true)
419
496
 
@@ -467,6 +544,10 @@ test("does not auto-restart when the restart policy is disabled (maxRestarts: 0)
467
544
 
468
545
  test("stops auto-restarting once maxRestarts within the window is reached", async () => {
469
546
  const managed = buildCrasher({backoffFactor: 1, maxDelayMs: 0, maxRestarts: 2, windowMs: 60000})
547
+ /** @type {{data: Record<string, import("../src/json.js").JsonValue>, message: string}[]} */
548
+ const events = []
549
+
550
+ managed.logger = (message, data = {}) => { events.push({data, message}) }
470
551
 
471
552
  try {
472
553
  await managed.start()
@@ -477,6 +558,11 @@ test("stops auto-restarting once maxRestarts within the window is reached", asyn
477
558
 
478
559
  assert.equal(managed.status().restarts, 2)
479
560
  assert.equal(managed.status().state, "failed")
561
+ assert.deepEqual(events.find((event) => event.message === "restart limit reached")?.data, {
562
+ id: "crasher",
563
+ maxRestarts: 2,
564
+ windowMs: 60000
565
+ })
480
566
  } finally {
481
567
  await managed.stop()
482
568
  }
@@ -3,7 +3,9 @@
3
3
  import assert from "node:assert/strict"
4
4
  import fs from "node:fs"
5
5
  import test from "node:test"
6
- import {measureProcessGroupRssBytes, processGroupMembers} from "../src/process-memory.js"
6
+ import os from "node:os"
7
+ import path from "node:path"
8
+ import {measureProcessGroupRssBytes, processGroupHasLiveMembers, processGroupMembers} from "../src/process-memory.js"
7
9
 
8
10
  const linuxOnly = process.platform !== "linux" && "requires /proc (Linux)"
9
11
 
@@ -38,3 +40,21 @@ test("lists process-group members with their command and resident memory", {skip
38
40
  test("returns an empty list for a process group with no members", {skip: linuxOnly}, () => {
39
41
  assert.deepEqual(processGroupMembers(2147483646), [])
40
42
  })
43
+
44
+ test("treats a process group containing only defunct members as stopped", () => {
45
+ const procPath = fs.mkdtempSync(path.join(os.tmpdir(), "rollbridge-proc-"))
46
+
47
+ try {
48
+ fs.mkdirSync(path.join(procPath, "101"))
49
+ fs.writeFileSync(path.join(procPath, "101", "stat"), "101 (worker) Z 1 77 0 0")
50
+
51
+ assert.equal(processGroupHasLiveMembers(77, procPath), false)
52
+
53
+ fs.mkdirSync(path.join(procPath, "102"))
54
+ fs.writeFileSync(path.join(procPath, "102", "stat"), "102 (worker) S 1 77 0 0")
55
+
56
+ assert.equal(processGroupHasLiveMembers(77, procPath), true)
57
+ } finally {
58
+ fs.rmSync(procPath, {force: true, recursive: true})
59
+ }
60
+ })
@@ -8,11 +8,11 @@ import net from "node:net"
8
8
  import os from "node:os"
9
9
  import path from "node:path"
10
10
  import test from "node:test"
11
- import {fileURLToPath} from "node:url"
11
+ import {fileURLToPath, pathToFileURL} from "node:url"
12
12
  import RollbridgeDaemon from "../src/daemon.js"
13
13
  import {normalizeConfig} from "../src/config.js"
14
14
  import {sendControlCommand} from "../src/control-client.js"
15
- import {readState, writeState} from "../src/state-store.js"
15
+ import {liveProcesses, readState, writeState} from "../src/state-store.js"
16
16
  import {runCli} from "../src/cli.js"
17
17
 
18
18
  const currentDir = path.dirname(fileURLToPath(import.meta.url))
@@ -635,6 +635,37 @@ test("persists daemon state to statePath and removes it on a clean shutdown", as
635
635
  assert.equal(stateAfterShutdown, undefined, "state file removed on clean shutdown")
636
636
  })
637
637
 
638
+ test("persisted daemon state excludes process commands, environment values, and output", async () => {
639
+ const secret = "state-secret-value"
640
+ const fixture = await createFixture({persistState: true})
641
+ const web = fixture.config.processes.find((processConfig) => processConfig.id === "web")
642
+
643
+ assert.ok(web)
644
+ web.env.ROLLBRIDGE_TEST_SECRET = secret
645
+ web.command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(`console.log(process.env.ROLLBRIDGE_TEST_SECRET); import(${JSON.stringify(pathToFileURL(dummyAppPath).href)})`)}`
646
+
647
+ const daemon = await startDaemon(fixture.config)
648
+
649
+ try {
650
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
651
+ await waitFor(() => Boolean(daemon.activeRelease?.getProcess("web")?.status().logs.some((entry) => entry.line === secret)))
652
+
653
+ daemon.persistState()
654
+ await waitFor(async () => (await fs.readFile(fixture.statePath, "utf8")).includes('"activeReleaseId": "v1"'))
655
+
656
+ const persisted = await fs.readFile(fixture.statePath, "utf8")
657
+
658
+ assert.doesNotMatch(persisted, /state-secret-value/)
659
+ assert.doesNotMatch(persisted, /ROLLBRIDGE_TEST_SECRET/)
660
+ assert.doesNotMatch(persisted, /"command"/)
661
+ assert.doesNotMatch(persisted, /"logs"/)
662
+ assert.deepEqual(liveProcesses(JSON.parse(persisted), () => true).map(({id, releaseId}) => ({id, releaseId})), [{id: "web", releaseId: "v1"}])
663
+ } finally {
664
+ await daemon.shutdown()
665
+ await fs.rm(fixture.root, {force: true, recursive: true})
666
+ }
667
+ })
668
+
638
669
  test("a clean shutdown clears the state file even when a persist write is in flight", async () => {
639
670
  const fixture = await createFixture({persistState: true})
640
671
  const daemon = await startDaemon(fixture.config)