rollbridge 0.1.39 → 0.1.40

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
@@ -227,7 +227,13 @@ then runs a private local process guardian which remains the OS supervisor for
227
227
  managed processes if the control daemon exits unexpectedly. A replacement using
228
228
  the exact same normalized config/runtime reconnects within `reconnectGraceMs`,
229
229
  reconstructs active and draining generations and their ports, and fences
230
- concurrent replacements. `ensure-daemon` can also prepare a requested
230
+ concurrent replacements. If no replacement reconnects during that grace, the
231
+ guardian restarts the exact accepted daemon command and environment itself; the
232
+ recovery definition is kept only in the guardian's private authenticated state
233
+ and is refreshed atomically during a package/runtime replacement. A restart
234
+ attempt which cannot claim ownership and publish ready listeners within the
235
+ accepted startup timeout is terminated with its process group and retried with a
236
+ nonzero backoff. `ensure-daemon` can also prepare a requested
231
237
  config/control-socket/package/runtime owner, prove it healthy, and atomically
232
238
  transfer guardian authority while every retained generation keeps its exact
233
239
  release reference and drains asynchronously. The old `statePath` is the durable
@@ -237,6 +243,15 @@ Prepared transactions fence owner mutations and compare a monotonic guardian
237
243
  state revision at staging. Existing HTTP/WebSocket connections remain owned by
238
244
  the retired listener process, while their counts transfer to the new daemon so
239
245
  later deploys continue to honor the original drain boundary.
246
+ If that new daemon itself exits while a retired listener still owns connections,
247
+ recovery conservatively retains the last authenticated transferred count until
248
+ the configured drain timeout; it never guesses that the older sockets closed.
249
+ An intermediate guardian which supports atomic owner replacement but predates
250
+ daemon recovery cannot be hot-upgraded because it is the existing processes' OS
251
+ supervisor. The upgrade fails before handoff and requires one explicit clean
252
+ `shutdown` followed by `ensure-daemon`; subsequent package/runtime replacements
253
+ remain atomic. A genuinely pre-replacement guardian still uses the separately
254
+ documented one-time disruptive compatibility bridge below.
240
255
 
241
256
  There is one explicit compatibility boundary: the first upgrade from a genuine
242
257
  pre-owner-replacement Rollbridge guardian and daemon cannot share its listeners
@@ -425,10 +440,13 @@ If the new release fails to start or health-check, the previous release stays
425
440
  active and any service started during this deploy is rolled back.
426
441
 
427
442
  `status.releaseReferences` lists the id and path of every active or draining
428
- release. A reference disappears only when that release is fully stopped. With
443
+ release, plus a stopped release that still owns a persistent service definition
444
+ or singleton, or remains part of an unresolved generation transition. A
445
+ reference disappears only when that release has stopped and no longer owns
446
+ runtime state. With
429
447
  `ownerRecovery`, those references and generations survive both same-authority
430
- daemon recovery and guardian-fenced incompatible config/control-socket/package/
431
- runtime replacement through `ensure-daemon`.
448
+ daemon recovery and guardian-fenced incompatible
449
+ config/control-socket/package/runtime replacement through `ensure-daemon`.
432
450
 
433
451
  ## Commands
434
452
 
@@ -7,10 +7,21 @@
7
7
  - Fail closed when an older retained guardian cannot commit that replacement
8
8
  atomically after the incumbent control socket disappears, preserving the
9
9
  incumbent owner, retained connections, and guardian-managed release processes.
10
+ - Retire the committed incumbent listener after a control-socket-absent handoff
11
+ only after relaying source-identified live connection counts through successive
12
+ owner replacements. Existing WebSocket connections can finish draining without
13
+ one retired listener clearing another listener's counts, and socket-path cleanup
14
+ remains fenced by the listener identity that was actually bound.
15
+ - Keep incumbent authority while it yields a fixed proxy, commit only after the
16
+ candidate receives complete listener state and binds successfully, and resume
17
+ the incumbent if that bind fails. Pending retirement survives candidate recovery,
18
+ concurrent replacements remain fenced, and crashed local sources publish zero
19
+ tombstones while stopped zero-count releases remain omitted.
10
20
  - Explicitly classify retained guardian replacement capabilities before preparing
11
21
  a transaction. Guardians with prepare/stage support but no retired-owner commit
12
22
  command use the fully attested one-time disruptive legacy upgrade bridge;
13
23
  malformed, stale, or ambiguous protocol responses continue to fail closed. The
14
24
  partial guardian's prepared transaction remains the mutation fence through
15
25
  candidate reconstruction and boundary revalidation, and failed preparation
16
- notifies the incumbent to resume paused release drains.
26
+ notifies the incumbent to resume paused release drains. The bridge coordinator
27
+ publishes its own authenticated recovery identity before the candidate proceeds.
@@ -5,5 +5,19 @@
5
5
  candidate-activate acknowledgement, and synchronous active/proxy commit with
6
6
  exact transition recovery and resume. Exact release definitions remain private
7
7
  to guardian recovery, post-commit singleton work is resumable, unresolved
