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.
Files changed (37) hide show
  1. package/AGENTS.md +14 -0
  2. package/README.md +69 -14
  3. package/TODO.md +5 -2
  4. package/changelog.d/20260828-atomic-owner-replacement.md +22 -0
  5. package/changelog.d/20260828-durable-owner-recovery.md +7 -0
  6. package/changelog.d/20260828-same-owner-jobs-generations.md +6 -0
  7. package/docs/cli.md +43 -21
  8. package/docs/config.md +71 -18
  9. package/docs/logging.md +8 -3
  10. package/docs/tensorbuzz-runbook.md +7 -6
  11. package/docs/troubleshooting.md +28 -12
  12. package/docs/velocious.md +11 -4
  13. package/docs/workers.md +8 -2
  14. package/examples/tensorbuzz.com.js +12 -4
  15. package/package.json +1 -1
  16. package/src/cli.js +209 -36
  17. package/src/config.js +8 -2
  18. package/src/control-client.js +118 -1
  19. package/src/daemon.js +939 -53
  20. package/src/guardian-client.js +434 -0
  21. package/src/managed-process.js +45 -15
  22. package/src/process-guardian.js +601 -0
  23. package/src/release-group.js +190 -15
  24. package/src/state-store.js +1 -1
  25. package/test/config-validation.test.js +22 -0
  26. package/test/fixtures/pre-split3-daemon-runner.js +30 -0
  27. package/test/fixtures/pre-split3-daemon.js +1336 -0
  28. package/test/fixtures/pre-split3-guardian-client.js +293 -0
  29. package/test/fixtures/pre-split3-process-guardian.js +292 -0
  30. package/test/fixtures/service-app.js +32 -2
  31. package/test/guardian-client.test.js +304 -0
  32. package/test/owner-recovery.test.js +950 -0
  33. package/test/owner-replacement.test.js +772 -0
  34. package/test/release-runtime-retention.test.js +1 -1
  35. package/test/rollbridge.test.js +178 -5
  36. package/test/shutdown-completion.test.js +1 -1
  37. package/test/state-store.test.js +12 -0
