rollbridge 0.1.39 → 0.1.41
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 +22 -4
- package/changelog.d/20260830-guardian-retired-replacement-process-key.md +12 -1
- package/changelog.d/20260830-release-generation-activation-lifecycle.md +16 -2
- package/changelog.d/20260830055159-guardian-daemon-restart.md +2 -0
- package/docs/cli.md +23 -9
- package/docs/config.md +37 -13
- package/docs/logging.md +4 -3
- package/docs/troubleshooting.md +7 -5
- package/examples/tensorbuzz.com.js +5 -2
- package/package.json +1 -1
- package/src/cli.js +118 -24
- package/src/config.js +35 -8
- package/src/daemon.js +822 -152
- package/src/guardian-client.js +121 -16
- package/src/managed-process.js +117 -15
- package/src/process-guardian.js +734 -43
- package/src/release-group.js +45 -7
- package/test/completion.test.js +4 -2
- package/test/config-examples.test.js +1 -0
- package/test/config-validation.test.js +22 -0
- package/test/fixtures/guardian-recovery-owner.js +86 -0
- package/test/fixtures/pre-split3-process-guardian.js +14 -0
- package/test/guardian-client.test.js +1420 -62
- package/test/managed-process.test.js +163 -7
- package/test/owner-recovery.test.js +575 -73
- package/test/owner-replacement.test.js +525 -28
- package/test/release-group.test.js +19 -2
- package/test/release-runtime-retention.test.js +121 -4
- package/test/rollbridge.test.js +263 -13
- package/test/support/process.js +41 -0
package/src/guardian-client.js
CHANGED
|
@@ -23,6 +23,7 @@ export default class GuardianClient {
|
|
|
23
23
|
this.processes = /** @type {Map<string, GuardianProcess>} */ (new Map())
|
|
24
24
|
this.reservedProcessKey = /** @type {string | undefined} */ (undefined)
|
|
25
25
|
this.reservedProcessProvenance = /** @type {string | undefined} */ (undefined)
|
|
26
|
+
this.generationReactivation = /** @type {boolean | undefined} */ (undefined)
|
|
26
27
|
this.events = /** @type {Map<string, {reject: (error: Error) => void, resolve: (value: Record<string, import("./json.js").JsonValue>) => void}[]>} */ (new Map())
|
|
27
28
|
this.eventHandlers = /** @type {Map<string, ((event: Record<string, import("./json.js").JsonValue>) => void)[]>} */ (new Map())
|
|
28
29
|
}
|
|
@@ -32,7 +33,7 @@ export default class GuardianClient {
|
|
|
32
33
|
* @param {{legacyGuardian?: {pid?: number, socketPath: string, token: string}, ownerState?: import("./json.js").JsonValue}} [options] - Optional authenticated legacy backend migration.
|
|
33
34
|
*/
|
|
34
35
|
async launch(options = {}) {
|
|
35
|
-
const child = spawn(process.execPath, [guardianPath, this.socketPath], {detached: true, stdio: ["ignore", "
|
|
36
|
+
const child = spawn(process.execPath, [guardianPath, this.socketPath], {detached: true, stdio: ["ignore", "inherit", "inherit", "ipc"]})
|
|
36
37
|
|
|
37
38
|
this.pid = child.pid
|
|
38
39
|
this.guardianExitPromise = new Promise((resolve) => child.once("exit", () => resolve(undefined)))
|
|
@@ -51,6 +52,7 @@ export default class GuardianClient {
|
|
|
51
52
|
if (child.connected) await new Promise((resolve) => child.once("disconnect", () => resolve(undefined)))
|
|
52
53
|
child.unref()
|
|
53
54
|
await this.connect()
|
|
55
|
+
this.generationReactivation = true
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/**
|
|
@@ -176,7 +178,20 @@ export default class GuardianClient {
|
|
|
176
178
|
* @param {import("./json.js").JsonValue} authority - Exact owner authority.
|
|
177
179
|
*/
|
|
178
180
|
async claimOwner(graceMs, authority) {
|
|
179
|
-
await this.request({authority, command: "claim-owner", graceMs})
|
|
181
|
+
await this.request({authority, command: "claim-owner", graceMs, ownerPid: process.pid})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Confirms that the claimed daemon has completed startup and published its listeners. */
|
|
185
|
+
async ownerReady() {
|
|
186
|
+
await this.request({command: "owner-ready", ownerPid: process.pid})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** @returns {Promise<{daemonRecovery: number, generationReactivation?: number}>} Guardian protocol capabilities. */
|
|
190
|
+
async capabilities() {
|
|
191
|
+
const capabilities = /** @type {{daemonRecovery: number, generationReactivation?: number}} */ (await this.request({command: "capabilities"}))
|
|
192
|
+
|
|
193
|
+
this.generationReactivation = capabilities.generationReactivation === 1
|
|
194
|
+
return capabilities
|
|
180
195
|
}
|
|
181
196
|
|
|
182
197
|
/** Starts graceful process retirement and relinquishes committed owner authority. */
|
|
@@ -221,7 +236,7 @@ export default class GuardianClient {
|
|
|
221
236
|
* @returns {Promise<{ownerState: import("./json.js").JsonValue, replacementId: string}>} Prepared transaction.
|
|
222
237
|
*/
|
|
223
238
|
async prepareOwnerReplacement(authority, nextAuthority) {
|
|
224
|
-
return /** @type {{ownerState: import("./json.js").JsonValue, replacementId: string}} */ (await this.request({authority, command: "prepare-owner-replacement", nextAuthority}))
|
|
239
|
+
return /** @type {{ownerState: import("./json.js").JsonValue, replacementId: string}} */ (await this.request({authority, command: "prepare-owner-replacement", nextAuthority, ownerPid: process.pid}))
|
|
225
240
|
}
|
|
226
241
|
|
|
227
242
|
/**
|
|
@@ -251,13 +266,24 @@ export default class GuardianClient {
|
|
|
251
266
|
await this.request({command: "commit-retired-owner-replacement", key, replacementId})
|
|
252
267
|
}
|
|
253
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Yields a control-less incumbent's listeners while it still owns authority.
|
|
271
|
+
* @param {string} replacementId - Same-authority staged transaction.
|
|
272
|
+
* @param {string} key - Exact recovered guardian process proving candidate reconstruction.
|
|
273
|
+
*/
|
|
274
|
+
async prepareRetiredOwnerListenerHandoff(replacementId, key) {
|
|
275
|
+
await this.request({command: "prepare-retired-owner-listener-handoff", key, replacementId})
|
|
276
|
+
}
|
|
277
|
+
|
|
254
278
|
/**
|
|
255
279
|
* Begins acquiring the authenticated legacy backend owner channel at the disruptive boundary.
|
|
256
280
|
* @param {string} replacementId - Exact prepared candidate transaction.
|
|
257
281
|
* @param {number} graceMs - Event-driven incumbent disconnect grace.
|
|
282
|
+
* @param {string} statePath - Durable public recovery state path.
|
|
283
|
+
* @param {import("./json.js").JsonValue} recoverySnapshot - Discoverable coordinator state.
|
|
258
284
|
*/
|
|
259
|
-
async beginLegacyOwnerClaim(replacementId, graceMs) {
|
|
260
|
-
await this.request({command: "begin-legacy-owner-claim", graceMs, replacementId})
|
|
285
|
+
async beginLegacyOwnerClaim(replacementId, graceMs, statePath, recoverySnapshot) {
|
|
286
|
+
await this.request({command: "begin-legacy-owner-claim", graceMs, recoverySnapshot, replacementId, statePath})
|
|
261
287
|
}
|
|
262
288
|
|
|
263
289
|
/**
|
|
@@ -273,6 +299,22 @@ export default class GuardianClient {
|
|
|
273
299
|
await this.request({command: "finalize-owner-replacement", replacementId})
|
|
274
300
|
}
|
|
275
301
|
|
|
302
|
+
/** @param {string} replacementId - Committed transaction with complete listener state. */
|
|
303
|
+
async completeOwnerListenerRetirement(replacementId) {
|
|
304
|
+
await this.request({command: "complete-owner-listener-retirement", replacementId})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* @param {string} replacementId - Committed control-less replacement transaction.
|
|
309
|
+
* @param {string} sourceId - Stable listener source identity.
|
|
310
|
+
* @param {string} releaseId - Retained release identity.
|
|
311
|
+
* @param {{http: number, websocket: number}} connections - Exact incumbent listener counts.
|
|
312
|
+
* @param {boolean} [localSource] - Whether the sender physically owns this source.
|
|
313
|
+
*/
|
|
314
|
+
async publishOwnerConnectionState(replacementId, sourceId, releaseId, connections, localSource = false) {
|
|
315
|
+
await this.request({command: "publish-owner-connection-state", connections, localSource, releaseId, replacementId, sourceId})
|
|
316
|
+
}
|
|
317
|
+
|
|
276
318
|
/** @param {string} replacementId - Prepared transaction to validate for listener yield. */
|
|
277
319
|
async validateOwnerReplacement(replacementId) {
|
|
278
320
|
await this.request({command: "validate-owner-replacement", replacementId})
|
|
@@ -294,9 +336,9 @@ export default class GuardianClient {
|
|
|
294
336
|
await this.request({command: "end-owner-mutation", mutationId})
|
|
295
337
|
}
|
|
296
338
|
|
|
297
|
-
/** @returns {Promise<{committedReplacementId: string | null, ownerClaimed: boolean}>} Transaction status. */
|
|
339
|
+
/** @returns {Promise<{committedReplacementId: string | null, ownerClaimed: boolean, retirementFailed?: boolean, retirementPending?: boolean, retirementReady?: boolean}>} Transaction status. */
|
|
298
340
|
async replacementStatus() {
|
|
299
|
-
return /** @type {{committedReplacementId: string | null, ownerClaimed: boolean}} */ (await this.request({command: "replacement-status"}))
|
|
341
|
+
return /** @type {{committedReplacementId: string | null, ownerClaimed: boolean, retirementFailed?: boolean, retirementPending?: boolean, retirementReady?: boolean}} */ (await this.request({command: "replacement-status"}))
|
|
300
342
|
}
|
|
301
343
|
|
|
302
344
|
/** @returns {Promise<import("./json.js").JsonValue>} Current private transfer state. */
|
|
@@ -408,6 +450,7 @@ class GuardianProcess extends ManagedProcess {
|
|
|
408
450
|
this.cachedStatus = super.status()
|
|
409
451
|
this.registration = /** @type {Promise<void> | undefined} */ (undefined)
|
|
410
452
|
this.pendingUpdate = Promise.resolve()
|
|
453
|
+
this.compatibilityReactivated = false
|
|
411
454
|
}
|
|
412
455
|
|
|
413
456
|
async ensureRegistered() {
|
|
@@ -442,19 +485,32 @@ class GuardianProcess extends ManagedProcess {
|
|
|
442
485
|
await this.pendingUpdate
|
|
443
486
|
if (lifecycleRole) this.lifecycleRole = lifecycleRole
|
|
444
487
|
this.cachedStatus = asProcessStatus(await this.client.request({command: "start", key: this.key, lifecycleRole, reason}))
|
|
488
|
+
if (this.cachedStatus.state === "running") this.compatibilityReactivated = false
|
|
445
489
|
}
|
|
446
490
|
|
|
447
|
-
/**
|
|
448
|
-
|
|
449
|
-
|
|
491
|
+
/**
|
|
492
|
+
* @param {import("./managed-process.js").ManagedProcessDefinition} definition - Updated definition.
|
|
493
|
+
* @param {import("./json.js").JsonValue} [ownerState] - Private owner state to commit with the definition.
|
|
494
|
+
* @returns {Promise<void>} Resolves once the guardian commits the replacement definition.
|
|
495
|
+
*/
|
|
496
|
+
updateDefinition(definition, ownerState) {
|
|
450
497
|
const registration = this.ensureRegistered()
|
|
498
|
+
const previousUpdate = this.pendingUpdate
|
|
499
|
+
const nextDefinition = serializableDefinition({...definition, id: this.id})
|
|
500
|
+
const nextProvenance = crypto.createHash("sha256").update(JSON.stringify(nextDefinition)).digest("hex")
|
|
451
501
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
502
|
+
const previousUpdateSettled = previousUpdate.catch(() => {
|
|
503
|
+
// The prior caller received the update failure and local provenance stayed unchanged,
|
|
504
|
+
// so a later explicit update may retry from the last guardian-committed definition.
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
this.pendingUpdate = Promise.all([registration, previousUpdateSettled]).then(async () => {
|
|
508
|
+
this.cachedStatus = asProcessStatus(await this.client.request({command: "update", definition: nextDefinition, key: this.key, ownerState, previousProvenance: this.provenance, provenance: nextProvenance}))
|
|
509
|
+
super.updateDefinition(definition)
|
|
510
|
+
this.definition = nextDefinition
|
|
511
|
+
this.provenance = nextProvenance
|
|
457
512
|
})
|
|
513
|
+
return this.pendingUpdate
|
|
458
514
|
}
|
|
459
515
|
|
|
460
516
|
async quiesce() {
|
|
@@ -480,12 +536,52 @@ class GuardianProcess extends ManagedProcess {
|
|
|
480
536
|
this.lifecycleRole = "active"
|
|
481
537
|
}
|
|
482
538
|
|
|
539
|
+
async reactivateStrict() {
|
|
540
|
+
await this.ensureRegistered()
|
|
541
|
+
await this.pendingUpdate
|
|
542
|
+
const command = this.lifecycle.reactivateCommand ? "reactivate-with-command" : "reactivate"
|
|
543
|
+
try {
|
|
544
|
+
this.cachedStatus = asProcessStatus(await this.client.request({command, key: this.key}))
|
|
545
|
+
this.client.generationReactivation = true
|
|
546
|
+
this.compatibilityReactivated = false
|
|
547
|
+
} catch (error) {
|
|
548
|
+
if (!(error instanceof Error) || error.message !== `Unknown guardian command: ${command}`) throw error
|
|
549
|
+
this.client.generationReactivation = false
|
|
550
|
+
await this.adoptRetainedActive({activate: true})
|
|
551
|
+
}
|
|
552
|
+
this.lifecycleRole = "active"
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Bridges a retained quiesced process owned by a pre-reactivation guardian.
|
|
557
|
+
* @param {{activate: boolean}} options - Whether to run the external activation acknowledgement.
|
|
558
|
+
*/
|
|
559
|
+
async adoptRetainedActive({activate}) {
|
|
560
|
+
const before = this.cachedStatus
|
|
561
|
+
|
|
562
|
+
if (!before.pid || (before.state !== "quiesced" && before.state !== "running")) throw new Error(`Process ${this.id} is not retained for compatible reactivation`)
|
|
563
|
+
if (activate) await this.runReactivationHook(before.pid)
|
|
564
|
+
const verified = asProcessStatus(await this.client.request({command: "status", key: this.key}))
|
|
565
|
+
|
|
566
|
+
if (verified.pid !== before.pid || (verified.state !== "quiesced" && verified.state !== "running")) {
|
|
567
|
+
throw new Error(`Process ${this.id} exited before compatible reactivation completed`)
|
|
568
|
+
}
|
|
569
|
+
const adopted = asProcessStatus(await this.client.request({command: "start", key: this.key, lifecycleRole: "active", reason: "deploy"}))
|
|
570
|
+
|
|
571
|
+
if (adopted.pid !== before.pid) throw new Error(`Process ${this.id} changed before compatible reactivation was adopted`)
|
|
572
|
+
this.cachedStatus = asProcessStatus({...adopted, lifecycleRole: "active", state: "running"})
|
|
573
|
+
this.compatibilityReactivated = true
|
|
574
|
+
}
|
|
575
|
+
|
|
483
576
|
/** @param {import("./managed-process.js").LifecycleRole} role - Exact generation role. */
|
|
484
577
|
async setLifecycleRole(role) {
|
|
485
578
|
await this.ensureRegistered()
|
|
486
579
|
await this.pendingUpdate
|
|
487
580
|
this.cachedStatus = asProcessStatus(await this.client.request({command: "set-lifecycle-role", key: this.key, lifecycleRole: role}))
|
|
488
581
|
this.lifecycleRole = role
|
|
582
|
+
if (role === "active" && this.client.generationReactivation === false && this.cachedStatus.state === "quiesced") {
|
|
583
|
+
await this.adoptRetainedActive({activate: false})
|
|
584
|
+
}
|
|
489
585
|
}
|
|
490
586
|
|
|
491
587
|
async stop(options = {}) {
|
|
@@ -501,8 +597,17 @@ class GuardianProcess extends ManagedProcess {
|
|
|
501
597
|
/** @param {Record<string, import("./json.js").JsonValue>} event - Guardian event. */
|
|
502
598
|
onGuardianEvent(event) {
|
|
503
599
|
if (event.status) this.cachedStatus = asProcessStatus(event.status)
|
|
600
|
+
if (this.compatibilityReactivated && (this.cachedStatus.state === "failed" || this.cachedStatus.state === "stopped") && this.shouldRestart()) {
|
|
601
|
+
this.compatibilityReactivated = false
|
|
602
|
+
void this.start("crash", this.lifecycleRole).catch((error) => {
|
|
603
|
+
this.logger("compatible reactivated process restart failed", {error: error instanceof Error ? error.message : String(error), id: this.id})
|
|
604
|
+
})
|
|
605
|
+
}
|
|
504
606
|
if (event.event === "process-log") {
|
|
505
|
-
|
|
607
|
+
const entry = asProcessLog(event.entry)
|
|
608
|
+
|
|
609
|
+
this.cachedStatus = {...this.cachedStatus, logs: [...this.cachedStatus.logs, entry].slice(-this.outputLines)}
|
|
610
|
+
this.emit("log", entry)
|
|
506
611
|
return
|
|
507
612
|
}
|
|
508
613
|
if (event.message === "process started") this.emit("started")
|
package/src/managed-process.js
CHANGED
|
@@ -5,6 +5,7 @@ import {spawn} from "node:child_process"
|
|
|
5
5
|
import {processGroupHasLiveMembers, processGroupMembers} from "./process-memory.js"
|
|
6
6
|
|
|
7
7
|
const ACTIVATION_HOOK_TIMEOUT_MS = 30000
|
|
8
|
+
const MAX_BUFFERED_OUTPUT_CHARACTERS = 64 * 1024
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* @typedef {import("./json.js").JsonValue} JsonValue
|
|
@@ -54,6 +55,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
54
55
|
this.state = /** @type {ManagedProcessState} */ ("stopped")
|
|
55
56
|
this.lastStartReason = /** @type {ManagedProcessStartReason | undefined} */ (undefined)
|
|
56
57
|
this.logs = /** @type {ManagedProcessLog[]} */ ([])
|
|
58
|
+
this.outputBuffers = {stderr: "", stdout: ""}
|
|
57
59
|
this.restarts = 0
|
|
58
60
|
this.recentRestarts = /** @type {number[]} */ ([])
|
|
59
61
|
this.rssBytes = /** @type {number | undefined} */ (undefined)
|
|
@@ -67,9 +69,11 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
67
69
|
this.intentionalStop = false
|
|
68
70
|
this.lifecycleRole = /** @type {LifecycleRole} */ ("candidate")
|
|
69
71
|
this.intentionalStopSignal = /** @type {ProcessExitSignal | undefined} */ (undefined)
|
|
72
|
+
this.lifecycleRestoreBarrier = /** @type {Promise<void> | undefined} */ (undefined)
|
|
70
73
|
this.quiescePromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
71
74
|
this.quiesceError = /** @type {Error | undefined} */ (undefined)
|
|
72
75
|
this.stopPromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
76
|
+
this.operationRevision = 0
|
|
73
77
|
this.restartTimer = undefined
|
|
74
78
|
this.child = undefined
|
|
75
79
|
this.exitPromise = undefined
|
|
@@ -84,6 +88,10 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
84
88
|
* @returns {Promise<void>} Resolves after spawn and lifecycle-role restoration.
|
|
85
89
|
*/
|
|
86
90
|
async start(reason = "deploy", lifecycleRole) {
|
|
91
|
+
const operationRevision = ++this.operationRevision
|
|
92
|
+
|
|
93
|
+
if (this.stopPromise) await this.stopPromise
|
|
94
|
+
if (operationRevision !== this.operationRevision) return
|
|
87
95
|
if (lifecycleRole) this.lifecycleRole = lifecycleRole
|
|
88
96
|
if (this.child) return
|
|
89
97
|
|
|
@@ -97,6 +105,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
97
105
|
this.state = "starting"
|
|
98
106
|
|
|
99
107
|
await new Promise((resolve, reject) => {
|
|
108
|
+
const outputBuffers = {stderr: "", stdout: ""}
|
|
100
109
|
const child = spawn(this.command, {
|
|
101
110
|
cwd: this.cwd,
|
|
102
111
|
detached: true,
|
|
@@ -118,18 +127,28 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
118
127
|
void (async () => {
|
|
119
128
|
this.startedAtMs = Date.now()
|
|
120
129
|
this.lastStartReason = reason
|
|
130
|
+
const lifecycleRestore = this.restoreLifecycleRole()
|
|
131
|
+
const lifecycleRestoreBarrier = lifecycleRestore.then(() => undefined, () => undefined)
|
|
132
|
+
|
|
133
|
+
this.lifecycleRestoreBarrier = lifecycleRestoreBarrier
|
|
121
134
|
try {
|
|
122
|
-
await
|
|
135
|
+
await lifecycleRestore
|
|
123
136
|
} catch (error) {
|
|
124
137
|
this.state = "failed"
|
|
125
138
|
this.logger("process lifecycle role restoration failed", {error: error instanceof Error ? error.message : String(error), id: this.id, role: this.lifecycleRole})
|
|
126
139
|
reject(error)
|
|
127
140
|
return
|
|
141
|
+
} finally {
|
|
142
|
+
if (this.lifecycleRestoreBarrier === lifecycleRestoreBarrier) this.lifecycleRestoreBarrier = undefined
|
|
128
143
|
}
|
|
129
144
|
if (this.child !== child) {
|
|
130
145
|
reject(new Error(`Process ${this.id} exited before lifecycle role ${this.lifecycleRole} was restored`))
|
|
131
146
|
return
|
|
132
147
|
}
|
|
148
|
+
if (this.intentionalStop || this.state !== "starting") {
|
|
149
|
+
reject(new Error(`Process ${this.id} was quiesced before lifecycle role ${this.lifecycleRole} was restored`))
|
|
150
|
+
return
|
|
151
|
+
}
|
|
133
152
|
this.state = "running"
|
|
134
153
|
this.logger("process started", {command: this.command, id: this.id, pid: child.pid || null, reason})
|
|
135
154
|
this.startMemoryMonitor()
|
|
@@ -148,17 +167,20 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
148
167
|
})
|
|
149
168
|
child.stdout.setEncoding("utf8")
|
|
150
169
|
child.stderr.setEncoding("utf8")
|
|
151
|
-
child.stdout.on("data", (chunk) => this.appendLog("stdout", chunk))
|
|
152
|
-
child.
|
|
170
|
+
child.stdout.on("data", (chunk) => this.appendLog("stdout", chunk, outputBuffers))
|
|
171
|
+
child.stdout.on("end", () => this.flushLogBuffer("stdout", outputBuffers))
|
|
172
|
+
child.stderr.on("data", (chunk) => this.appendLog("stderr", chunk, outputBuffers))
|
|
173
|
+
child.stderr.on("end", () => this.flushLogBuffer("stderr", outputBuffers))
|
|
153
174
|
})
|
|
154
175
|
}
|
|
155
176
|
|
|
156
177
|
/**
|
|
157
178
|
* Updates the command template used for future restarts without touching the currently running child.
|
|
158
179
|
* @param {ManagedProcessDefinition} definition - Replacement process definition.
|
|
159
|
-
* @
|
|
180
|
+
* @param {import("./json.js").JsonValue} [_ownerState] - Private owner state committed atomically by remote implementations.
|
|
181
|
+
* @returns {void | Promise<void>} Definition replacement completion for remote implementations.
|
|
160
182
|
*/
|
|
161
|
-
updateDefinition(definition) {
|
|
183
|
+
updateDefinition(definition, _ownerState) {
|
|
162
184
|
this.command = definition.command
|
|
163
185
|
this.cwd = definition.cwd
|
|
164
186
|
this.env = definition.env
|
|
@@ -176,10 +198,19 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
176
198
|
/**
|
|
177
199
|
* @param {"stdout" | "stderr"} stream - Stream name.
|
|
178
200
|
* @param {string} chunk - Output chunk.
|
|
201
|
+
* @param {{stderr: string, stdout: string}} [buffers] - Per-process stream fragments.
|
|
179
202
|
* @returns {void}
|
|
180
203
|
*/
|
|
181
|
-
appendLog(stream, chunk) {
|
|
182
|
-
|
|
204
|
+
appendLog(stream, chunk, buffers = this.outputBuffers) {
|
|
205
|
+
const lines = `${buffers[stream]}${String(chunk)}`.split(/\r?\n/)
|
|
206
|
+
let fragment = lines.pop() ?? ""
|
|
207
|
+
|
|
208
|
+
while (fragment.length > MAX_BUFFERED_OUTPUT_CHARACTERS) {
|
|
209
|
+
lines.push(fragment.slice(0, MAX_BUFFERED_OUTPUT_CHARACTERS))
|
|
210
|
+
fragment = fragment.slice(MAX_BUFFERED_OUTPUT_CHARACTERS)
|
|
211
|
+
}
|
|
212
|
+
buffers[stream] = fragment
|
|
213
|
+
for (const line of lines) {
|
|
183
214
|
if (!line) continue
|
|
184
215
|
|
|
185
216
|
const entry = {at: new Date().toISOString(), line, stream}
|
|
@@ -193,6 +224,15 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
193
224
|
}
|
|
194
225
|
}
|
|
195
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Retains a final output line that did not end with a newline.
|
|
229
|
+
* @param {"stdout" | "stderr"} stream - Stream name.
|
|
230
|
+
* @param {{stderr: string, stdout: string}} buffers - Per-process stream fragments.
|
|
231
|
+
*/
|
|
232
|
+
flushLogBuffer(stream, buffers) {
|
|
233
|
+
if (buffers[stream]) this.appendLog(stream, "\n", buffers)
|
|
234
|
+
}
|
|
235
|
+
|
|
196
236
|
/**
|
|
197
237
|
* @param {number | null} code - Exit code.
|
|
198
238
|
* @param {ProcessExitSignal} signal - Exit signal.
|
|
@@ -375,6 +415,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
375
415
|
* @returns {Promise<void>} Resolves when stopped.
|
|
376
416
|
*/
|
|
377
417
|
async stop(options = {}) {
|
|
418
|
+
this.operationRevision += 1
|
|
378
419
|
if (!this.stopPromise) this.stopPromise = this.performStop(options)
|
|
379
420
|
return await this.stopPromise
|
|
380
421
|
}
|
|
@@ -442,16 +483,17 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
442
483
|
clearTimeout(this.restartTimer)
|
|
443
484
|
this.restartTimer = undefined
|
|
444
485
|
}
|
|
486
|
+
if (this.lifecycleRestoreBarrier) await this.lifecycleRestoreBarrier
|
|
445
487
|
if (!this.child?.pid) {
|
|
446
488
|
this.state = "stopped"
|
|
447
|
-
if (this.
|
|
489
|
+
if (this.hasRestorableLifecycle()) this.lifecycleRole = "retired"
|
|
448
490
|
return
|
|
449
491
|
}
|
|
450
492
|
this.state = "stopping"
|
|
451
493
|
if (this.lifecycle.quietCommand) this.quiesceError = await this.runHook(this.lifecycle.quietCommand, this.hookTimeoutMs(), "quiet command")
|
|
452
494
|
if (!this.quiesceError) {
|
|
453
495
|
this.state = "quiesced"
|
|
454
|
-
if (this.
|
|
496
|
+
if (this.hasRestorableLifecycle()) this.lifecycleRole = "retired"
|
|
455
497
|
}
|
|
456
498
|
})()
|
|
457
499
|
return await this.quiescePromise
|
|
@@ -475,12 +517,69 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
475
517
|
const command = this.lifecycle.activateCommand
|
|
476
518
|
|
|
477
519
|
if (!command) return
|
|
478
|
-
const
|
|
520
|
+
const child = this.child
|
|
521
|
+
const pid = this.pid
|
|
479
522
|
|
|
480
|
-
if (
|
|
523
|
+
if (!child?.pid || child.pid !== pid || this.state !== "running") throw new Error(`Process ${this.id} is not running for activation`)
|
|
524
|
+
await this.runActivationHook(pid)
|
|
525
|
+
if (this.child !== child || this.pid !== pid || this.state !== "running") throw new Error(`Process ${this.id} exited before activation completed`)
|
|
481
526
|
this.lifecycleRole = "active"
|
|
482
527
|
}
|
|
483
528
|
|
|
529
|
+
/** Reactivates an explicitly retired process without replacing its release-scoped process. */
|
|
530
|
+
async reactivateStrict() {
|
|
531
|
+
const child = this.child
|
|
532
|
+
const pid = this.pid
|
|
533
|
+
|
|
534
|
+
if (!child?.pid || child.pid !== pid || (this.state !== "quiesced" && this.state !== "running")) {
|
|
535
|
+
throw new Error(`Process ${this.id} is not retained for reactivation`)
|
|
536
|
+
}
|
|
537
|
+
await this.runReactivationHook(pid)
|
|
538
|
+
if (this.child !== child || this.pid !== pid) throw new Error(`Process ${this.id} exited before reactivation completed`)
|
|
539
|
+
this.intentionalStop = false
|
|
540
|
+
this.intentionalStopSignal = undefined
|
|
541
|
+
this.quiescePromise = undefined
|
|
542
|
+
this.quiesceError = undefined
|
|
543
|
+
this.state = "running"
|
|
544
|
+
this.lifecycleRole = "active"
|
|
545
|
+
this.startMemoryMonitor()
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Runs the generation activation hook against one exact retained process.
|
|
550
|
+
* @param {number | undefined} pid - Exact process group leader.
|
|
551
|
+
* @returns {Promise<void>} Resolves after acknowledgement.
|
|
552
|
+
*/
|
|
553
|
+
async runActivationHook(pid) {
|
|
554
|
+
const command = this.lifecycle.activateCommand
|
|
555
|
+
|
|
556
|
+
if (!command) return
|
|
557
|
+
const error = await this.runHook(command, ACTIVATION_HOOK_TIMEOUT_MS, "activate command", pid)
|
|
558
|
+
|
|
559
|
+
if (error) throw error
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Runs the process-specific reactivation hook, falling back to the generation
|
|
564
|
+
* coordinator's activation hook for the original lifecycle contract.
|
|
565
|
+
* @param {number | undefined} pid - Exact process group leader.
|
|
566
|
+
* @returns {Promise<void>} Resolves after acknowledgement.
|
|
567
|
+
*/
|
|
568
|
+
async runReactivationHook(pid) {
|
|
569
|
+
const command = this.lifecycle.reactivateCommand ?? this.lifecycle.activateCommand
|
|
570
|
+
|
|
571
|
+
if (!command) return
|
|
572
|
+
const label = this.lifecycle.reactivateCommand ? "reactivate command" : "activate command"
|
|
573
|
+
const error = await this.runHook(command, ACTIVATION_HOOK_TIMEOUT_MS, label, pid)
|
|
574
|
+
|
|
575
|
+
if (error) throw error
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** @returns {boolean} Whether this process owns a durable external lifecycle role. */
|
|
579
|
+
hasRestorableLifecycle() {
|
|
580
|
+
return Boolean(this.lifecycle.activateCommand || this.lifecycle.reactivateCommand)
|
|
581
|
+
}
|
|
582
|
+
|
|
484
583
|
/**
|
|
485
584
|
* Records the durable desired role without firing a lifecycle command.
|
|
486
585
|
* @param {LifecycleRole} role - Exact role owned by this process generation.
|
|
@@ -491,12 +590,15 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
491
590
|
|
|
492
591
|
/** Restores an active or retired role after this exact process starts. */
|
|
493
592
|
async restoreLifecycleRole() {
|
|
494
|
-
if (!this.
|
|
495
|
-
const command = this.lifecycleRole === "active"
|
|
593
|
+
if (!this.hasRestorableLifecycle() || this.lifecycleRole === "candidate") return
|
|
594
|
+
const command = this.lifecycleRole === "active"
|
|
595
|
+
? this.lifecycle.reactivateCommand ?? this.lifecycle.activateCommand
|
|
596
|
+
: this.lifecycle.quietCommand
|
|
496
597
|
|
|
497
598
|
if (!command) throw new Error(`Process ${this.id} cannot restore lifecycle role ${this.lifecycleRole} without its paired command`)
|
|
498
599
|
const timeoutMs = this.lifecycleRole === "active" ? ACTIVATION_HOOK_TIMEOUT_MS : this.hookTimeoutMs()
|
|
499
|
-
const
|
|
600
|
+
const activeLabel = this.lifecycle.reactivateCommand ? "reactivate" : "activate"
|
|
601
|
+
const error = await this.runHook(command, timeoutMs, `${this.lifecycleRole === "active" ? activeLabel : "quiet"} command`, this.pid)
|
|
500
602
|
|
|
501
603
|
if (error) throw error
|
|
502
604
|
}
|
|
@@ -713,7 +815,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
713
815
|
id: this.id,
|
|
714
816
|
lastMemoryRestartAt: this.lastMemoryRestartAtMs === undefined ? undefined : new Date(this.lastMemoryRestartAtMs).toISOString(),
|
|
715
817
|
lastStartReason: this.lastStartReason,
|
|
716
|
-
...(this.
|
|
818
|
+
...(this.hasRestorableLifecycle() ? {lifecycleRole: this.lifecycleRole} : {}),
|
|
717
819
|
logs: this.logs.slice(-this.outputLines),
|
|
718
820
|
memoryRestarts: this.memoryRestarts,
|
|
719
821
|
pid: this.pid,
|