8
- control mutations are fenced, and an active coordinator restores its role after
9
- restart. Hook-free configs keep their existing deploy behavior.
8
+ control and terminal owner mutations are fenced, recovery uses a complete
9
+ monotonic journal and publishes listener/PID readiness before replaying long
10
+ hooks, signals shut down cleanly after replay, and an active coordinator
11
+ restores its role after restart. Public recovery state advances only after its
12
+ private guardian authority, persistent service definitions retain their exact
13
+ release across recovery, manual restart reaches the active handoff coordinator,
14
+ unresolved transitions retain both release definitions and reject
15
+ config-authority-changing owner replacements, and live activation-mode changes
16
+ require a daemon restart. Hook-free configs keep their existing deploy behavior.
17
+
18
+ ### Fixed
19
+
20
+ - Serialize active-role restoration with retirement, reject empty lifecycle
21
+ commands, bound guardian process-log forwarding for stalled clients, and keep
22
+ a claimed recovery owner alive until replacement listener retirement. Journal
23
+ hook-free post-switch retirement and singleton work for exact crash recovery.
@@ -0,0 +1,2 @@
1
+ - Let an `ownerRecovery` process guardian restart the exact accepted Rollbridge daemon command and environment after the reconnection grace when no external replacement claims ownership. Active and draining managed processes keep their existing PIDs, foreground or configured daemon log destinations remain current, the guardian atomically publishes PID files only for ready owners, startup fencing lasts through listener readiness, failed or superseded process groups are terminated, failed starts use a nonzero retry backoff, diagnostics exclude private recovery values, relative control/state paths resolve from the durable config directory, ensured daemons recover from that directory rather than a removable release cwd, and atomic package/runtime replacement refreshes the guardian's private recovery definition.
2
+ - Require one explicit clean shutdown and restart when upgrading an intermediate persistent guardian that supports owner replacement but predates daemon recovery, because the existing OS supervisor cannot gain daemon-recovery code through an ordinary daemon-only handoff. Genuinely pre-replacement guardians retain their one-time disruptive compatibility bridge.
package/docs/cli.md CHANGED
@@ -99,8 +99,11 @@ rollbridge ensure-daemon [--config <path>]
99
99
  ```
100
100
 
101
101
  Starts the daemon as a detached process **only if** the control socket is not
102
- already accepting commands, waits until it responds, then prints the daemon
103
- status JSON. Idempotent — safe to call before every deploy.
102
+ already accepting commands, waits until it responds and its guardian accepts
103
+ the ready owner, then prints the daemon status JSON. Idempotent — safe to call
104
+ before every deploy. The detached daemon uses the config file's directory as its
105
+ working directory, rather than the invoking release, so release retention cannot
106
+ remove the accepted recovery cwd.
104
107
 
105
108
  Before starting a detached daemon, Rollbridge atomically copies its runtime code
106
109
  and production dependency closure into a content-addressed directory outside
@@ -128,8 +131,8 @@ and authority failures do not qualify and fail before any deploy is sent.
128
131
  [`logging.md`](logging.md) for the log format and rotation guidance.
129
132
  - `--daemon-pid-path <path>` — file the detached daemon's PID is written to.
130
133
  Default: `/tmp/rollbridge-<application>.pid`. During replacement, the file
131
- continues to name the incumbent until the reachable winner reports and
132
- publishes its exact `daemonPid`.
134
+ continues to name the incumbent until the authenticated guardian atomically
135
+ publishes the ready winner's exact `daemonPid`.
133
136
  - `--daemon-runtime-path <path>` — parent directory for content-addressed daemon
134
137
  runtime snapshots. Default:
135
138
  `/tmp/rollbridge-<user-id>-<application-hash>-runtime`. The directory must be owned
@@ -167,10 +170,16 @@ An unresolved failure blocks different deploys; only the exact same release,
167
170
  path, revision, and config authority may explicitly resume its incomplete
168
171
  idempotent phase. A durable `committed_pending` phase keeps exact retry from
169
172
  reporting success until singleton replacement finishes. Stop, restart, and
170
- rollback mutations are rejected while the transition is unresolved. Hook-free
171
- configs retain the existing post-activation quiet behavior and retirement result.
172
- `status.releaseReferences` lists `{releaseId, releasePath}` for every active or
173
- draining release and excludes fully stopped history.
173
+ rollback mutations are rejected while the transition is unresolved. Automatic
174
+ replay also fences `shutdown` and `retire-owner` until the replay attempt settles.
175
+ An owner replacement may change runtime/package identity during an unresolved
176
+ transition, but it cannot change config authority until that transition commits;
177
+ the incumbent owner remains in place when such a replacement is rejected.
178
+ Hook-free configs retain the existing post-activation quiet behavior and
179
+ retirement result. `status.releaseReferences` lists `{releaseId, releasePath}`
180
+ for every active or draining release and for a stopped release that still owns a
181
+ persistent service definition, pending singleton, or unresolved generation
182
+ transition; unrelated fully stopped history is excluded.
174
183
 
175
184
  For hook-free configs, after candidate activation `Daemon.deploy()` begins old-generation retirement
176
185
  and asynchronous drain before awaiting singleton replacement. A singleton
@@ -244,6 +253,9 @@ Memory-supervised processes also report `rssBytes`, `memoryRestarts`,
244
253
  `daemonRuntime` identifies the immutable Rollbridge runtime serving the proxy:
245
254
  its runtime `format`, package `version`, content `digest`, and absolute `path`.
246
255
  `ensure-daemon` uses this attestation before reusing a responsive daemon.
256
+ With `ownerRecovery`, `ownerRecovery.ready` becomes `true` only after the
257
+ guardian has accepted that daemon's listener readiness and atomically published
258
+ its configured PID file; `ensure-daemon` does not return a pre-ready status.
247
259
 
248
260
  A foreground known-release daemon also reports the exact CLI bootstrap identity:
249
261
 
@@ -302,7 +314,9 @@ Targeting it (by id or `--policy proxied`) is an error; use `rollbridge deploy`
302
314
  for a zero-downtime replacement. `--process <id>` with an id that is not a
303
315
  managed process (unknown, or a companion with no active release) is also an
304
316
  error. Restarting a `service` bounces a shared broker (for example Velocious
305
- Beacon), which briefly disrupts every process that depends on it.
317
+ Beacon), which briefly disrupts every process that depends on it. For a handoff
318
+ service, restart targets only the active release's instance and restores its
319
+ active lifecycle role before reporting success.
306
320
 
307
321
  ## `predeploy-cleanup`
308
322
 
package/docs/config.md CHANGED
@@ -50,13 +50,13 @@ restart.
50
50
  | `proxy` | object | **required** | Proxy listener and shared defaults (see below). |
51
51
  | `processes` | array | **required** | Managed processes (see below). Exactly one must be `proxied`. |
52
52
  | `releaseRetention` | object | — | How many stopped releases the daemon retains (see below). |
53
- | `statePath` | string | unset (no persistence) | File the daemon persists its state to, enabling orphaned-process detection on the next startup (see [`statePath`](#statepath)). |
53
+ | `statePath` | string | unset (no persistence) | File the daemon persists its state to, enabling orphaned-process detection on the next startup; relative paths resolve from the config file directory (see [`statePath`](#statepath)). |
54
54
 
55
55
  ## `control`
56
56
 
57
57
  | Field | Type | Default | Description |
58
58
  | --- | --- | --- | --- |
59
- | `control.path` | string | `/tmp/rollbridge-<application>.sock` | Unix domain socket the CLI uses to talk to the daemon. |
59
+ | `control.path` | string | `/tmp/rollbridge-<application>.sock` | Unix domain socket the CLI uses to talk to the daemon; relative paths resolve from the config file directory. |
60
60
  | `control.mode` | octal string (e.g. `"660"`) or octal number (`0o660`) | unset | `chmod` applied to the socket after it binds, to share it with a deploy group. When unset, the daemon umask applies. |
61
61
  | `control.owner` | non-negative integer uid or user name | unset | `chown` owner applied to the socket after it binds. |
62
62
  | `control.group` | non-negative integer gid or group name | unset | `chown` group applied to the socket after it binds, so a shared deploy group can use it. |
@@ -126,15 +126,30 @@ ownerRecovery: {reconnectGraceMs: 30000}
126
126
  The private guardian socket is derived from `statePath`; the atomic state file is
127
127
  written mode `0600` and contains its authentication capability. The guardian
128
128
  owns managed child processes, restart policy, lifecycle hooks, and exit events.
129
- After an unexpected daemon exit, an exact config/runtime replacement claims the
130
- guardian during `reconnectGraceMs`, restores active and draining releases with
131
- their allocated ports, and resumes proxy/control ownership. Concurrent matching
132
- starts are fenced: one claims ownership and losers attest that winner. A config
133
- identity mismatch or partial state fails closed without rewriting the snapshot.
134
- Owner disconnection alone never reclaims accepted work or transfers workers:
135
- guardian-owned processes and their generation-local connections continue during
136
- the grace, so the replacement reconnects to supervision rather than duplicating
137
- execution.
129
+ After an unexpected daemon exit, an exact config/runtime replacement may claim
130
+ the guardian during `reconnectGraceMs`. If none does, the guardian restarts the
131
+ exact accepted daemon command and environment itself, retains startup fencing
132
+ until that daemon publishes its ready listeners and PID file, and uses a nonzero
133
+ retry backoff after failed starts. The replacement restores active and draining
134
+ releases with their allocated ports and resumes proxy/control ownership.
135
+ An unfailed durable generation transition resumes under the guardian's mutation
136
+ fence after those listeners and the recovered PID are ready, so a valid long
137
+ lifecycle hook does not consume the daemon startup deadline. Status remains
138
+ available during replay while competing mutations and terminal owner operations
139
+ stay fenced. `SIGINT` and `SIGTERM` wait for that startup replay before beginning
140
+ clean shutdown. Transition snapshots carry a monotonic journal revision; the
141
+ public snapshot is considered only when it is strictly newer and contains every
142
+ retained transition and singleton-owning release, while private guardian state
143
+ remains authoritative on ties and for older released snapshots without a
144
+ journal revision.
145
+ Concurrent matching starts are fenced: one claims ownership and losers attest
146
+ that winner. The authenticated guardian's private committed state is
147
+ authoritative when the public snapshot is stale or partially written; missing or
148
+ corrupt guardian identity, authentication, or authority still fails closed
149
+ without rewriting the snapshot. Owner disconnection alone never reclaims
150
+ accepted work or transfers workers: guardian-owned processes and their
151
+ generation-local connections continue during the grace, so the replacement
152
+ reconnects to supervision rather than duplicating execution.
138
153
 
139
154
  For a responsive incompatible owner, `ensure-daemon` prepares the requested
140
155
  durable runtime, restores exact active and draining generation definitions from
@@ -163,6 +178,13 @@ identity; retry config or socket changes after the protocol upgrade, when the
163
178
  normal atomic handoff applies. Other guardian/auth/transport/identity failures
164
179
  remain fail-closed.
165
180
 
181
+ An intermediate guardian which already supports atomic owner replacement but
182
+ predates guardian-owned daemon recovery cannot be upgraded through that bridge.
183
+ The candidate aborts before listener handoff, the incumbent resumes any paused
184
+ drains and remains serving, and the command requests one explicit clean
185
+ `shutdown` followed by `ensure-daemon` so the new guardian can become the OS
186
+ supervisor.
187
+
166
188
  Without `ownerRecovery`, `statePath` retains the advisory orphan behavior above.
167
189
 
168
190
  ## `legacyTakeover`
@@ -293,7 +315,9 @@ release id, path, revision, and config may resume only its incomplete idempotent
293
315
  phase. Once the health-ready candidate is journaled, its exact config becomes the
294
316
  transition authority even if a later hook fails. A recorded failed hook is not
295
317
  retried merely because daemon ownership changes. Omit `activateCommand` to retain
296
- the existing activate-then-retire behavior.
318
+ the existing activate-then-retire behavior. Adding, removing, or moving
319
+ `activateCommand` changes the daemon's generation coordinator and therefore
320
+ requires a daemon restart before the next deploy.
297
321
 
298
322
  The synchronous traffic assignment is persisted as `committed_pending` before
299
323
  Rollbridge awaits singleton replacement. Exact retry or unambiguous owner recovery
@@ -469,6 +493,6 @@ Rollbridge sets these in every managed process's environment (the process's own
469
493
  - `restart.maxRestarts` must be a non-negative integer (omit it for unlimited restarts); `restart.backoffFactor` must be a number ≥ 1; `restart.windowMs` and `restart.maxDelayMs` must be non-negative numbers.
470
494
  - When `memory` is set, `memory.limitBytes` must be a positive integer, `memory.warnBytes` a non-negative integer, and `memory.checkIntervalMs` a positive number.
471
495
  - `replicas` must be a positive integer; `replicas > 1` is allowed only on a `companion` process without a `port`. Process ids must not contain `#` (reserved for replica instance ids).
