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.
- package/AGENTS.md +14 -0
- package/README.md +69 -14
- package/TODO.md +5 -2
- package/changelog.d/20260828-atomic-owner-replacement.md +22 -0
- package/changelog.d/20260828-durable-owner-recovery.md +7 -0
- package/changelog.d/20260828-same-owner-jobs-generations.md +6 -0
- package/docs/cli.md +43 -21
- package/docs/config.md +71 -18
- package/docs/logging.md +8 -3
- package/docs/tensorbuzz-runbook.md +7 -6
- package/docs/troubleshooting.md +28 -12
- package/docs/velocious.md +11 -4
- package/docs/workers.md +8 -2
- package/examples/tensorbuzz.com.js +12 -4
- package/package.json +1 -1
- package/src/cli.js +209 -36
- package/src/config.js +8 -2
- package/src/control-client.js +118 -1
- package/src/daemon.js +939 -53
- package/src/guardian-client.js +434 -0
- package/src/managed-process.js +45 -15
- package/src/process-guardian.js +601 -0
- package/src/release-group.js +190 -15
- package/src/state-store.js +1 -1
- package/test/config-validation.test.js +22 -0
- package/test/fixtures/pre-split3-daemon-runner.js +30 -0
- package/test/fixtures/pre-split3-daemon.js +1336 -0
- package/test/fixtures/pre-split3-guardian-client.js +293 -0
- package/test/fixtures/pre-split3-process-guardian.js +292 -0
- package/test/fixtures/service-app.js +32 -2
- package/test/guardian-client.test.js +304 -0
- package/test/owner-recovery.test.js +950 -0
- package/test/owner-replacement.test.js +772 -0
- package/test/release-runtime-retention.test.js +1 -1
- package/test/rollbridge.test.js +178 -5
- package/test/shutdown-completion.test.js +1 -1
- 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
|
+
}
|
package/src/managed-process.js
CHANGED
|
@@ -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
|
|
423
|
-
*
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
|
|
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)
|
|
474
|
-
|
|
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
|
-
|
|
482
|
-
|
|
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
|
}
|