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,434 @@
1
+ // @ts-check
2
+
3
+ import crypto from "node:crypto"
4
+ import {spawn} from "node:child_process"
5
+ import net from "node:net"
6
+ import {fileURLToPath} from "node:url"
7
+ import ManagedProcess from "./managed-process.js"
8
+
9
+ const guardianPath = fileURLToPath(new URL("./process-guardian.js", import.meta.url))
10
+
11
+ export default class GuardianClient {
12
+ /** @param {{pid?: number, socketPath: string, token: string}} identity - Durable guardian identity. */
13
+ constructor({pid, socketPath, token}) {
14
+ this.pid = pid
15
+ this.socketPath = socketPath
16
+ this.token = token
17
+ this.socket = /** @type {net.Socket | undefined} */ (undefined)
18
+ this.buffer = ""
19
+ this.nextId = 0
20
+ this.pending = /** @type {Map<number, {command: string, reject: (error: Error) => void, resolve: (value: import("./json.js").JsonValue) => void}>} */ (new Map())
21
+ this.idleWaiters = /** @type {(() => void)[]} */ ([])
22
+ this.guardianExitPromise = /** @type {Promise<void> | undefined} */ (undefined)
23
+ this.processes = /** @type {Map<string, GuardianProcess>} */ (new Map())
24
+ this.events = /** @type {Map<string, {reject: (error: Error) => void, resolve: (value: Record<string, import("./json.js").JsonValue>) => void}[]>} */ (new Map())
25
+ this.eventHandlers = /** @type {Map<string, ((event: Record<string, import("./json.js").JsonValue>) => void)[]>} */ (new Map())
26
+ }
27
+
28
+ /** Launches a new detached guardian and connects to it after its bind acknowledgement. */
29
+ /**
30
+ * @param {{legacyGuardian?: {pid?: number, socketPath: string, token: string}, ownerState?: import("./json.js").JsonValue}} [options] - Optional authenticated legacy backend migration.
31
+ */
32
+ async launch(options = {}) {
33
+ const child = spawn(process.execPath, [guardianPath, this.socketPath], {detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"]})
34
+
35
+ this.pid = child.pid
36
+ this.guardianExitPromise = new Promise((resolve) => child.once("exit", () => resolve(undefined)))
37
+
38
+ await new Promise((resolve, reject) => {
39
+ child.once("error", reject)
40
+ child.once("exit", (code) => reject(new Error(`Process guardian exited before readiness with status ${code}`)))
41
+ child.once("message", (message) => {
42
+ if (message && typeof message === "object" && "error" in message) reject(new Error(String(message.error)))
43
+ else resolve(undefined)
44
+ })
45
+ child.send({...options, token: this.token}, (error) => {
46
+ if (error) reject(error)
47
+ })
48
+ })
49
+ if (child.connected) await new Promise((resolve) => child.once("disconnect", () => resolve(undefined)))
50
+ child.unref()
51
+ await this.connect()
52
+ }
53
+
54
+ /**
55
+ * Starts a current transaction guardian in front of this authenticated pre-split guardian.
56
+ * @param {{ownerState: import("./json.js").JsonValue, socketPath: string, token: string}} options - Upgrade identity and exact committed state.
57
+ * @returns {Promise<GuardianClient>} Current guardian client backed by the legacy supervisor.
58
+ */
59
+ async upgradeLegacyGuardian({ownerState, socketPath, token}) {
60
+ const upgraded = new GuardianClient({socketPath, token})
61
+
62
+ await upgraded.launch({
63
+ legacyGuardian: {pid: this.pid, socketPath: this.socketPath, token: this.token},
64
+ ownerState
65
+ })
66
+ return upgraded
67
+ }
68
+
69
+ /** Abandons an uncommitted upgrade coordinator without touching legacy-owned processes. */
70
+ async abandonLegacyUpgrade() {
71
+ await this.request({command: "abandon-legacy-upgrade"})
72
+ const socket = this.socket
73
+
74
+ if (!socket || socket.destroyed) throw new Error("Legacy guardian upgrade disconnected before abandon acknowledgement")
75
+ const closed = new Promise((resolve) => socket.once("close", resolve))
76
+
77
+ socket.end()
78
+ await closed
79
+ }
80
+
81
+ /** Connects to an existing guardian. */
82
+ async connect() {
83
+ if (this.socket && !this.socket.destroyed) return
84
+ const socket = net.createConnection(this.socketPath)
85
+
86
+ socket.setEncoding("utf8")
87
+ await new Promise((resolve, reject) => {
88
+ socket.once("connect", resolve)
89
+ socket.once("error", reject)
90
+ })
91
+ socket.on("data", (chunk) => this.onData(String(chunk)))
92
+ socket.once("close", () => {
93
+ for (const {command, reject} of this.pending.values()) reject(new Error(`Process guardian connection closed while awaiting ${command}`))
94
+ this.pending.clear()
95
+ for (const waiters of this.events.values()) for (const {reject} of waiters) reject(new Error("Process guardian connection closed"))
96
+ this.events.clear()
97
+ this.resolveIdleWaiters()
98
+ })
99
+ this.socket = socket
100
+ }
101
+
102
+ /**
103
+ * @param {string} key - Stable process identity.
104
+ * @param {ConstructorParameters<typeof ManagedProcess>[0]} definition - Process definition.
105
+ * @returns {GuardianProcess} Remote managed process.
106
+ */
107
+ process(key, definition) {
108
+ const processInstance = new GuardianProcess({client: this, definition, key})
109
+
110
+ this.processes.set(key, processInstance)
111
+ return processInstance
112
+ }
113
+
114
+ /**
115
+ * @param {Record<string, import("./json.js").JsonValue>} command - Command.
116
+ * @returns {Promise<import("./json.js").JsonValue>} Guardian response.
117
+ */
118
+ async request(command) {
119
+ if (!this.socket || this.socket.destroyed) throw new Error("Process guardian is not connected")
120
+ this.nextId += 1
121
+ const id = this.nextId
122
+ const response = new Promise((resolve, reject) => this.pending.set(id, {command: String(command.command), reject, resolve}))
123
+
124
+ this.socket.write(`${JSON.stringify({...command, id, token: this.token})}\n`)
125
+ return await response
126
+ }
127
+
128
+ /** Stops the guardian after every owned process has stopped. */
129
+ async shutdown() {
130
+ if (this.pending.size > 0) {
131
+ await new Promise((resolve) => {
132
+ this.idleWaiters.push(() => { resolve(undefined) })
133
+ })
134
+ }
135
+ await this.request({command: "shutdown"})
136
+ const socket = this.socket
137
+
138
+ if (!socket || socket.destroyed) throw new Error("Process guardian disconnected before shutdown acknowledgement")
139
+ const closed = new Promise((resolve) => socket.once("close", () => resolve(undefined)))
140
+
141
+ socket.end()
142
+ await closed
143
+ }
144
+
145
+ /** Waits for a guardian launched by this client to exit. */
146
+ async guardianExit() {
147
+ if (!this.guardianExitPromise) throw new Error("Guardian exit is observable only from the launching client")
148
+ await this.guardianExitPromise
149
+ }
150
+
151
+ /**
152
+ * @param {number} graceMs - Event-driven handoff grace while the prior owner disconnects.
153
+ * @param {import("./json.js").JsonValue} authority - Exact owner authority.
154
+ */
155
+ async claimOwner(graceMs, authority) {
156
+ await this.request({authority, command: "claim-owner", graceMs})
157
+ }
158
+
159
+ /** Starts graceful process retirement and relinquishes committed owner authority. */
160
+ async retireOwner() {
161
+ await this.request({command: "retire-owner"})
162
+ }
163
+
164
+ /** @param {import("./json.js").JsonValue} ownerState - Private transferable owner state. */
165
+ async publishOwnerState(ownerState) {
166
+ await this.request({command: "publish-owner-state", ownerState})
167
+ }
168
+
169
+ /**
170
+ * @param {import("./json.js").JsonValue} authority - Persisted current authority.
171
+ * @param {import("./json.js").JsonValue} nextAuthority - Requested authority.
172
+ * @returns {Promise<{ownerState: import("./json.js").JsonValue, replacementId: string}>} Prepared transaction.
173
+ */
174
+ async prepareOwnerReplacement(authority, nextAuthority) {
175
+ return /** @type {{ownerState: import("./json.js").JsonValue, replacementId: string}} */ (await this.request({authority, command: "prepare-owner-replacement", nextAuthority}))
176
+ }
177
+
178
+ /**
179
+ * @param {string} replacementId - Prepared transaction.
180
+ * @param {import("./json.js").JsonValue} ownerState - Complete candidate state.
181
+ * @returns {Promise<{committed: boolean}>} Whether staging completed an ownerless transaction.
182
+ */
183
+ async stageOwnerReplacement(replacementId, ownerState) {
184
+ return /** @type {{committed: boolean}} */ (await this.request({command: "stage-owner-replacement", ownerState, replacementId}))
185
+ }
186
+
187
+ /** @param {string} replacementId - Prepared transaction to abort before staging. */
188
+ async abortOwnerReplacement(replacementId) {
189
+ await this.request({command: "abort-owner-replacement", replacementId})
190
+ }
191
+
192
+ /** @param {string} replacementId - Prepared transaction id. */
193
+ async commitOwnerReplacement(replacementId) {
194
+ await this.request({command: "commit-owner-replacement", replacementId})
195
+ }
196
+
197
+ /** @param {string} replacementId - Committed transaction awaiting incumbent retirement. */
198
+ async finalizeOwnerReplacement(replacementId) {
199
+ await this.request({command: "finalize-owner-replacement", replacementId})
200
+ }
201
+
202
+ /** @param {string} replacementId - Prepared transaction to validate for listener yield. */
203
+ async validateOwnerReplacement(replacementId) {
204
+ await this.request({command: "validate-owner-replacement", replacementId})
205
+ }
206
+
207
+ /**
208
+ * Acquires the committed owner's mutation fence.
209
+ * @param {string} operation - Control mutation name.
210
+ * @returns {Promise<string>} Mutation lease id.
211
+ */
212
+ async beginOwnerMutation(operation) {
213
+ const result = /** @type {{mutationId: string}} */ (await this.request({command: "begin-owner-mutation", operation}))
214
+
215
+ return result.mutationId
216
+ }
217
+
218
+ /** @param {string} mutationId - Mutation lease id. */
219
+ async endOwnerMutation(mutationId) {
220
+ await this.request({command: "end-owner-mutation", mutationId})
221
+ }
222
+
223
+ /** @returns {Promise<{committedReplacementId: string | null, ownerClaimed: boolean}>} Transaction status. */
224
+ async replacementStatus() {
225
+ return /** @type {{committedReplacementId: string | null, ownerClaimed: boolean}} */ (await this.request({command: "replacement-status"}))
226
+ }
227
+
228
+ /** @returns {Promise<import("./json.js").JsonValue>} Current private transfer state. */
229
+ async ownerState() {
230
+ const result = /** @type {{ownerState: import("./json.js").JsonValue}} */ (await this.request({command: "owner-state"}))
231
+
232
+ return result.ownerState
233
+ }
234
+
235
+ /**
236
+ * @param {string} event - Guardian event name.
237
+ * @returns {Promise<Record<string, import("./json.js").JsonValue>>} Next event payload.
238
+ */
239
+ waitForEvent(event) {
240
+ return new Promise((resolve, reject) => {
241
+ const waiters = this.events.get(event) || []
242
+
243
+ waiters.push({reject, resolve})
244
+ this.events.set(event, waiters)
245
+ })
246
+ }
247
+
248
+ /**
249
+ * Subscribes to authenticated guardian transaction events.
250
+ * @param {string} event - Event name.
251
+ * @param {(event: Record<string, import("./json.js").JsonValue>) => void} handler - Event handler.
252
+ */
253
+ onEvent(event, handler) {
254
+ const handlers = this.eventHandlers.get(event) || []
255
+
256
+ handlers.push(handler)
257
+ this.eventHandlers.set(event, handlers)
258
+ }
259
+
260
+ /** @returns {Promise<{key: string, provenance: string, status: import("./managed-process.js").ManagedProcessStatus}[]>} Exact guardian-owned inventory. */
261
+ async inventory() {
262
+ return /** @type {{key: string, provenance: string, status: import("./managed-process.js").ManagedProcessStatus}[]} */ (await this.request({command: "inventory"}))
263
+ }
264
+
265
+ /**
266
+ * Stops and forgets one exact guardian-owned registration.
267
+ * @param {string} key - Stable guardian registration key.
268
+ * @param {string} provenance - Exact expected process-definition provenance.
269
+ */
270
+ async remove(key, provenance) {
271
+ await this.request({command: "remove", key, provenance})
272
+ this.processes.delete(key)
273
+ }
274
+
275
+ /** Stops guardian registrations absent from the reconstructed durable snapshot. */
276
+ async reconcileInventory() {
277
+ const unexpected = (await this.inventory()).filter((entry) => !this.processes.has(entry.key))
278
+ const results = await Promise.allSettled(unexpected.map((entry) => this.remove(entry.key, entry.provenance)))
279
+ const errors = results.filter((result) => result.status === "rejected").map((result) => result.reason)
280
+
281
+ if (errors.length > 0) {
282
+ throw new AggregateError(errors, `Guardian inventory reconciliation failed for ${errors.length} registration${errors.length === 1 ? "" : "s"}: ${errors.map((error) => errorMessage(error instanceof Error ? error : String(error))).join("; ")}`)
283
+ }
284
+ }
285
+
286
+ /** Disconnects a fenced startup loser without changing guardian-owned processes. */
287
+ disconnect() {
288
+ this.socket?.destroy()
289
+ }
290
+
291
+ /** Resolves shutdown barriers after every earlier request settles. */
292
+ resolveIdleWaiters() {
293
+ if (this.pending.size > 0) return
294
+ for (const resolve of this.idleWaiters.splice(0)) resolve()
295
+ }
296
+
297
+ /** @param {string} chunk - Protocol bytes. */
298
+ onData(chunk) {
299
+ this.buffer += chunk
300
+ let newline = this.buffer.indexOf("\n")
301
+
302
+ while (newline >= 0) {
303
+ const line = this.buffer.slice(0, newline)
304
+ const message = JSON.parse(line)
305
+
306
+ this.buffer = this.buffer.slice(newline + 1)
307
+ if (message.event) {
308
+ if (message.event === "process" || message.event === "status") this.processes.get(message.key)?.onGuardianEvent(message)
309
+ for (const handler of this.eventHandlers.get(message.event) || []) handler(message)
310
+ const waiter = this.events.get(message.event)?.shift()
311
+
312
+ if (waiter) waiter.resolve(message)
313
+ } else {
314
+ const pending = this.pending.get(message.id)
315
+
316
+ this.pending.delete(message.id)
317
+ this.resolveIdleWaiters()
318
+ if (message.error) pending?.reject(new Error(message.error))
319
+ else pending?.resolve(message.result)
320
+ }
321
+ newline = this.buffer.indexOf("\n")
322
+ }
323
+ }
324
+ }
325
+
326
+ class GuardianProcess extends ManagedProcess {
327
+ /** @param {{client: GuardianClient, definition: ConstructorParameters<typeof ManagedProcess>[0], key: string}} args - Remote process args. */
328
+ constructor({client, definition, key}) {
329
+ super(definition)
330
+ this.client = client
331
+ this.key = key
332
+ this.definition = serializableDefinition(definition)
333
+ this.provenance = crypto.createHash("sha256").update(JSON.stringify(this.definition)).digest("hex")
334
+ this.cachedStatus = super.status()
335
+ this.registration = /** @type {Promise<void> | undefined} */ (undefined)
336
+ this.pendingUpdate = Promise.resolve()
337
+ }
338
+
339
+ async ensureRegistered() {
340
+ if (!this.registration) {
341
+ this.registration = this.client.request({command: "register", definition: this.definition, key: this.key, provenance: this.provenance})
342
+ .then((status) => { this.cachedStatus = asProcessStatus(status) })
343
+ }
344
+ await this.registration
345
+ }
346
+
347
+ /** Reconnects to an already registered guardian process without changing its desired state. */
348
+ async recover() {
349
+ await this.ensureRegistered()
350
+ }
351
+
352
+ async start(reason = "deploy") {
353
+ await this.ensureRegistered()
354
+ await this.pendingUpdate
355
+ this.cachedStatus = asProcessStatus(await this.client.request({command: "start", key: this.key, reason}))
356
+ }
357
+
358
+ /** @param {import("./managed-process.js").ManagedProcessDefinition} definition - Updated definition. */
359
+ updateDefinition(definition) {
360
+ const previousProvenance = this.provenance
361
+ const registration = this.ensureRegistered()
362
+
363
+ super.updateDefinition(definition)
364
+ this.definition = serializableDefinition(this)
365
+ this.provenance = crypto.createHash("sha256").update(JSON.stringify(this.definition)).digest("hex")
366
+ this.pendingUpdate = registration.then(async () => {
367
+ this.cachedStatus = asProcessStatus(await this.client.request({command: "update", definition: this.definition, key: this.key, previousProvenance, provenance: this.provenance}))
368
+ })
369
+ }
370
+
371
+ async quiesce() {
372
+ await this.ensureRegistered()
373
+ await this.pendingUpdate
374
+ this.cachedStatus = asProcessStatus(await this.client.request({command: "quiesce", key: this.key}))
375
+ }
376
+
377
+ async quiesceStrict() {
378
+ await this.quiesce()
379
+ }
380
+
381
+ async stop(options = {}) {
382
+ await this.ensureRegistered()
383
+ await this.pendingUpdate
384
+ this.cachedStatus = asProcessStatus(await this.client.request({command: "stop", key: this.key, options}))
385
+ }
386
+
387
+ status() {
388
+ return this.cachedStatus
389
+ }
390
+
391
+ /** @param {Record<string, import("./json.js").JsonValue>} event - Guardian event. */
392
+ onGuardianEvent(event) {
393
+ if (event.status) this.cachedStatus = asProcessStatus(event.status)
394
+ if (event.message === "process started") this.emit("started")
395
+ if (event.message === "process exited") this.emit("exit", event.data)
396
+ this.logger(typeof event.message === "string" ? event.message : "guardian process status", event.data && typeof event.data === "object" && !Array.isArray(event.data) ? event.data : {})
397
+ }
398
+ }
399
+
400
+ /**
401
+ * @param {ConstructorParameters<typeof ManagedProcess>[0] | ManagedProcess} definition - Managed definition.
402
+ * @returns {Record<string, import("./json.js").JsonValue>} Serializable definition.
403
+ */
404
+ function serializableDefinition(definition) {
405
+ return {
406
+ command: definition.command,
407
+ cwd: definition.cwd,
408
+ env: definition.env,
409
+ id: definition.id,
410
+ lifecycle: definition.lifecycle,
411
+ memory: definition.memory,
412
+ outputLines: definition.outputLines,
413
+ restart: definition.restart,
414
+ restartDelayMs: definition.restartDelayMs,
415
+ stopSignal: definition.stopSignal,
416
+ stopTimeoutMs: definition.stopTimeoutMs
417
+ }
418
+ }
419
+
420
+ /**
421
+ * @param {import("./json.js").JsonValue} value - Protocol value.
422
+ * @returns {import("./managed-process.js").ManagedProcessStatus} Process status.
423
+ */
424
+ function asProcessStatus(value) {
425
+ return JSON.parse(JSON.stringify(value))
426
+ }
427
+
428
+ /**
429
+ * @param {Error | string} error - Error-like value.
430
+ * @returns {string} Error message.
431
+ */
432
+ function errorMessage(error) {
433
+ return error instanceof Error ? error.message : String(error)
434
+ }
@@ -6,7 +6,7 @@ import {processGroupHasLiveMembers, processGroupMembers} from "./process-memory.
6
6
 
7
7
  /**
8
8
  * @typedef {import("./json.js").JsonValue} JsonValue
9
- * @typedef {"starting" | "running" | "stopping" | "stopped" | "failed"} ManagedProcessState
9
+ * @typedef {"starting" | "running" | "quiesced" | "stopping" | "stopped" | "failed"} ManagedProcessState
10
10
  * @typedef {"deploy" | "crash" | "manual" | "memory"} ManagedProcessStartReason
11
11
  * @typedef {import("node:child_process").ChildProcess["signalCode"]} ProcessExitSignal
12
12
  * @typedef {{at: string, line: string, stream: "stdout" | "stderr"}} ManagedProcessLog
@@ -64,6 +64,8 @@ export default class ManagedProcess extends EventEmitter {
64
64
  this.intentionalStop = false
65
65
  this.intentionalStopSignal = /** @type {ProcessExitSignal | undefined} */ (undefined)
66
66
  this.quiescePromise = /** @type {Promise<void> | undefined} */ (undefined)
67
+ this.quiesceError = /** @type {Error | undefined} */ (undefined)
68
+ this.stopPromise = /** @type {Promise<void> | undefined} */ (undefined)
67
69
  this.restartTimer = undefined
68
70
  this.child = undefined
69
71
  this.exitPromise = undefined
@@ -82,6 +84,8 @@ export default class ManagedProcess extends EventEmitter {
82
84
  this.intentionalStop = false
83
85
  this.intentionalStopSignal = undefined
84
86
  this.quiescePromise = undefined
87
+ this.quiesceError = undefined
88
+ this.stopPromise = undefined
85
89
  this.exitCode = undefined
86
90
  this.exitSignal = undefined
87
91
  this.state = "starting"
@@ -342,6 +346,15 @@ export default class ManagedProcess extends EventEmitter {
342
346
  * @returns {Promise<void>} Resolves when stopped.
343
347
  */
344
348
  async stop(options = {}) {
349
+ if (!this.stopPromise) this.stopPromise = this.performStop(options)
350
+ return await this.stopPromise
351
+ }
352
+
353
+ /**
354
+ * @param {{timeoutMs?: number}} options - Stop options.
355
+ * @returns {Promise<void>} Resolves when stopped.
356
+ */
357
+ async performStop(options) {
345
358
  const pgid = this.child?.pid ?? this.pid
346
359
  const exitPromise = this.exitPromise
347
360
  await this.quiesce()
@@ -405,11 +418,18 @@ export default class ManagedProcess extends EventEmitter {
405
418
  return
406
419
  }
407
420
  this.state = "stopping"
408
- if (this.lifecycle.quietCommand) await this.runHook(this.lifecycle.quietCommand, this.hookTimeoutMs(), "quiet command")
421
+ if (this.lifecycle.quietCommand) this.quiesceError = await this.runHook(this.lifecycle.quietCommand, this.hookTimeoutMs(), "quiet command")
422
+ if (!this.quiesceError) this.state = "quiesced"
409
423
  })()
410
424
  return await this.quiescePromise
411
425
  }
412
426
 
427
+ /** @returns {Promise<void>} Quiesces and rejects when the quiet hook did not succeed. */
428
+ async quiesceStrict() {
429
+ await this.quiesce()
430
+ if (this.quiesceError) throw this.quiesceError
431
+ }
432
+
413
433
  /** @returns {number} Timeout used for lifecycle hooks. */
414
434
  hookTimeoutMs() {
415
435
  if (this.stopTimeoutMs === "indefinite") return 30000
@@ -419,18 +439,19 @@ export default class ManagedProcess extends EventEmitter {
419
439
 
420
440
  /**
421
441
  * Runs a lifecycle hook command, bounded by a timeout so a hung hook can never block stop().
422
- * Failures are logged and swallowed the graceful-stop sequence proceeds (and SIGKILL is the
423
- * ultimate fallback) regardless of the hook's outcome.
442
+ * Failures are logged and returned so generation retirement can surface a failed quiet hook;
443
+ * ordinary stop sequences may still continue to their configured stop mechanism.
424
444
  * @param {string} command - Shell command to run.
425
445
  * @param {number} timeoutMs - Maximum time to wait for the hook before killing it.
426
446
  * @param {string} label - Hook name, for log messages.
427
447
  * @param {number | undefined} [pid] - Process-group leader exposed to the hook.
428
- * @returns {Promise<void>} Resolves when the hook exits, errors, or times out.
448
+ * @returns {Promise<Error | undefined>} Failure, or undefined after a successful hook.
429
449
  */
430
450
  async runHook(command, timeoutMs, label, pid = this.pid) {
431
- await new Promise((resolve) => {
451
+ return await new Promise((resolve) => {
432
452
  let settled = false
433
- const finish = () => { if (!settled) { settled = true; resolve(undefined) } }
453
+ /** @param {Error | undefined} error - Hook failure. */
454
+ const finish = (error) => { if (!settled) { settled = true; resolve(error) } }
434
455
 
435
456
  /** @type {import("node:child_process").ChildProcess} */
436
457
  let hook
@@ -444,8 +465,9 @@ export default class ManagedProcess extends EventEmitter {
444
465
  stdio: "ignore"
445
466
  })
446
467
  } catch (error) {
447
- this.logger(`${label} failed`, {error: error instanceof Error ? error.message : String(error), id: this.id})
448
- finish()
468
+ const failure = error instanceof Error ? error : new Error(String(error))
469
+ this.logger(`${label} failed`, {error: failure.message, id: this.id})
470
+ finish(failure)
449
471
 
450
472
  return
451
473
  }
@@ -461,7 +483,7 @@ export default class ManagedProcess extends EventEmitter {
461
483
  }
462
484
  }
463
485
 
464
- finish()
486
+ finish(new Error(`${label} timed out after ${timeoutMs}ms`))
465
487
  }, timeoutMs)
466
488
 
467
489
  hook.once("exit", (code, signal) => {
@@ -470,16 +492,24 @@ export default class ManagedProcess extends EventEmitter {
470
492
  // A non-zero/signalled exit is surfaced (but still non-fatal); skip when the timeout
471
493
  // already killed the hook, which logs separately.
472
494
  if (!settled) {
473
- if (typeof code === "number" && code !== 0) this.logger(`${label} exited non-zero`, {code, id: this.id})
474
- else if (signal) this.logger(`${label} exited on signal`, {id: this.id, signal})
495
+ if (typeof code === "number" && code !== 0) {
496
+ this.logger(`${label} exited non-zero`, {code, id: this.id})
497
+ finish(new Error(`${label} exited non-zero with status ${code}`))
498
+ return
499
+ } else if (signal) {
500
+ this.logger(`${label} exited on signal`, {id: this.id, signal})
501
+ finish(new Error(`${label} exited on signal ${signal}`))
502
+ return
503
+ }
475
504
  }
476
505
 
477
- finish()
506
+ finish(undefined)
478
507
  })
479
508
  hook.once("error", (error) => {
480
509
  clearTimeout(timer)
481
- this.logger(`${label} failed`, {error: error instanceof Error ? error.message : String(error), id: this.id})
482
- finish()
510
+ const failure = error instanceof Error ? error : new Error(String(error))
511
+ this.logger(`${label} failed`, {error: failure.message, id: this.id})
512
+ finish(failure)
483
513
  })
484
514
  })
485
515
  }