472
- - `lifecycle.activateCommand`/`quietCommand`/`drainCommand`/`stopCommand` must be strings when set, and `lifecycle.drainTimeoutMs` a non-negative number; `lifecycle.drainCommand` requires a positive `lifecycle.drainTimeoutMs`. `activateCommand` is allowed on at most one handoff service, requires that service's `quietCommand`, and requires `statePath` plus `ownerRecovery`. A `lifecycle.stopCommand` may not be combined with a custom `stopSignal` (the `stopCommand` runs instead of the signal, so the signal would be ignored).
496
+ - `lifecycle.activateCommand`/`quietCommand`/`drainCommand`/`stopCommand` must be non-empty strings when set, and `lifecycle.drainTimeoutMs` a non-negative number; `lifecycle.drainCommand` requires a positive `lifecycle.drainTimeoutMs`. `activateCommand` is allowed on at most one handoff service, requires that service's `quietCommand`, and requires `statePath` plus `ownerRecovery`. A `lifecycle.stopCommand` may not be combined with a custom `stopSignal` (the `stopCommand` runs instead of the signal, so the signal would be ignored).
473
497
  - `nonBlockingDrain` must be a boolean, and is allowed only on a `companion` process.
474
498
  - `statePath` must be a string when set.
package/docs/logging.md CHANGED
@@ -15,7 +15,7 @@ daemon was started.
15
15
 