@@ -0,0 +1,1336 @@
1
+ // @ts-nocheck
2
+
3
+ import fs from "node:fs/promises"
4
+ import http from "node:http"
5
+ import net from "node:net"
6
+ import crypto from "node:crypto"
7
+ import {isDeepStrictEqual} from "node:util"
8
+ import httpProxy from "http-proxy"
9
+ import {loadConfig} from "../../src/config.js"
10
+ import EventLog from "../../src/event-log.js"
11
+ import GuardianClient from "./pre-split3-guardian-client.js"
12
+ import ReleaseGroup from "../../src/release-group.js"
13
+ import {clearState, isProcessAlive, liveProcesses, readState, writeState} from "../../src/state-store.js"
14
+ import {resolveGroupId, resolveUserId} from "../../src/system-ids.js"
15
+
16
+ const EVENT_HISTORY_LIMIT = 1000
17
+ const STATE_PERSIST_INTERVAL_MS = 5000
18
+
19
+ /**
20
+ * @typedef {import("./json.js").JsonValue} JsonValue
21
+ * @typedef {{releaseId?: string, releasePath: string, revision?: string}} DeployArgs
22
+ * @typedef {{attestation?: string, releaseId: string, releasePath: string, revision: string}} BootstrapIdentity
23
+ * @typedef {{id: string, process: import("./managed-process.js").ManagedProcessStatus}} ProcessStatus
24
+ * @typedef {{activeReleaseId: string | null, application: string, bootstrap: BootstrapIdentity | undefined, control: import("./config.js").ControlConfig, daemonRuntime: import("./daemon-runtime.js").DaemonRuntimeIdentity | undefined, ownerRecovery: {configDigest: string} | undefined, orphans: {id: string, pid: number, releaseId: string | null}[], proxy: {host: string, port: number | undefined, upstreamHost: string}, releaseReferences: {releaseId: string, releasePath: string}[], releases: import("./release-group.js").ReleaseStatus[], services: ProcessStatus[], singletons: ProcessStatus[]}} DaemonStatus
25
+ * @typedef {{configDigest: string, format: number, guardian: {pid?: number, socketPath: string, token: string}, reconnectGraceMs: number}} OwnerRecoveryMetadata
26
+ * @typedef {DaemonStatus & {recovery: OwnerRecoveryMetadata}} OwnerRecoverySnapshot
27
+ */
28
+
29
+ export default class RollbridgeDaemon {
30
+ /**
31
+ * @param {object} args - Options.
32
+ * @param {BootstrapIdentity} [args.bootstrap] - Immutable known-release foreground bootstrap identity.
33
+ * @param {import("./config.js").RollbridgeConfig} args.config - Rollbridge config.
34
+ * @param {string} [args.configPath] - Config file path to reload before deploys.
35
+ * @param {(message: string, data?: Record<string, JsonValue>) => void} [args.logger] - Logger.
36
+ * @param {import("./daemon-runtime.js").DaemonRuntimeIdentity} [args.runtime] - Immutable daemon runtime identity.
37
+ */
38
+ constructor({bootstrap, config, configPath, logger, runtime}) {
39
+ this.bootstrap = bootstrap ? {...bootstrap} : undefined
40
+ this.config = config
41
+ this.configPath = configPath
42
+ this.runtime = runtime
43
+ this.eventLog = new EventLog(EVENT_HISTORY_LIMIT)
44
+
45
+ const baseLogger = logger || ((message, data = {}) => console.log(JSON.stringify({at: new Date().toISOString(), data, message})))
46
+
47
+ // Every operational milestone is logged through this.logger, so recording here
48
+ // gives a structured event history for free (deploys, switches, stops, crashes,
49
+ // restarts, and failed commands).
50
+ this.logger = /** @type {(message: string, data?: Record<string, JsonValue>) => void} */ ((message, data = {}) => {
51
+ this.eventLog.record(message, data)
52
+ baseLogger(message, data)
53
+ })
54
+
55
+ this.releases = /** @type {Map<string, ReleaseGroup>} */ (new Map())
56
+ this.services = /** @type {Map<string, import("./managed-process.js").default>} */ (new Map())
57
+ this.servicePorts = /** @type {Record<string, number>} */ ({})
58
+ this.singletons = /** @type {Map<string, import("./managed-process.js").default>} */ (new Map())
59
+ this.activeRelease = /** @type {ReleaseGroup | undefined} */ (undefined)
60
+ this.proxy = httpProxy.createProxyServer({ws: true, xfwd: true})
61
+ this.proxyServer = /** @type {http.Server | undefined} */ (undefined)
62
+ this.controlServer = /** @type {net.Server | undefined} */ (undefined)
63
+ this.controlSocketOwned = false
64
+ this.controlSockets = /** @type {Set<net.Socket>} */ (new Set())
65
+ this.proxyPort = /** @type {number | undefined} */ (undefined)
66
+ this.stopping = false
67
+ this.statePath = config.statePath
68
+ this.persistTimer = /** @type {ReturnType<typeof setInterval> | undefined} */ (undefined)
69
+ this.persistenceEnabled = false
70
+ this.pendingWrite = /** @type {Promise<void> | undefined} */ (undefined)
71
+ this.stateCleanupEnabled = false
72
+ this.shutdownPromise = /** @type {Promise<void> | undefined} */ (undefined)
73
+ this.retirementPromise = /** @type {Promise<void> | undefined} */ (undefined)
74
+ this.controlClosePromise = /** @type {Promise<void> | undefined} */ (undefined)
75
+ this.startingReleases = /** @type {Set<ReleaseGroup>} */ (new Set())
76
+ this.guardian = /** @type {GuardianClient | undefined} */ (undefined)
77
+ this.guardianIdentity = /** @type {{pid?: number, socketPath: string, token: string} | undefined} */ (undefined)
78
+ // Still-alive managed processes left by a previous daemon (from statePath), captured at
79
+ // startup and surfaced in status(). The daemon cannot re-manage them, only report them.
80
+ this.orphans = /** @type {{id: string, pid: number, releaseId: string | null}[]} */ ([])
81
+
82
+ this.proxy.on("error", (error, req, res) => this.onProxyError(error, req, res))
83
+ }
84
+
85
+ /**
86
+ * Starts daemon listeners.
87
+ * @param {{exposeControl?: boolean, reportOrphans?: boolean}} [options] - Listener startup options.
88
+ * @returns {Promise<void>} Resolves when the requested listeners are ready.
89
+ */
90
+ async start({exposeControl = true, reportOrphans = true} = {}) {
91
+ if (this.config.ownerRecovery) await this.initializeOwnerRecovery()
92
+ else if (reportOrphans) await this.reportOrphans()
93
+ await this.startProxy()
94
+ if (exposeControl) await this.exposeControl()
95
+ }
96
+
97
+ /** Connects to the durable process guardian and reconstructs a matching persisted owner snapshot. */
98
+ async initializeOwnerRecovery() {
99
+ if (!this.statePath) throw new Error("ownerRecovery requires statePath")
100
+ const state = await readState(this.statePath)
101
+ const snapshot = state && typeof state === "object" && !Array.isArray(state) ? /** @type {OwnerRecoverySnapshot} */ (state) : undefined
102
+ const recovery = snapshot?.recovery
103
+ const configDigest = this.ownerRecoveryConfigDigest()
104
+
105
+ if (snapshot && !recovery) throw new Error(`Owner recovery state ${this.statePath} is missing durable guardian identity; refusing to overwrite it.`)
106
+ if (recovery && recovery.configDigest !== configDigest) throw new Error("Owner recovery config identity does not match the persisted owner; refusing cross-authority adoption.")
107
+ if (snapshot && ((this.runtime?.digest ?? null) !== (snapshot.daemonRuntime?.digest ?? null))) {
108
+ throw new Error("Owner recovery runtime identity does not match the persisted owner; use the exact same Rollbridge runtime.")
109
+ }
110
+
111
+ const guardianIdentity = recovery?.guardian || {
112
+ socketPath: `${this.statePath}.guardian.sock`,
113
+ token: crypto.randomBytes(32).toString("hex")
114
+ }
115
+ this.guardianIdentity = guardianIdentity
116
+ this.guardian = new GuardianClient(guardianIdentity)
117
+ if (recovery) await this.guardian.connect()
118
+ else {
119
+ await this.guardian.launch()
120
+ guardianIdentity.pid = this.guardian.pid
121
+ }
122
+ await this.guardian.claimOwner(this.config.ownerRecovery?.reconnectGraceMs ?? 30000)
123
+
124
+ if (snapshot) {
125
+ await this.restoreOwnerState(snapshot)
126
+ await this.guardian.reconcileInventory()
127
+ for (const release of this.releases.values()) {
128
+ if (release.state === "draining") void this.drainAndPrune(release, this.config)
129
+ }
130
+ }
131
+ else {
132
+ this.persistenceEnabled = true
133
+ await this.persistState({throwOnError: true})
134
+ }
135
+ this.stateCleanupEnabled = true
136
+ }
137
+
138
+ /** @returns {string} Stable identity for same-authority recovery. */
139
+ ownerRecoveryConfigDigest() {
140
+ return crypto.createHash("sha256").update(JSON.stringify(this.config)).digest("hex")
141
+ }
142
+
143
+ /** @param {OwnerRecoverySnapshot} snapshot - Validated persisted owner state. */
144
+ async restoreOwnerState(snapshot) {
145
+ if (!Array.isArray(snapshot.releases) || (snapshot.activeReleaseId !== null && typeof snapshot.activeReleaseId !== "string")) {
146
+ throw new Error("Owner recovery state is partial or corrupt; active release metadata is required.")
147
+ }
148
+ if (snapshot.activeReleaseId === null && snapshot.releases.length === 0) return
149
+ this.bootstrap = snapshot.bootstrap ? {...snapshot.bootstrap} : undefined
150
+
151
+ for (const releaseStatus of snapshot.releases) {
152
+ if (releaseStatus.state !== "active" && releaseStatus.state !== "draining") continue
153
+ const release = new ReleaseGroup({
154
+ config: this.config,
155
+ logger: this.logger,
156
+ processFactory: (key, definition) => this.guardianProcess(key, definition),
157
+ releaseId: releaseStatus.releaseId,
158
+ releasePath: releaseStatus.releasePath,
159
+ revision: releaseStatus.revision,
160
+ servicePorts: this.servicePorts,
161
+ shouldStart: () => !this.stopping
162
+ })
163
+
164
+ await release.restore(releaseStatus)
165
+ this.releases.set(release.releaseId, release)
166
+ if (release.releaseId === snapshot.activeReleaseId) this.activeRelease = release
167
+ }
168
+
169
+ if (snapshot.activeReleaseId !== null && !this.activeRelease) throw new Error(`Owner recovery state does not contain active release ${snapshot.activeReleaseId}.`)
170
+ const definitionRelease = this.activeRelease || [...this.releases.values()].at(-1)
171
+ if (!definitionRelease) throw new Error("Owner recovery state has no release definition for owned processes.")
172
+ if (!this.activeRelease && snapshot.singletons.length > 0) throw new Error("Owner recovery state has release-owned singletons without an active release identity.")
173
+ for (const serviceStatus of snapshot.services) {
174
+ const processConfig = this.config.processes.find((candidate) => candidate.id === serviceStatus.id && candidate.policy === "service" && candidate.deployStrategy !== "handoff")
175
+
176
+ if (!processConfig) throw new Error(`Owner recovery state contains unknown service ${serviceStatus.id}.`)
177
+ const service = definitionRelease.buildProcess(processConfig, {guardianKey: `service:${serviceStatus.id}`, shouldRestart: () => !this.stopping})
178
+
179
+ await this.recoverGuardianProcess(service)
180
+ this.services.set(serviceStatus.id, service)
181
+ if (definitionRelease.ports[serviceStatus.id] !== undefined) this.servicePorts[serviceStatus.id] = definitionRelease.ports[serviceStatus.id]
182
+ }
183
+ for (const singletonStatus of snapshot.singletons) {
184
+ const processConfig = this.config.processes.find((candidate) => candidate.id === singletonStatus.id && candidate.policy === "singleton")
185
+
186
+ if (!processConfig) throw new Error(`Owner recovery state contains unknown singleton ${singletonStatus.id}.`)
187
+ const singleton = definitionRelease.buildProcess(processConfig, {guardianKey: `singleton:${definitionRelease.releaseId}:${singletonStatus.id}`})
188
+
189
+ await this.recoverGuardianProcess(singleton)
190
+ this.singletons.set(singletonStatus.id, singleton)
191
+ }
192
+ this.logger("owner state recovered", {activeReleaseId: this.activeRelease?.releaseId ?? null, releases: this.releases.size})
193
+ }
194
+
195
+ /**
196
+ * @param {string} key - Guardian key.
197
+ * @param {Parameters<GuardianClient["process"]>[1]} definition - Process definition.
198
+ * @returns {import("./managed-process.js").default} Guardian-backed managed process.
199
+ */
200
+ guardianProcess(key, definition) {
201
+ if (!this.guardian) throw new Error("Process guardian is not initialized")
202
+ return this.guardian.process(key, definition)
203
+ }
204
+
205
+ /** @param {import("./managed-process.js").default} processInstance - Guardian-backed process. */
206
+ async recoverGuardianProcess(processInstance) {
207
+ if (!("recover" in processInstance) || typeof processInstance.recover !== "function") throw new Error(`Managed process ${processInstance.id} is not guardian-backed`)
208
+ await processInstance.recover()
209
+ }
210
+
211
+ /** Releases only resources created by a fenced startup loser. */
212
+ async abandonOwnerRecoveryAttempt() {
213
+ this.guardian?.disconnect()
214
+ await this.closeServer(this.proxyServer)
215
+ }
216
+
217
+ /** @returns {Promise<void>} Exposes control commands and begins periodic state persistence. */
218
+ async exposeControl() {
219
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
220
+
221
+ await this.startControlServer()
222
+
223
+ if (this.stopping) {
224
+ await this.closeServer(this.controlServer)
225
+ await fs.rm(this.config.control.path, {force: true})
226
+ throw new Error("Rollbridge is shutting down")
227
+ }
228
+
229
+ this.startStatePersistence()
230
+ }
231
+
232
+ /** @returns {Promise<void>} Starts the stable local proxy. */
233
+ async startProxy() {
234
+ const server = http.createServer((request, response) => this.proxyHttp(request, response))
235
+
236
+ server.on("upgrade", (request, socket, head) => this.proxyWebSocket(request, socket, head))
237
+ this.proxyServer = server
238
+
239
+ await new Promise((resolve, reject) => {
240
+ server.once("error", reject)
241
+ server.listen(this.config.proxy.port, this.config.proxy.host, () => {
242
+ const address = server.address()
243
+ this.proxyPort = address && typeof address === "object" ? address.port : this.config.proxy.port
244
+ this.logger("proxy listening", {host: this.config.proxy.host, port: this.proxyPort})
245
+ resolve(undefined)
246
+ })
247
+ })
248
+ }
249
+
250
+ /** @returns {Promise<void>} Starts the control socket. */
251
+ async startControlServer() {
252
+ const server = net.createServer((socket) => this.handleControlSocket(socket))
253
+
254
+ this.controlServer = server
255
+ await this.prepareControlSocketPath()
256
+
257
+ await new Promise((resolve, reject) => {
258
+ server.once("error", reject)
259
+ server.listen(this.config.control.path, () => {
260
+ this.controlSocketOwned = true
261
+ this.logger("control socket listening", {path: this.config.control.path})
262
+ resolve(undefined)
263
+ })
264
+ })
265
+
266
+ if (this.config.control.mode !== undefined) {
267
+ await fs.chmod(this.config.control.path, this.config.control.mode)
268
+ }
269
+
270
+ await this.applyControlSocketOwnership()
271
+ }
272
+
273
+ /**
274
+ * Applies control.owner/control.group to the bound socket via chown, resolving names to ids.
275
+ * @returns {Promise<void>} Resolves once ownership is applied (no-op when neither is set).
276
+ */
277
+ async applyControlSocketOwnership() {
278
+ const {group, owner, path: socketPath} = this.config.control
279
+
280
+ if (owner === undefined && group === undefined) return
281
+
282
+ // -1 leaves the uid/gid unchanged (POSIX chown semantics).
283
+ const uid = owner === undefined ? -1 : resolveUserId(owner)
284
+ const gid = group === undefined ? -1 : resolveGroupId(group)
285
+
286
+ try {
287
+ await fs.chown(socketPath, uid, gid)
288
+ } catch (error) {
289
+ const reason = error instanceof Error ? error.message : String(error)
290
+
291
+ throw new Error(`Could not set control socket owner/group on ${socketPath}: ${reason}. Run the daemon as a user allowed to chown it (for example root, or a member of the target group).`, {cause: error})
292
+ }
293
+ }
294
+
295
+ /** @returns {Promise<void>} Removes a stale Unix socket before binding, or fails clearly when a daemon is alive. */
296
+ async prepareControlSocketPath() {
297
+ const existing = await inspectControlSocket(this.config.control.path)
298
+
299
+ if (existing.alive) {
300
+ throw new Error(controlSocketBusyMessage(this.config.control.path, existing))
301
+ }
302
+
303
+ try {
304
+ await fs.rm(this.config.control.path, {force: true})
305
+ } catch (error) {
306
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return
307
+ throw error
308
+ }
309
+ }
310
+
311
+ /**
312
+ * @param {http.IncomingMessage} request - Client request.
313
+ * @param {http.ServerResponse} response - Client response.
314
+ * @returns {void}
315
+ */
316
+ proxyHttp(request, response) {
317
+ const release = this.activeRelease
318
+
319
+ if (!release) {
320
+ response.writeHead(503, {"Content-Type": "text/plain; charset=utf-8"})
321
+ response.end("No active release\n")
322
+ return
323
+ }
324
+
325
+ const {target} = release.proxyTarget()
326
+ const releaseConnection = release.retainConnection("http")
327
+ let released = false
328
+ const done = () => {
329
+ if (released) return
330
+
331
+ released = true
332
+ releaseConnection()
333
+ }
334
+
335
+ response.once("finish", done)
336
+ response.once("close", done)
337
+ this.proxy.web(request, response, {target})
338
+ }
339
+
340
+ /**
341
+ * @param {http.IncomingMessage} request - Client request.
342
+ * @param {import("node:stream").Duplex} socket - Client socket.
343
+ * @param {Buffer} head - Upgrade head.
344
+ * @returns {void}
345
+ */
346
+ proxyWebSocket(request, socket, head) {
347
+ const release = this.activeRelease
348
+
349
+ if (!release) {
350
+ socket.end("HTTP/1.1 503 Service Unavailable\r\n\r\n")
351
+ return
352
+ }
353
+
354
+ const {target} = release.proxyTarget()
355
+ const releaseConnection = release.retainConnection("websocket")
356
+ socket.once("close", releaseConnection)
357
+ this.proxy.ws(request, socket, head, {target})
358
+ }
359
+
360
+ /**
361
+ * @param {Error} error - Proxy error.
362
+ * @param {http.IncomingMessage} _request - Client request.
363
+ * @param {http.ServerResponse | import("node:net").Socket} response - Response or socket.
364
+ * @returns {void}
365
+ */
366
+ onProxyError(error, _request, response) {
367
+ this.logger("proxy error", {error: error.message})
368
+
369
+ if ("writeHead" in response && !response.headersSent) {
370
+ response.writeHead(502, {"Content-Type": "text/plain; charset=utf-8"})
371
+ response.end("Bad gateway\n")
372
+ return
373
+ }
374
+
375
+ if ("destroy" in response) {
376
+ response.destroy()
377
+ }
378
+ }
379
+
380
+ /**
381
+ * @param {import("node:net").Socket} socket - Control socket.
382
+ * @returns {void}
383
+ */
384
+ handleControlSocket(socket) {
385
+ this.controlSockets.add(socket)
386
+ socket.setEncoding("utf8")
387
+ let buffer = ""
388
+
389
+ socket.once("close", () => this.controlSockets.delete(socket))
390
+ socket.on("error", (error) => {
391
+ const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : null
392
+
393
+ this.logger("control connection error", {code, error: error.message})
394
+ })
395
+
396
+ socket.on("data", (chunk) => {
397
+ buffer += chunk
398
+ let newlineIndex = buffer.indexOf("\n")
399
+
400
+ while (newlineIndex >= 0) {
401
+ const line = buffer.slice(0, newlineIndex)
402
+ buffer = buffer.slice(newlineIndex + 1)
403
+ this.handleControlLine(line, socket)
404
+ newlineIndex = buffer.indexOf("\n")
405
+ }
406
+ })
407
+ }
408
+
409
+ /**
410
+ * @param {string} line - JSON command line.
411
+ * @param {import("node:net").Socket} socket - Control socket.
412
+ * @returns {void}
413
+ */
414
+ handleControlLine(line, socket) {
415
+ const closesConnection = isShutdownControlLine(line)
416
+ const respond = (/** @type {Record<string, JsonValue>} */ response) => {
417
+ const payload = `${JSON.stringify(response)}\n`
418
+
419
+ if (closesConnection) {
420
+ socket.end(payload, () => socket.destroy())
421
+ } else if (!socket.destroyed) {
422
+ socket.write(payload)
423
+ }
424
+ }
425
+
426
+ this.executeControlLine(line, socket)
427
+ .then((response) => respond({status: "success", ...response}))
428
+ .catch((error) => {
429
+ this.logger("command failed", {error: error instanceof Error ? error.message : String(error)})
430
+ respond({
431
+ error: error instanceof Error ? error.message : String(error),
432
+ status: "error"
433
+ })
434
+ })
435
+ }
436
+
437
+ /**
438
+ * @param {string} line - JSON command line.
439
+ * @param {net.Socket} [controlSocket] - Requesting control connection, used only for shutdown completion.
440
+ * @returns {Promise<Record<string, JsonValue>>} Command response.
441
+ */
442
+ async executeControlLine(line, controlSocket) {
443
+ const command = JSON.parse(line)
444
+
445
+ if (!command || typeof command !== "object") {
446
+ throw new Error("Control command must be an object")
447
+ }
448
+
449
+ const data = /** @type {Record<string, JsonValue>} */ (command)
450
+ const commandName = data.command
451
+
452
+ if (commandName === "deploy") {
453
+ return await this.deploy({
454
+ releaseId: stringOrUndefined(data.releaseId),
455
+ releasePath: requiredString(data.releasePath, "releasePath"),
456
+ revision: stringOrUndefined(data.revision)
457
+ })
458
+ }
459
+
460
+ if (commandName === "status") {
461
+ return this.status()
462
+ }
463
+
464
+ if (commandName === "events") {
465
+ return {events: this.eventLog.recent(typeof data.limit === "number" ? data.limit : undefined)}
466
+ }
467
+
468
+ if (commandName === "stop") {
469
+ await this.stopRelease(stringOrUndefined(data.releaseId))
470
+ return this.status()
471
+ }
472
+
473
+ if (commandName === "restart") {
474
+ return await this.restartProcesses({
475
+ policy: stringOrUndefined(data.policy),
476
+ processId: stringOrUndefined(data.processId)
477
+ })
478
+ }
479
+
480
+ if (commandName === "rollback") {
481
+ return await this.rollback({releaseId: stringOrUndefined(data.releaseId)})
482
+ }
483
+
484
+ if (commandName === "shutdown") {
485
+ // Stop accepting new control connections before cleanup, but keep this requesting
486
+ // connection open as the completion channel. Waiting for all control connections here
487
+ // would deadlock: server.close() includes the socket awaiting this response.
488
+ await this.shutdown({completionSocket: controlSocket, waitForControlConnections: false})
489
+
490
+ return {message: "shutdown"}
491
+ }
492
+
493
+ if (commandName === "retire-owner") {
494
+ const attestation = requiredString(data.attestation, "attestation")
495
+ if (!/^sha256:[a-f0-9]{64}$/.test(attestation)) throw new Error("Owner retirement attestation must use the canonical sha256:<64 lowercase hex> format")
496
+ await this.retireOwner({attestation, completionSocket: controlSocket})
497
+ return {message: "owner retired"}
498
+ }
499
+
500
+ throw new Error(`Unknown command: ${String(commandName)}`)
501
+ }
502
+
503
+ /**
504
+ * Starts a new release, switches traffic, and drains the previous release.
505
+ * @param {DeployArgs} args - Deploy args.
506
+ * @returns {Promise<Record<string, JsonValue>>} Deploy result.
507
+ */
508
+ async deploy({releaseId, releasePath, revision}) {
509
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
510
+
511
+ const nextConfig = this.configPath ? await loadConfig(this.configPath) : this.config
512
+
513
+ this.assertReloadCompatible(nextConfig)
514
+
515
+ const newReleaseId = releaseId || revision || new Date().toISOString().replace(/[^0-9]/g, "")
516
+ const release = new ReleaseGroup({
517
+ config: nextConfig,
518
+ logger: this.logger,
519
+ ...(this.guardian ? {processFactory: (key, definition) => this.guardianProcess(key, definition)} : {}),
520
+ releaseId: newReleaseId,
521
+ releasePath,
522
+ revision,
523
+ servicePorts: this.servicePorts,
524
+ shouldStart: () => !this.stopping
525
+ })
526
+
527
+ this.logger("deploy starting", {releaseId: newReleaseId, releasePath, revision})
528
+ const startedServices = /** @type {string[]} */ ([])
529
+
530
+ this.startingReleases.add(release)
531
+
532
+ try {
533
+ await this.ensureServices(release, startedServices)
534
+ await release.start()
535
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
536
+ } catch (error) {
537
+ this.logger("deploy failed", {error: error instanceof Error ? error.message : String(error), releaseId: newReleaseId})
538
+ await release.stop()
539
+ await this.stopStartedServices(startedServices)
540
+ throw error
541
+ } finally {
542
+ this.startingReleases.delete(release)
543
+ }
544
+
545
+ const previousRelease = this.activeRelease
546
+
547
+ this.config = nextConfig
548
+ this.releases.set(release.releaseId, release)
549
+ release.activate()
550
+ this.activeRelease = release
551
+ this.logger("traffic switched", {previousReleaseId: previousRelease ? previousRelease.releaseId : null, releaseId: release.releaseId})
552
+
553
+ this.refreshServiceDefinitions(release)
554
+ let retirementFailure
555
+
556
+ if (previousRelease) {
557
+ try {
558
+ await previousRelease.beginRetirement(nextConfig)
559
+ void this.drainAndPrune(previousRelease, nextConfig)
560
+ } catch (error) {
561
+ retirementFailure = previousRelease.retirementError ?? (error instanceof Error ? error.message : String(error))
562
+ this.logger("release retirement quiescence failed", {error: retirementFailure, releaseId: previousRelease.releaseId})
563
+ }
564
+ }
565
+
566
+ await this.replaceSingletons(release)
567
+
568
+ await this.persistState()
569
+
570
+ return {
571
+ activeReleaseId: release.releaseId,
572
+ previousReleaseId: previousRelease ? previousRelease.releaseId : null,
573
+ ...(retirementFailure && previousRelease ? {retirement: {error: retirementFailure, releaseId: previousRelease.releaseId, status: "quiescence_failed"}} : {})
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Rejects config changes that require rebinding daemon-owned resources or changing process topology.
579
+ * @param {import("./config.js").RollbridgeConfig} nextConfig - Freshly loaded config.
580
+ * @returns {void}
581
+ */
582
+ assertReloadCompatible(nextConfig) {
583
+ /** @type {string[]} */
584
+ const restartRequired = []
585
+
586
+ if (nextConfig.application !== this.config.application) restartRequired.push("application")
587
+ if (!isDeepStrictEqual(nextConfig.control, this.config.control)) restartRequired.push("control")
588
+ if (nextConfig.statePath !== this.config.statePath) restartRequired.push("statePath")
589
+ if (!isDeepStrictEqual(nextConfig.ownerRecovery, this.config.ownerRecovery)) restartRequired.push("ownerRecovery")
590
+
591
+ if (nextConfig.proxy.host !== this.config.proxy.host) restartRequired.push("proxy.host")
592
+ if (nextConfig.proxy.port !== this.config.proxy.port) restartRequired.push("proxy.port")
593
+ if (nextConfig.proxy.upstreamHost !== this.config.proxy.upstreamHost) restartRequired.push("proxy.upstreamHost")
594
+
595
+ if (nextConfig.processes.length !== this.config.processes.length) {
596
+ restartRequired.push("processes")
597
+ } else {
598
+ for (const processConfig of this.config.processes) {
599
+ const nextProcessConfig = nextConfig.processes.find((candidate) => candidate.id === processConfig.id)
600
+
601
+ if (!nextProcessConfig ||
602
+ nextProcessConfig.policy !== processConfig.policy ||
603
+ nextProcessConfig.deployStrategy !== processConfig.deployStrategy ||
604
+ nextProcessConfig.replicas !== processConfig.replicas ||
605
+ !isDeepStrictEqual(nextProcessConfig.port, processConfig.port)) {
606
+ restartRequired.push("processes")
607
+ break
608
+ }
609
+ }
610
+ }
611
+
612
+ if (restartRequired.length > 0) {
613
+ throw new Error(`Config changes to ${restartRequired.join(", ")} cannot be applied live; restart the Rollbridge daemon before deploying.`)
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Rolls back to a previously-active release by re-running the deploy flow on its
619
+ * retained metadata: it re-starts the target release, health-checks it, switches
620
+ * traffic, replaces singletons, and drains the current release — just like a deploy,
621
+ * so a failed rollback leaves the current release active.
622
+ * @param {{releaseId?: string}} [args] - Target release id; defaults to the most recently retired release.
623
+ * @returns {Promise<Record<string, JsonValue>>} The rollback result.
624
+ */
625
+ async rollback({releaseId} = {}) {
626
+ const target = releaseId ? this.releases.get(releaseId) : this.previousRelease()
627
+
628
+ if (!target) {
629
+ throw new Error(releaseId ? `No retained release "${releaseId}" to roll back to.` : "No previous release to roll back to.")
630
+ }
631
+
632
+ if (target === this.activeRelease) {
633
+ throw new Error(`Release "${target.releaseId}" is already active.`)
634
+ }
635
+
636
+ // The target may still be draining a prior deploy (live processes). Stop it before the
637
+ // deploy below re-uses its id in this.releases, otherwise the still-running instance
638
+ // would be dropped from status/pruning/shutdown and could be orphaned.
639
+ if (target.state !== "stopped" && target.state !== "failed") {
640
+ await target.stop()
641
+ }
642
+
643
+ this.logger("rollback starting", {releaseId: target.releaseId, releasePath: target.releasePath})
644
+
645
+ return await this.deploy({releaseId: target.releaseId, releasePath: target.releasePath, revision: target.revision})
646
+ }
647
+
648
+ /**
649
+ * @returns {ReleaseGroup | undefined} The most recently active release other than the current one, if any.
650
+ */
651
+ previousRelease() {
652
+ /** @type {ReleaseGroup | undefined} */
653
+ let previous
654
+
655
+ for (const release of this.releases.values()) {
656
+ if (release === this.activeRelease || !release.activatedAt) continue
657
+ if (!previous || Date.parse(release.activatedAt) >= Date.parse(/** @type {string} */ (previous.activatedAt))) previous = release
658
+ }
659
+
660
+ return previous
661
+ }
662
+
663
+ /**
664
+ * Starts missing daemon-wide services before release-owned processes need them.
665
+ * @param {ReleaseGroup} release - Release providing templates and ports.
666
+ * @param {string[]} startedServices - Service ids started by this deploy.
667
+ * @returns {Promise<void>} Resolves when missing services are running.
668
+ */
669
+ async ensureServices(release, startedServices) {
670
+ await release.allocatePorts()
671
+
672
+ for (const processConfig of release.config.processes) {
673
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
674
+ if (processConfig.policy !== "service" || processConfig.deployStrategy === "handoff") continue
675
+ if (this.services.has(processConfig.id)) continue
676
+
677
+ const service = release.buildProcess(processConfig, {guardianKey: `service:${processConfig.id}`, shouldRestart: () => !this.stopping})
678
+
679
+ this.services.set(processConfig.id, service)
680
+
681
+ if (release.ports[processConfig.id] !== undefined) {
682
+ this.servicePorts[processConfig.id] = release.ports[processConfig.id]
683
+ }
684
+
685
+ try {
686
+ await service.start("deploy")
687
+ startedServices.push(processConfig.id)
688
+ } catch (error) {
689
+ this.services.delete(processConfig.id)
690
+ delete this.servicePorts[processConfig.id]
691
+ throw error
692
+ }
693
+
694
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Stops services that were started for a failed deploy.
700
+ * @param {string[]} startedServices - Service ids started by the failed deploy.
701
+ * @returns {Promise<void>} Resolves when cleanup finishes.
702
+ */
703
+ async stopStartedServices(startedServices) {
704
+ for (const serviceId of startedServices) {
705
+ const service = this.services.get(serviceId)
706
+
707
+ if (!service) continue
708
+
709
+ await service.stop()
710
+ this.services.delete(serviceId)
711
+ delete this.servicePorts[serviceId]
712
+ }
713
+ }
714
+
715
+ /**
716
+ * Updates daemon-wide service restart templates after a successful deploy.
717
+ * @param {ReleaseGroup} release - Active release.
718
+ * @returns {void}
719
+ */
720
+ refreshServiceDefinitions(release) {
721
+ for (const processConfig of this.config.processes) {
722
+ if (processConfig.policy !== "service") continue
723
+
724
+ const service = this.services.get(processConfig.id)
725
+
726
+ if (!service) continue
727
+
728
+ const nextDefinition = release.buildProcess(processConfig, {shouldRestart: () => !this.stopping})
729
+
730
+ service.updateDefinition(nextDefinition)
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Restarts singleton processes for the new release without overlapping old singleton processes.
736
+ * @param {ReleaseGroup} release - Active release.
737
+ * @returns {Promise<void>} Resolves when singletons have been replaced.
738
+ */
739
+ async replaceSingletons(release) {
740
+ for (const processConfig of this.config.processes) {
741
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
742
+ if (processConfig.policy !== "singleton") continue
743
+
744
+ const previous = this.singletons.get(processConfig.id)
745
+
746
+ if (previous) {
747
+ await previous.stop()
748
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
749
+ }
750
+
751
+ const singleton = release.buildProcess(processConfig, {guardianKey: `singleton:${release.releaseId}:${processConfig.id}`})
752
+
753
+ this.singletons.set(processConfig.id, singleton)
754
+ await singleton.start("deploy")
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Restarts non-proxied processes selected by id or policy, or all of them: running
760
+ * processes are bounced (stop then start) and crashed or stopped ones are revived,
761
+ * matching the conventional meaning of "restart".
762
+ *
763
+ * The proxied process is never restarted in place (that would drop traffic); use a
764
+ * deploy for a zero-downtime replacement.
765
+ * @param {{policy?: string, processId?: string}} selector - Restart selector; restarts all non-proxied processes when both are omitted.
766
+ * @returns {Promise<Record<string, JsonValue>>} The ids that were restarted.
767
+ */
768
+ async restartProcesses({policy, processId} = {}) {
769
+ if (policy === "proxied" || (processId !== undefined && this.isProxiedId(processId))) {
770
+ throw new Error('The proxied process cannot be restarted in place; use "rollbridge deploy" for a zero-downtime replacement.')
771
+ }
772
+
773
+ const targets = this.collectRestartTargets({policy, processId})
774
+
775
+ if (processId !== undefined && targets.length === 0) {
776
+ throw new Error(`No managed process with id "${processId}" to restart.`)
777
+ }
778
+
779
+ for (const target of targets) {
780
+ this.logger("process restart requested", {processId: target.id})
781
+ await target.process.stop()
782
+ await target.process.start("manual")
783
+ }
784
+
785
+ return {restarted: targets.map((target) => target.id)}
786
+ }
787
+
788
+ /**
789
+ * @param {{policy?: string, processId?: string}} selector - Restart selector.
790
+ * @returns {{id: string, process: import("./managed-process.js").default}[]} Running non-proxied processes matching the selector.
791
+ */
792
+ collectRestartTargets({policy, processId}) {
793
+ const targets = /** @type {{id: string, process: import("./managed-process.js").default}[]} */ ([])
794
+
795
+ for (const processConfig of this.config.processes) {
796
+ if (processConfig.policy === "proxied") continue
797
+ if (policy !== undefined && processConfig.policy !== policy) continue
798
+
799
+ for (const instance of this.runningInstances(processConfig)) {
800
+ // A processId selector matches the base config id (all replicas) or one replica's id.
801
+ if (processId !== undefined && processId !== processConfig.id && processId !== instance.id) continue
802
+
803
+ targets.push(instance)
804
+ }
805
+ }
806
+
807
+ return targets
808
+ }
809
+
810
+ /**
811
+ * @param {import("./config.js").ProcessConfig} processConfig - Process definition.
812
+ * @returns {{id: string, process: import("./managed-process.js").default}[]} Running instances (replicas) for this config.
813
+ */
814
+ runningInstances(processConfig) {
815
+ if (processConfig.policy === "service") {
816
+ const service = this.services.get(processConfig.id)
817
+
818
+ return service ? [{id: processConfig.id, process: service}] : []
819
+ }
820
+
821
+ if (processConfig.policy === "singleton") {
822
+ const singleton = this.singletons.get(processConfig.id)
823
+
824
+ return singleton ? [{id: processConfig.id, process: singleton}] : []
825
+ }
826
+
827
+ return this.activeRelease ? this.activeRelease.getProcesses(processConfig.id) : []
828
+ }
829
+
830
+ /**
831
+ * @param {string} id - Process id.
832
+ * @returns {boolean} True when the id belongs to the proxied process.
833
+ */
834
+ isProxiedId(id) {
835
+ return this.config.processes.some((processConfig) => processConfig.policy === "proxied" && processConfig.id === id)
836
+ }
837
+
838
+ /**
839
+ * @param {string | undefined} releaseId - Release id, or active release when omitted.
840
+ * @returns {Promise<void>} Resolves when stopped.
841
+ */
842
+ async stopRelease(releaseId) {
843
+ const release = releaseId ? this.releases.get(releaseId) : this.activeRelease
844
+
845
+ if (!release) throw new Error(`Release not found: ${releaseId || "active"}`)
846
+ if (release === this.activeRelease) this.activeRelease = undefined
847
+
848
+ await release.stop()
849
+ this.logger("release stopped", {releaseId: release.releaseId})
850
+ this.pruneStoppedReleases()
851
+ this.persistState()
852
+ }
853
+
854
+ /**
855
+ * Drains and stops a retired release in the background, then prunes stopped releases.
856
+ * @param {ReleaseGroup} release - Release to drain and stop.
857
+ * @param {import("./config.js").RollbridgeConfig} [config] - Refreshed config governing retirement.
858
+ * @returns {Promise<void>} Resolves once drained, stopped, and pruned.
859
+ */
860
+ async drainAndPrune(release, config = this.config) {
861
+ try {
862
+ await release.drainAndStop(config.proxy.drainTimeoutMs, config)
863
+ this.logger("release drained", {releaseId: release.releaseId})
864
+ } catch (error) {
865
+ this.logger("release drain failed", {error: error instanceof Error ? error.message : String(error), releaseId: release.releaseId})
866
+ } finally {
867
+ this.pruneStoppedReleases()
868
+ this.persistState()
869
+ }
870
+ }
871
+
872
+ /** @returns {void} Removes stopped releases beyond the retention policy. */
873
+ pruneStoppedReleases() {
874
+ const statuses = [...this.releases.values()].map((release) => release.status())
875
+
876
+ for (const releaseId of releasesToPrune(statuses, this.config.releaseRetention, Date.now())) {
877
+ this.releases.delete(releaseId)
878
+ }
879
+ }
880
+
881
+ /** @returns {void} Starts periodic state persistence when statePath is configured. */
882
+ startStatePersistence() {
883
+ if (!this.statePath) return
884
+
885
+ this.stateCleanupEnabled = true
886
+ this.persistenceEnabled = true
887
+ this.persistState()
888
+ this.persistTimer = setInterval(() => this.persistState(), STATE_PERSIST_INTERVAL_MS)
889
+ this.persistTimer.unref?.()
890
+ }
891
+
892
+ /**
893
+ * Persists a state snapshot (status plus recent events) to statePath, atomically and
894
+ * fire-and-forget unless the caller awaits the returned write. A failed write is logged.
895
+ * @param {{throwOnError?: boolean}} [options] - Whether a write failure rejects the returned promise.
896
+ * @returns {Promise<void> | undefined} The queued write, or undefined when persistence is disabled.
897
+ */
898
+ persistState({throwOnError = false} = {}) {
899
+ if (!this.statePath || !this.persistenceEnabled || this.stopping) return
900
+
901
+ const statePath = this.statePath
902
+ const status = /** @type {Record<string, JsonValue>} */ (secretSafeStateValue(this.status()))
903
+ const events = secretSafeStateValue(this.eventLog.recent())
904
+ const snapshot = {
905
+ ...status,
906
+ events,
907
+ persistedAt: new Date().toISOString(),
908
+ ...(this.guardianIdentity ? {recovery: {
909
+ configDigest: this.ownerRecoveryConfigDigest(),
910
+ format: 1,
911
+ guardian: this.guardianIdentity,
912
+ reconnectGraceMs: this.config.ownerRecovery?.reconnectGraceMs
913
+ }} : {})
914
+ }
915
+
916
+ // Serialize writes (and track the tail) so shutdown can wait for an in-flight write before
917
+ // clearing the file — otherwise a write started before shutdown could recreate it afterward.
918
+ this.pendingWrite = Promise.resolve(this.pendingWrite)
919
+ .catch(() => {})
920
+ .then(() => writeState(statePath, snapshot))
921
+ .catch((error) => {
922
+ this.logger("state persist failed", {error: error instanceof Error ? error.message : String(error)})
923
+ if (throwOnError) throw error
924
+ })
925
+
926
+ return this.pendingWrite
927
+ }
928
+
929
+ /**
930
+ * On startup, reads any state left by a previous daemon and reports managed processes whose
931
+ * pids are still alive — likely orphans from a daemon that did not shut down cleanly. This is
932
+ * advisory (Rollbridge cannot re-adopt detached children); the operator stops the leftovers.
933
+ * A recycled pid could be a false positive, so reports are a prompt to investigate.
934
+ * @returns {Promise<void>} Resolves once orphans are reported.
935
+ */
936
+ async reportOrphans() {
937
+ if (!this.statePath) return
938
+
939
+ this.stateCleanupEnabled = true
940
+ const orphans = liveProcesses(await readState(this.statePath))
941
+
942
+ // Keep them for status() so `rollbridge status` reflects still-running children after a
943
+ // restart, not just the startup log below.
944
+ this.orphans = orphans
945
+
946
+ for (const orphan of orphans) {
947
+ this.logger("orphaned managed process detected", {pid: orphan.pid, processId: orphan.id, releaseId: orphan.releaseId})
948
+ }
949
+
950
+ if (orphans.length > 0) {
951
+ this.logger("orphaned processes from a previous daemon", {count: orphans.length, hint: "a previous daemon did not shut down cleanly; verify these pids and stop any leftovers"})
952
+ }
953
+ }
954
+
955
+ /**
956
+ * Stops proxy, control socket, and child processes.
957
+ * @param {{completionSocket?: net.Socket, waitForControlConnections?: boolean}} [options] - Shutdown connection behavior.
958
+ * @returns {Promise<void>} Resolves when owned resources are stopped (and, by default, control connections close).
959
+ */
960
+ async shutdown({completionSocket, waitForControlConnections = true} = {}) {
961
+ if (!this.shutdownPromise) this.shutdownPromise = this.performShutdown(completionSocket)
962
+
963
+ await this.shutdownPromise
964
+ if (waitForControlConnections && this.controlClosePromise) await this.controlClosePromise
965
+ }
966
+
967
+ /**
968
+ * Relinquishes stable listeners promptly while retaining draining children under
969
+ * this daemon until their normal stop contract completes.
970
+ * @param {{attestation: string, completionSocket?: net.Socket}} options - Attested handoff request.
971
+ * @returns {Promise<void>} Resolves once a replacement can exclusively bind listeners.
972
+ */
973
+ async retireOwner({attestation, completionSocket}) {
974
+ if (this.retirementPromise) return await this.retirementPromise
975
+ this.retirementPromise = this.performOwnerRetirement(attestation, completionSocket)
976
+ return await this.retirementPromise
977
+ }
978
+
979
+ /**
980
+ * @param {string} attestation - Replacement boot attestation.
981
+ * @param {net.Socket | undefined} completionSocket - Requesting handoff connection.
982
+ * @returns {Promise<void>} Resolves after quiesce and listener release.
983
+ */
984
+ async performOwnerRetirement(attestation, completionSocket) {
985
+ this.stopping = true
986
+ if (this.persistTimer) {
987
+ clearInterval(this.persistTimer)
988
+ this.persistTimer = undefined
989
+ }
990
+ this.persistenceEnabled = false
991
+ if (this.pendingWrite) await this.pendingWrite
992
+ this.stateCleanupEnabled = false
993
+ this.controlClosePromise = this.closeServer(this.controlServer)
994
+ for (const socket of this.controlSockets) if (socket !== completionSocket) socket.destroy()
995
+ await Promise.all([
996
+ ...[...this.services.values()].map((processInstance) => processInstance.quiesce()),
997
+ ...[...this.singletons.values()].map((processInstance) => processInstance.quiesce()),
998
+ ...[...this.startingReleases].map((release) => release.quiesce()),
999
+ ...[...this.releases.values()].map((release) => release.quiesce())
1000
+ ])
1001
+ await this.removeControlSocket()
1002
+ void this.closeServer(this.proxyServer)
1003
+ void Promise.allSettled([
1004
+ ...[...this.services.values()].map((processInstance) => processInstance.stop()),
1005
+ ...[...this.singletons.values()].map((processInstance) => processInstance.stop()),
1006
+ ...[...this.startingReleases].map((release) => release.stop()),
1007
+ ...[...this.releases.values()].map((release) => release.stop())
1008
+ ])
1009
+ this.logger("external owner retired", {attestation, status: "draining"})
1010
+ }
1011
+
1012
+ /**
1013
+ * @param {net.Socket | undefined} completionSocket - Requester retained for the final response.
1014
+ * @returns {Promise<void>} Retires listeners and cleans up every daemon-owned resource.
1015
+ */
1016
+ async performShutdown(completionSocket) {
1017
+ this.stopping = true
1018
+ const cleanupErrors = /** @type {Error[]} */ ([])
1019
+
1020
+ // server.close() stops new connections synchronously. Unlink immediately afterward so a
1021
+ // replacement can bind as soon as cleanup completes; existing connections remain usable for
1022
+ // the shutdown completion/error response.
1023
+ this.controlClosePromise = this.closeServer(this.controlServer)
1024
+
1025
+ for (const socket of this.controlSockets) {
1026
+ if (socket !== completionSocket) socket.destroy()
1027
+ }
1028
+
1029
+ await captureShutdownError(cleanupErrors, "control socket unlink", () => this.removeControlSocket())
1030
+
1031
+ if (this.persistTimer) {
1032
+ clearInterval(this.persistTimer)
1033
+ this.persistTimer = undefined
1034
+ }
1035
+
1036
+ await captureShutdownError(cleanupErrors, "proxy close", async () => this.proxy.close())
1037
+ const dependentStopResults = await Promise.allSettled([
1038
+ ...[...this.singletons.values()].map((processInstance) => processInstance.stop()),
1039
+ ...[...this.startingReleases].map((release) => release.stop()),
1040
+ ...[...this.releases.values()].map((release) => release.stop())
1041
+ ])
1042
+ const serviceStopResults = await Promise.allSettled([...this.services.values()].map((processInstance) => processInstance.stop()))
1043
+ const stopResults = [...dependentStopResults, ...serviceStopResults]
1044
+ const guardian = this.guardian
1045
+ if (guardian) await captureShutdownError(cleanupErrors, "process guardian shutdown", () => guardian.shutdown())
1046
+ await captureShutdownError(cleanupErrors, "proxy server close", () => this.closeServer(this.proxyServer))
1047
+
1048
+ // Wait for any in-flight write first so it can't recreate or overwrite the final state (no
1049
+ // new writes start: stopping is set and the persist timer is cleared above). Prior-daemon
1050
+ // orphans are not owned by this daemon, so retain their records until they are confirmed gone.
1051
+ await captureShutdownError(cleanupErrors, "persistent state cleanup", async () => {
1052
+ if (!this.statePath || !this.stateCleanupEnabled) return
1053
+ if (this.pendingWrite) await this.pendingWrite
1054
+ const orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
1055
+
1056
+ if (orphans.length > 0) {
1057
+ await writeState(this.statePath, {activeReleaseId: null, orphans, releases: [], services: [], singletons: []})
1058
+ } else {
1059
+ await clearState(this.statePath)
1060
+ }
1061
+ })
1062
+
1063
+ const stopErrors = stopResults.filter((result) => result.status === "rejected").map((result) => result.reason)
1064
+
1065
+ if (stopErrors.length > 0) {
1066
+ cleanupErrors.push(new AggregateError(stopErrors, `Shutdown failed to stop ${stopErrors.length} owned resource${stopErrors.length === 1 ? "" : "s"}.`))
1067
+ }
1068
+
1069
+ if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, cleanupErrors.map((error) => error.message).join("; "))
1070
+ }
1071
+
1072
+ /** @returns {Promise<void>} Removes the configured control socket path. */
1073
+ async removeControlSocket() {
1074
+ if (!this.controlSocketOwned) return
1075
+
1076
+ await fs.rm(this.config.control.path, {force: true})
1077
+ this.controlSocketOwned = false
1078
+ }
1079
+
1080
+ /**
1081
+ * @param {net.Server | http.Server | undefined} server - Server.
1082
+ * @returns {Promise<void>} Resolves when closed.
1083
+ */
1084
+ async closeServer(server) {
1085
+ if (!server || !server.listening) return
1086
+
1087
+ await new Promise((resolve) => server.close(() => resolve(undefined)))
1088
+ }
1089
+
1090
+ /** @returns {number | undefined} Current proxy port. */
1091
+ getProxyPort() {
1092
+ return this.proxyPort
1093
+ }
1094
+
1095
+ /** @returns {DaemonStatus} Status payload. */
1096
+ status() {
1097
+ // Re-check liveness and prune the dead permanently, so the list self-clears as the operator
1098
+ // stops the leftovers (e.g. via `rollbridge recover`). Pruning (not just filtering) matters:
1099
+ // a cleared orphan must not reappear if the OS later recycles its pid for an unrelated process.
1100
+ this.orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
1101
+
1102
+ return {
1103
+ activeReleaseId: this.activeRelease ? this.activeRelease.releaseId : null,
1104
+ application: this.config.application,
1105
+ bootstrap: this.bootstrap ? {...this.bootstrap} : undefined,
1106
+ control: {...this.config.control},
1107
+ daemonRuntime: this.runtime ? {...this.runtime} : undefined,
1108
+ ownerRecovery: this.guardian ? {configDigest: this.ownerRecoveryConfigDigest()} : undefined,
1109
+ orphans: [...this.orphans],
1110
+ proxy: {
1111
+ host: this.config.proxy.host,
1112
+ port: this.proxyPort ?? this.config.proxy.port,
1113
+ upstreamHost: this.config.proxy.upstreamHost
1114
+ },
1115
+ releaseReferences: [...this.releases.values()]
1116
+ .filter((release) => release.state === "active" || release.state === "draining")
1117
+ .map((release) => ({releaseId: release.releaseId, releasePath: release.releasePath})),
1118
+ releases: [...this.releases.values()].map((release) => release.status()),
1119
+ services: [...this.services.entries()].map(([id, processInstance]) => ({
1120
+ id,
1121
+ process: processInstance.status()
1122
+ })),
1123
+ singletons: [...this.singletons.entries()].map(([id, processInstance]) => ({
1124
+ id,
1125
+ process: processInstance.status()
1126
+ }))
1127
+ }
1128
+ }
1129
+ }
1130
+
1131
+ /**
1132
+ * @param {JsonValue} value - Value.
1133
+ * @returns {string | undefined} String value.
1134
+ */
1135
+ function stringOrUndefined(value) {
1136
+ if (value === undefined || value === null) return undefined
1137
+ if (typeof value !== "string") throw new Error("Expected string value")
1138
+
1139
+ return value
1140
+ }
1141
+
1142
+ /**
1143
+ * @param {string} line - Raw control line.
1144
+ * @returns {boolean} Whether the line requests shutdown and needs a terminal response connection.
1145
+ */
1146
+ function isShutdownControlLine(line) {
1147
+ try {
1148
+ const command = JSON.parse(line)
1149
+
1150
+ return Boolean(command && typeof command === "object" && ["retire-owner", "shutdown"].includes(command.command))
1151
+ } catch {
1152
+ return false
1153
+ }
1154
+ }
1155
+
1156
+ /**
1157
+ * Runs one shutdown cleanup step and records a labeled failure without skipping later cleanup.
1158
+ * @param {Error[]} errors - Accumulated cleanup errors.
1159
+ * @param {string} label - Non-secret cleanup step name.
1160
+ * @param {() => Promise<void>} operation - Cleanup operation.
1161
+ * @returns {Promise<void>} Resolves after the operation succeeds or its failure is recorded.
1162
+ */
1163
+ async function captureShutdownError(errors, label, operation) {
1164
+ try {
1165
+ await operation()
1166
+ } catch (error) {
1167
+ const reason = error instanceof Error ? error.message : String(error)
1168
+
1169
+ errors.push(new Error(`${label} failed: ${reason}`, {cause: error}))
1170
+ }
1171
+ }
1172
+
1173
+ const SECRET_BEARING_STATE_KEYS = new Set(["children", "command", "cwd", "env", "environment", "logs", "output"])
1174
+
1175
+ /**
1176
+ * Removes process definitions and captured output from a value before it reaches statePath.
1177
+ * The live status/events APIs retain those diagnostics in memory; persistent state is only a
1178
+ * secret-safe recovery aid and must not become a second process log or configuration store.
1179
+ * @param {JsonValue} value - JSON value to sanitize.
1180
+ * @returns {JsonValue} A secret-safe copy.
1181
+ */
1182
+ function secretSafeStateValue(value) {
1183
+ if (Array.isArray(value)) return value.map((entry) => secretSafeStateValue(entry))
1184
+ if (!value || typeof value !== "object") return value
1185
+
1186
+ /** @type {Record<string, JsonValue>} */
1187
+ const safe = {}
1188
+
1189
+ for (const [key, entry] of Object.entries(value)) {
1190
+ if (SECRET_BEARING_STATE_KEYS.has(key)) continue
1191
+ safe[key] = secretSafeStateValue(entry)
1192
+ }
1193
+
1194
+ return safe
1195
+ }
1196
+
1197
+ /**
1198
+ * @param {JsonValue} value - Value.
1199
+ * @param {string} key - Key.
1200
+ * @returns {string} String value.
1201
+ */
1202
+ function requiredString(value, key) {
1203
+ if (typeof value !== "string" || value.length === 0) {
1204
+ throw new Error(`${key} is required`)
1205
+ }
1206
+
1207
+ return value
1208
+ }
1209
+
1210
+ /**
1211
+ * @typedef {{releaseId: string, state: string, stoppedAt: string | undefined}} PrunableRelease
1212
+ */
1213
+
1214
+ /**
1215
+ * Selects stopped releases to prune by the retention policy, keeping the most recent.
1216
+ * @param {PrunableRelease[]} releases - Status of all tracked releases, in deploy order (oldest first).
1217
+ * @param {import("./config.js").ReleaseRetentionConfig} policy - Retention policy.
1218
+ * @param {number} now - Current epoch milliseconds.
1219
+ * @returns {string[]} Release ids to remove.
1220
+ */
1221
+ export function releasesToPrune(releases, policy, now) {
1222
+ const stopped = releases
1223
+ .filter((release) => release.state === "stopped")
1224
+ .map((release, index) => ({deployOrder: index, releaseId: release.releaseId, stoppedAtMs: release.stoppedAt ? Date.parse(release.stoppedAt) : 0}))
1225
+ // Most recent first; ties (same stoppedAt millisecond) prefer the later-deployed release.
1226
+ .sort((first, second) => second.stoppedAtMs - first.stoppedAtMs || second.deployOrder - first.deployOrder)
1227
+
1228
+ /** @type {string[]} */
1229
+ const remove = []
1230
+
1231
+ stopped.forEach((release, index) => {
1232
+ const beyondKeep = index >= policy.keep
1233
+ const tooOld = policy.maxAgeMs > 0 && release.stoppedAtMs > 0 && now - release.stoppedAtMs > policy.maxAgeMs
1234
+
1235
+ if (beyondKeep || tooOld) remove.push(release.releaseId)
1236
+ })
1237
+
1238
+ return remove
1239
+ }
1240
+
1241
+ /**
1242
+ * @typedef {{alive: boolean, application?: string, activeReleaseId?: string | null, proxy?: {host: string, port: number}}} ControlSocketInspection
1243
+ */
1244
+
1245
+ /**
1246
+ * Builds an operator-facing message explaining why the control socket cannot be bound.
1247
+ * @param {string} socketPath - Control socket path.
1248
+ * @param {ControlSocketInspection} inspection - Result of probing the socket.
1249
+ * @returns {string} Diagnostic message.
1250
+ */
1251
+ function controlSocketBusyMessage(socketPath, inspection) {
1252
+ if (inspection.application === undefined) {
1253
+ return `The control socket ${socketPath} is already in use by another process. Stop that process or set a different control.path.`
1254
+ }
1255
+
1256
+ const releaseDetail = inspection.activeReleaseId ? `active release: ${inspection.activeReleaseId}` : "no active release"
1257
+
1258
+ return `A Rollbridge daemon for application "${inspection.application}" is already running on ${socketPath} (${releaseDetail}). ` +
1259
+ `Run "rollbridge status" to inspect it or "rollbridge shutdown" to stop it, or set a different control.path.`
1260
+ }
1261
+
1262
+ /**
1263
+ * Probes an existing control socket to see whether a daemon is alive, and identifies it when it is Rollbridge.
1264
+ * @param {string} socketPath - Control socket path.
1265
+ * @param {number} [timeoutMs] - How long to wait for a status response before treating the socket as busy.
1266
+ * @returns {Promise<ControlSocketInspection>} Whether the socket is live and, when it is Rollbridge, its identity.
1267
+ */
1268
+ export async function inspectControlSocket(socketPath, timeoutMs = 1000) {
1269
+ return await new Promise((resolve, reject) => {
1270
+ const socket = net.createConnection(socketPath)
1271
+ let buffer = ""
1272
+ let settled = false
1273
+ let timer = /** @type {ReturnType<typeof setTimeout> | undefined} */ (undefined)
1274
+
1275
+ const finish = (/** @type {ControlSocketInspection} */ result) => {
1276
+ if (settled) return
1277
+
1278
+ settled = true
1279
+ if (timer) clearTimeout(timer)
1280
+ socket.destroy()
1281
+ resolve(result)
1282
+ }
1283
+
1284
+ timer = setTimeout(() => finish({alive: true}), timeoutMs)
1285
+ socket.setEncoding("utf8")
1286
+ socket.once("connect", () => socket.write(`${JSON.stringify({command: "status"})}\n`))
1287
+ socket.on("data", (chunk) => {
1288
+ buffer += chunk
1289
+ const newlineIndex = buffer.indexOf("\n")
1290
+
1291
+ if (newlineIndex < 0) return
1292
+
1293
+ const status = parseControlStatus(buffer.slice(0, newlineIndex))
1294
+
1295
+ finish(status ? {activeReleaseId: status.activeReleaseId, alive: true, application: status.application, proxy: status.proxy} : {alive: true})
1296
+ })
1297
+ socket.once("error", (error) => {
1298
+ if (settled) return
1299
+
1300
+ if (error && typeof error === "object" && "code" in error && (error.code === "ENOENT" || error.code === "ECONNREFUSED")) {
1301
+ settled = true
1302
+ if (timer) clearTimeout(timer)
1303
+ resolve({alive: false})
1304
+ return
1305
+ }
1306
+
1307
+ settled = true
1308
+ if (timer) clearTimeout(timer)
1309
+ reject(error)
1310
+ })
1311
+ })
1312
+ }
1313
+
1314
+ /**
1315
+ * Parses a control status response line into a Rollbridge identity, if it is one.
1316
+ * @param {string} line - JSON response line.
1317
+ * @returns {{application: string, activeReleaseId: string | null, proxy: {host: string, port: number} | undefined} | undefined} Identity, or undefined when unrecognized.
1318
+ */
1319
+ function parseControlStatus(line) {
1320
+ /** @type {JsonValue} */
1321
+ let parsed
1322
+
1323
+ try {
1324
+ parsed = JSON.parse(line)
1325
+ } catch {
1326
+ return undefined
1327
+ }
1328
+
1329
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined
1330
+ if (typeof parsed.application !== "string") return undefined
1331
+
1332
+ const proxy = "proxy" in parsed && parsed.proxy && typeof parsed.proxy === "object" && !Array.isArray(parsed.proxy) && typeof parsed.proxy.host === "string" && typeof parsed.proxy.port === "number" ? {host: parsed.proxy.host, port: parsed.proxy.port} : undefined
1333
+
1334
+ return {activeReleaseId: typeof parsed.activeReleaseId === "string" ? parsed.activeReleaseId : null, application: parsed.application, proxy}
1335
+ }
1336
+