16
16
  | How the daemon runs | Destination |
17
17
  | --- | --- |
18
- | `rollbridge daemon` (foreground) | stdout — redirect it (`rollbridge daemon … >> /var/log/rollbridge/app.log 2>&1`) or let your service manager capture it. |
18
+ | `rollbridge daemon` (foreground) | stdout — redirect it (`rollbridge daemon … >> /var/log/rollbridge/app.log 2>&1`) or let your service manager capture it. With `ownerRecovery`, guardian restarts inherit that same accepted destination. |
19
19
  | systemd (`examples/rollbridge.service`) | the journal — `journalctl -u rollbridge`. journald rotates on its own. |
20
20
  | `rollbridge ensure-daemon` / `rollbridge deploy --ensure-daemon` | the **daemon log file**: `--daemon-log-path <path>`, default `/tmp/rollbridge-<application>.log`. The detached daemon's stdout and stderr are appended there. |
21
21
 
@@ -46,8 +46,9 @@ protected accordingly.
46
46
 
47
47
  Without `ownerRecovery`, both in-memory views clear when the daemon restarts.
48
48
  With it, guardian-held process output remains available after reconnection while
49
- the replacement daemon begins a new event history. The configured log file
50
- persists in either mode.
49
+ the replacement daemon begins a new event history. A detached ensured daemon
50
+ keeps appending to its configured log file, while a recovered foreground daemon
51
+ inherits its original stdout/stderr destination.
51
52
 
52
53
  ## Rotation
53
54
 
@@ -138,11 +138,13 @@ end close idle WebSockets on deploy). In the documented compliant jobs topology,
138
138
  jobs companions use `nonBlockingDrain: true`, so timeout expiry affects only the
139
139
  web side and must not stop a still-draining jobs generation.
140
140
 
141
- `status.releaseReferences` reports active and draining releases until full stop;
142
- Rampway still owns enforcement against on-disk cleanup. With `ownerRecovery`,
143
- references reconstruct across same-authority daemon process replacement and
144
- transfer across an incompatible `ensure-daemon` owner handoff. They do not
145
- transfer through the separate destructive `--takeover-owner` path. If
141
+ `status.releaseReferences` reports active and draining releases, plus a stopped
142
+ release that still owns a persistent service definition, pending singleton, or
143
+ unresolved generation transition, until all runtime ownership ends; Rampway
144
+ still owns enforcement against on-disk cleanup. With `ownerRecovery`, references
145
+ reconstruct across same-authority daemon process replacement and transfer across
146
+ an incompatible `ensure-daemon` owner handoff. They do not transfer through the
147
+ separate destructive `--takeover-owner` path. If
146
148
  `retirementError` is set,
147
149
  inspect the quiet-hook events. Rollbridge deliberately leaves that generation
148
150
  alive rather than signaling arbitrary PIDs or continuing its stop sequence.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.39",
3
+ "version": "0.1.40",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
package/src/cli.js CHANGED
@@ -41,37 +41,71 @@ export async function runCli(argv) {
41
41
  .option("--boot-attestation <digest>", "Opaque bootstrap ownership attestation (requires the complete bootstrap release tuple)")
42
42
  .option("--takeover-owner", "Boot and health-check before retiring the current external owner")
43
43
  .option("--replace-owner", "Resume a prepared durable owner replacement")
44
+ .addOption(new Option("--guardian-daemon-log-path <path>").hideHelp())
45
+ .addOption(new Option("--guardian-daemon-pid-path <path>").hideHelp())
46
+ .addOption(new Option("--guardian-daemon-start-timeout-ms <ms>").hideHelp())
44
47
  .addOption(new Option("--legacy-incumbent-pid <pid>").hideHelp())
45
48
  .action(async (options) => {
46
49
  const bootstrap = await validateDaemonBootstrapOptions(options)
47
50
  const configPath = await resolveConfigPath(options.config)
48
51
  const config = await loadConfig(configPath)
49
52
  const runtime = await loadDaemonRuntimeIdentity(process.env.ROLLBRIDGE_DAEMON_RUNTIME_MANIFEST)
53
+ const recoveryEnvironment = /** @type {Record<string, string>} */ (Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined)))
54
+ const recoveryLogPath = options.guardianDaemonLogPath ? path.resolve(options.guardianDaemonLogPath) : undefined
55
+ const recoveryPidPath = options.guardianDaemonPidPath ? path.resolve(options.guardianDaemonPidPath) : undefined
56
+ const recoveryStartupTimeoutMs = normalizeTimeoutMs(options.guardianDaemonStartTimeoutMs)
50
57
  const daemon = new RollbridgeDaemon({
51
58
  bootstrap,
52
59
  config,
53
60
  configPath,
54
61
  legacyIncumbentPid: positiveIntegerOrUndefined(options.legacyIncumbentPid, "legacy incumbent pid"),
62
+ recoveryCommand: {
63
+ args: [
64
+ path.resolve(argv[1]), "daemon", "--config", configPath,
65
+ ...(recoveryLogPath ? ["--guardian-daemon-log-path", recoveryLogPath] : []),
66
+ ...(recoveryPidPath ? ["--guardian-daemon-pid-path", recoveryPidPath] : []),
67
+ "--guardian-daemon-start-timeout-ms", String(recoveryStartupTimeoutMs)
68
+ ],
69
+ cwd: process.cwd(),
70
+ env: recoveryEnvironment,
71
+ executable: process.execPath,
72
+ logPath: recoveryLogPath,
73
+ pidPath: recoveryPidPath,
74
+ startupTimeoutMs: recoveryStartupTimeoutMs
75
+ },
55
76
  runtime
56
77
  })
78
+ let startupPromise = Promise.resolve()
79
+ let shutdownRequested = false
80
+ const shutdown = async () => {
81
+ shutdownRequested = true
82
+ await startupPromise.catch(() => undefined)
83
+ await daemon.shutdown()
84
+ process.exit(0)
85
+ }
86
+
87
+ process.once("SIGINT", () => { void shutdown() })
88
+ process.once("SIGTERM", () => { void shutdown() })
57
89
 
58
90
  if (options.takeoverOwner && (!bootstrap || !bootstrap.attestation)) throw new Error("Daemon --takeover-owner requires the complete bootstrap release tuple and --boot-attestation.")
59
91
 
60
92
  if (options.replaceOwner) {
61
93
  if (bootstrap || options.takeoverOwner) throw new Error("Daemon --replace-owner cannot be combined with bootstrap takeover options.")
62
- await daemon.replaceIncompatibleOwner()
94
+ startupPromise = daemon.replaceIncompatibleOwner()
95
+ await startupPromise
63
96
  }
64
97
 
65
98
  if (!options.takeoverOwner && !options.replaceOwner) {
66
99
  try {
67
- await daemon.start({exposeControl: !bootstrap})
100
+ startupPromise = daemon.start({exposeControl: !bootstrap})
101
+ await startupPromise
68
102
  } catch (error) {
69
103
  if (!config.ownerRecovery) throw error
70
104
 
71
105
  const winner = await sendControlCommand({command: {command: "status"}, path: config.control.path}).catch(() => undefined)
72
106
  const matchingWinner = winner?.application === config.application &&
73
107
  winner.ownerRecovery && typeof winner.ownerRecovery === "object" && !Array.isArray(winner.ownerRecovery) &&
74
- winner.ownerRecovery.configDigest === daemon.ownerRecoveryConfigDigest() &&
108
+ winner.ownerRecovery.configDigest === daemon.ownerRecoveryConfigDigest() && winner.ownerRecovery.ready === true &&
75
109
  ((!runtime && !winner.daemonRuntime) || (runtime && winner.daemonRuntime && typeof winner.daemonRuntime === "object" && !Array.isArray(winner.daemonRuntime) && winner.daemonRuntime.digest === runtime.digest))
76
110
 
77
111
  if (!matchingWinner) throw error
@@ -79,14 +113,7 @@ export async function runCli(argv) {
79
113
  return
80
114
  }
81
115
  }
82
-
83
- const shutdown = async () => {
84
- await daemon.shutdown()
85
- process.exit(0)
86
- }
87
-
88
- process.once("SIGINT", () => { void shutdown() })
89
- process.once("SIGTERM", () => { void shutdown() })
116
+ if (shutdownRequested) return
90
117
 
91
118
  if (bootstrap) {
92
119
  try {
@@ -105,6 +132,7 @@ export async function runCli(argv) {
105
132
  if (config.ownerRecovery && daemon.releases.size > 0) {
106
133
  if (daemon.activeRelease) {
107
134
  await daemon.exposeControl()
135
+ await publishDaemonReadiness(daemon, recoveryPidPath)
108
136
  return
109
137
  }
110
138
  await daemon.abandonOwnerRecoveryAttempt()
@@ -124,6 +152,7 @@ export async function runCli(argv) {
124
152
  return
125
153
  }
126
154
  }
155
+ if (!shutdownRequested) await publishDaemonReadiness(daemon, recoveryPidPath)
127
156
  })
128
157
 
129
158
  program
@@ -839,6 +868,8 @@ async function validateDaemonBootstrapOptions(options) {
839
868
  */
840
869
  async function ensureDaemonRunning({config, configPath, logPath, pidPath, runtimePath, timeoutMs}) {
841
870
  const runtime = await prepareDaemonRuntime(runtimePath || defaultDaemonRuntimePath(config))
871
+ const resolvedConfigPath = path.resolve(configPath)
872
+ const resolvedLogPath = path.resolve(logPath || defaultDaemonLogPath(config))
842
873
  const existingStatus = await daemonStatus(config)
843
874
  const expectedConfigDigest = ownerConfigDigest(config)
844
875
 
@@ -848,26 +879,30 @@ async function ensureDaemonRunning({config, configPath, logPath, pidPath, runtim
848
879
  existingStatus.ownerRecovery.configDigest === expectedConfigDigest
849
880
  )
850
881
 
851
- if (compatibleDaemonRuntime(existingStatus, runtime) && matchingRecoveryAuthority) return existingStatus
882
+ if (compatibleDaemonRuntime(existingStatus, runtime) && matchingRecoveryAuthority) {
883
+ if (!config.ownerRecovery || (existingStatus.ownerRecovery && typeof existingStatus.ownerRecovery === "object" && !Array.isArray(existingStatus.ownerRecovery) && existingStatus.ownerRecovery.ready === true)) return existingStatus
884
+ return await waitForDaemonStatus(config, timeoutMs, {configDigest: expectedConfigDigest, runtime})
885
+ }
852
886
  if (!config.ownerRecovery) assertCompatibleDaemonRuntime(existingStatus, runtime)
853
887
  }
854
888
 
855
889
  const persistedState = config.ownerRecovery && config.statePath ? await readState(config.statePath) : undefined
856
890
  const persistedOwner = persistedState && typeof persistedState === "object" && !Array.isArray(persistedState) ? persistedState : undefined
857
891
  const persistedRecovery = persistedOwner?.recovery
858
- const replacement = Boolean(config.ownerRecovery && (existingStatus || (persistedRecovery && typeof persistedRecovery === "object" && !Array.isArray(persistedRecovery) && (
859
- persistedRecovery.configDigest !== expectedConfigDigest || !compatibleDaemonRuntime(/** @type {Record<string, import("./json.js").JsonValue>} */ (persistedOwner), runtime)
860
- ))))
861
- const resolvedPidPath = pidPath || defaultDaemonPidPath(config)
892
+ const replacement = Boolean(config.ownerRecovery && (existingStatus || (persistedRecovery && typeof persistedRecovery === "object" && !Array.isArray(persistedRecovery))))
893
+ const resolvedPidPath = path.resolve(pidPath || defaultDaemonPidPath(config))
862
894
  const legacyIncumbentPid = replacement ? await readDaemonPid(resolvedPidPath) : undefined
863
895
 
864
896
  await fsPromises.mkdir(path.dirname(resolvedPidPath), {recursive: true})
865
897
  const candidate = await startDaemonProcess({
866
- configPath,
867
- logPath: logPath || defaultDaemonLogPath(config),
898
+ configPath: resolvedConfigPath,
899
+ cwd: path.dirname(resolvedConfigPath),
900
+ logPath: resolvedLogPath,
901
+ pidPath: resolvedPidPath,
868
902
  replacement,
869
903
  legacyIncumbentPid,
870
- runtime
904
+ runtime,
905
+ timeoutMs
871
906
  })
872
907
  let startedStatus
873
908
 
@@ -885,8 +920,11 @@ async function ensureDaemonRunning({config, configPath, logPath, pidPath, runtim
885
920
  )
886
921
 
887
922
  if (!startedPid) throw new Error("Started Rollbridge daemon did not report its exact PID")
888
- await fsPromises.writeFile(resolvedPidPath, `${startedPid}\n`)
923
+ if (!config.ownerRecovery) await fsPromises.writeFile(resolvedPidPath, `${startedPid}\n`)
889
924
  return startedStatus
925
+ } catch (error) {
926
+ killDetachedProcessGroup(candidate)
927
+ throw error
890
928
  } finally {
891
929
  candidate.unref()
892
930
  }
@@ -917,13 +955,16 @@ async function daemonStatus(config) {
917
955
  * Starts the foreground daemon command as a detached child.
918
956
  * @param {object} args - Options.
919
957
  * @param {string} args.configPath - Config path.
958
+ * @param {string} args.cwd - Durable daemon working directory.
920
959
  * @param {string} args.logPath - Log file path.
960
+ * @param {string} args.pidPath - PID file path.
921
961
  * @param {boolean} args.replacement - Whether to run the incompatible replacement transaction.
922
962
  * @param {number | undefined} args.legacyIncumbentPid - Exact incumbent recorded before candidate spawn.
923
963
  * @param {import("./daemon-runtime.js").DaemonRuntimeIdentity} args.runtime - Prepared runtime.
964
+ * @param {number} args.timeoutMs - Guardian recovery startup timeout.
924
965
  * @returns {Promise<import("node:child_process").ChildProcess>} Referenced child after exact spawn completion.
925
966
  */
926
- async function startDaemonProcess({configPath, legacyIncumbentPid, logPath, replacement = false, runtime}) {
967
+ async function startDaemonProcess({configPath, cwd, legacyIncumbentPid, logPath, pidPath, replacement = false, runtime, timeoutMs}) {
927
968
  await fsPromises.mkdir(path.dirname(logPath), {recursive: true})
928
969
 
929
970
  const stdoutFd = fs.openSync(logPath, "a")
@@ -932,9 +973,13 @@ async function startDaemonProcess({configPath, legacyIncumbentPid, logPath, repl
932
973
  try {
933
974
  const child = spawn(process.execPath, [
934
975
  path.join(runtime.path, "bin", "rollbridge"), "daemon", "--config", configPath,
976
+ "--guardian-daemon-log-path", logPath,
977
+ "--guardian-daemon-pid-path", pidPath,
978
+ "--guardian-daemon-start-timeout-ms", String(timeoutMs),
935
979
  ...(replacement ? ["--replace-owner"] : []),
936
980
  ...(legacyIncumbentPid ? ["--legacy-incumbent-pid", String(legacyIncumbentPid)] : [])
937
981
  ], {
982
+ cwd,
938
983
  detached: true,
939
984
  env: {...process.env, ROLLBRIDGE_DAEMON_RUNTIME_MANIFEST: path.join(runtime.path, "runtime.json")},
940
985
  stdio: ["ignore", stdoutFd, stderrFd]
@@ -1026,11 +1071,10 @@ async function waitForDaemonStatus(config, timeoutMs, expected = {}) {
1026
1071
 
1027
1072
  if (status) {
1028
1073
  const statusPid = typeof status.daemonPid === "number" ? status.daemonPid : undefined
1029
- const candidateResolved = !expected.candidate || candidateExit || statusPid === expected.candidate.pid
1030
-
1031
1074
  if (expected.runtime && !compatibleDaemonRuntime(status, expected.runtime)) {
1032
1075
  if (!expected.configDigest) assertCompatibleDaemonRuntime(status, expected.runtime)
1033
- } else if (candidateResolved && (!expected.configDigest || (status.ownerRecovery && typeof status.ownerRecovery === "object" && !Array.isArray(status.ownerRecovery) && status.ownerRecovery.configDigest === expected.configDigest))) {
1076
+ } else if (!expected.configDigest || (status.ownerRecovery && typeof status.ownerRecovery === "object" && !Array.isArray(status.ownerRecovery) && status.ownerRecovery.configDigest === expected.configDigest && status.ownerRecovery.ready === true)) {
1077
+ if (expected.candidate && !candidateExit && statusPid !== expected.candidate.pid) killDetachedProcessGroup(expected.candidate)
1034
1078
  return status
1035
1079
  }
1036
1080
  }
@@ -1058,6 +1102,31 @@ async function waitForDaemonStatus(config, timeoutMs, expected = {}) {
1058
1102
  }
1059
1103
  }
1060
1104
 
1105
+ /**
1106
+ * Publishes the exact daemon PID before confirming completed startup to its guardian.
1107
+ * @param {RollbridgeDaemon} daemon - Started daemon.
1108
+ * @param {string | undefined} pidPath - Optional daemon PID file.
1109
+ */
1110
+ async function publishDaemonReadiness(daemon, pidPath) {
1111
+ if (daemon.guardian) {
1112
+ await daemon.markOwnerReady()
1113
+ } else if (pidPath) {
1114
+ await fsPromises.mkdir(path.dirname(pidPath), {recursive: true})
1115
+ await fsPromises.writeFile(pidPath, `${process.pid}\n`)
1116
+ }
1117
+ }
1118
+
1119
+ /** @param {import("node:child_process").ChildProcess} child - Exact detached process-group leader. */
1120
+ function killDetachedProcessGroup(child) {
1121
+ if (!child.pid) return
1122
+ try {
1123
+ process.kill(-child.pid, "SIGKILL")
1124
+ } catch (error) {
1125
+ if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return
1126
+ throw error
1127
+ }
1128
+ }
1129
+
1061
1130
  /**
1062
1131
  * @param {import("node:child_process").ChildProcess} candidate - Exact spawned daemon candidate.
1063
1132
  * @param {{code: number | null, signal: DaemonExitSignal}} exit - Exact exit status.
package/src/config.js CHANGED
@@ -67,8 +67,12 @@ export async function parseConfigFile(configPath) {
67
67
  */
68
68
  export async function loadConfig(configPath) {
69
69
  const {absolutePath, rawConfig} = await parseConfigFile(configPath)
70
+ const config = normalizeConfig(rawConfig, absolutePath)
71
+ const configDirectory = path.dirname(absolutePath)
70
72
 
71
- return normalizeConfig(rawConfig, absolutePath)
73
+ config.control.path = path.resolve(configDirectory, config.control.path)
74
+ if (config.statePath) config.statePath = path.resolve(configDirectory, config.statePath)
75
+ return config
72
76
  }
73
77
 
74
78
  /**
@@ -351,10 +355,10 @@ function normalizeLifecycle(value, key, issues) {
351
355
  /** @type {LifecycleConfig} */
352
356
  const lifecycle = {drainTimeoutMs: nonNegativeOrDefault(drainTimeoutMs, `${key}.drainTimeoutMs`, issues, 0, false)}
353
357
 
354
- if (value.activateCommand !== undefined) lifecycle.activateCommand = normalizeString(value.activateCommand, `${key}.activateCommand`, issues)
355
- if (value.quietCommand !== undefined) lifecycle.quietCommand = normalizeString(value.quietCommand, `${key}.quietCommand`, issues)
356
- if (value.drainCommand !== undefined) lifecycle.drainCommand = normalizeString(value.drainCommand, `${key}.drainCommand`, issues)
357
- if (value.stopCommand !== undefined) lifecycle.stopCommand = normalizeString(value.stopCommand, `${key}.stopCommand`, issues)
358
+ if (value.activateCommand !== undefined) lifecycle.activateCommand = normalizeString(value.activateCommand, `${key}.activateCommand`, issues, {nonEmpty: true})
359
+ if (value.quietCommand !== undefined) lifecycle.quietCommand = normalizeString(value.quietCommand, `${key}.quietCommand`, issues, {nonEmpty: true})
360
+ if (value.drainCommand !== undefined) lifecycle.drainCommand = normalizeString(value.drainCommand, `${key}.drainCommand`, issues, {nonEmpty: true})
361
+ if (value.stopCommand !== undefined) lifecycle.stopCommand = normalizeString(value.stopCommand, `${key}.stopCommand`, issues, {nonEmpty: true})
358
362
 
359
363
  if (lifecycle.drainCommand !== undefined && lifecycle.drainTimeoutMs <= 0) {
360
364
  issues.push({fix: `Set ${key}.drainTimeoutMs to a positive number to bound ${key}.drainCommand; with 0 the drain step is skipped and the command never runs.`, message: `${key}.drainCommand requires a positive ${key}.drainTimeoutMs`})
@@ -849,7 +853,7 @@ function normalizePortRange(value, key, issues) {
849
853
  * @param {JsonValue} value - Raw value.
850
854
  * @param {string} key - Config key.
851
855
  * @param {ConfigIssue[]} issues - Issue collector.
852
- * @param {{default?: string}} [options] - Options.
856
+ * @param {{default?: string, nonEmpty?: boolean}} [options] - Options.
853
857
  * @returns {string} Normalized string, or a placeholder when invalid.
854
858
  */
855
859
  function normalizeString(value, key, issues, options = {}) {
@@ -867,6 +871,10 @@ function normalizeString(value, key, issues, options = {}) {
867
871
  return options.default ?? ""
868
872
  }
869
873
 
874
+ if (options.nonEmpty && !value.trim()) {
875
+ issues.push({fix: `Set ${key} to a non-empty command string.`, message: `${key} must not be empty`})
876
+ }
877
+
870
878
  return value
871
879
  }
872
880