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,293 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
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 "../../src/managed-process.js"
|
|
8
|
+
|
|
9
|
+
const guardianPath = fileURLToPath(new URL("./pre-split3-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
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Launches a new detached guardian and connects to it after its bind acknowledgement. */
|
|
27
|
+
async launch() {
|
|
28
|
+
const child = spawn(process.execPath, [guardianPath, this.socketPath], {detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"]})
|
|
29
|
+
|
|
30
|
+
this.pid = child.pid
|
|
31
|
+
this.guardianExitPromise = new Promise((resolve) => child.once("exit", () => resolve(undefined)))
|
|
32
|
+
|
|
33
|
+
await new Promise((resolve, reject) => {
|
|
34
|
+
child.once("error", reject)
|
|
35
|
+
child.once("exit", (code) => reject(new Error(`Process guardian exited before readiness with status ${code}`)))
|
|
36
|
+
child.once("message", (message) => {
|
|
37
|
+
if (message && typeof message === "object" && "error" in message) reject(new Error(String(message.error)))
|
|
38
|
+
else resolve(undefined)
|
|
39
|
+
})
|
|
40
|
+
child.send({token: this.token}, (error) => {
|
|
41
|
+
if (error) reject(error)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
if (child.connected) await new Promise((resolve) => child.once("disconnect", () => resolve(undefined)))
|
|
45
|
+
child.unref()
|
|
46
|
+
await this.connect()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Connects to an existing guardian. */
|
|
50
|
+
async connect() {
|
|
51
|
+
if (this.socket && !this.socket.destroyed) return
|
|
52
|
+
const socket = net.createConnection(this.socketPath)
|
|
53
|
+
|
|
54
|
+
socket.setEncoding("utf8")
|
|
55
|
+
await new Promise((resolve, reject) => {
|
|
56
|
+
socket.once("connect", resolve)
|
|
57
|
+
socket.once("error", reject)
|
|
58
|
+
})
|
|
59
|
+
socket.on("data", (chunk) => this.onData(String(chunk)))
|
|
60
|
+
socket.once("close", () => {
|
|
61
|
+
for (const {command, reject} of this.pending.values()) reject(new Error(`Process guardian connection closed while awaiting ${command}`))
|
|
62
|
+
this.pending.clear()
|
|
63
|
+
this.resolveIdleWaiters()
|
|
64
|
+
})
|
|
65
|
+
this.socket = socket
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {string} key - Stable process identity.
|
|
70
|
+
* @param {ConstructorParameters<typeof ManagedProcess>[0]} definition - Process definition.
|
|
71
|
+
* @returns {GuardianProcess} Remote managed process.
|
|
72
|
+
*/
|
|
73
|
+
process(key, definition) {
|
|
74
|
+
const processInstance = new GuardianProcess({client: this, definition, key})
|
|
75
|
+
|
|
76
|
+
this.processes.set(key, processInstance)
|
|
77
|
+
return processInstance
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {Record<string, import("./json.js").JsonValue>} command - Command.
|
|
82
|
+
* @returns {Promise<import("./json.js").JsonValue>} Guardian response.
|
|
83
|
+
*/
|
|
84
|
+
async request(command) {
|
|
85
|
+
if (!this.socket || this.socket.destroyed) throw new Error("Process guardian is not connected")
|
|
86
|
+
this.nextId += 1
|
|
87
|
+
const id = this.nextId
|
|
88
|
+
const response = new Promise((resolve, reject) => this.pending.set(id, {command: String(command.command), reject, resolve}))
|
|
89
|
+
|
|
90
|
+
this.socket.write(`${JSON.stringify({...command, id, token: this.token})}\n`)
|
|
91
|
+
return await response
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Stops the guardian after every owned process has stopped. */
|
|
95
|
+
async shutdown() {
|
|
96
|
+
if (this.pending.size > 0) {
|
|
97
|
+
await new Promise((resolve) => {
|
|
98
|
+
this.idleWaiters.push(() => { resolve(undefined) })
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
await this.request({command: "shutdown"})
|
|
102
|
+
const socket = this.socket
|
|
103
|
+
|
|
104
|
+
if (!socket || socket.destroyed) throw new Error("Process guardian disconnected before shutdown acknowledgement")
|
|
105
|
+
const closed = new Promise((resolve) => socket.once("close", () => resolve(undefined)))
|
|
106
|
+
|
|
107
|
+
socket.end()
|
|
108
|
+
await closed
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Waits for a guardian launched by this client to exit. */
|
|
112
|
+
async guardianExit() {
|
|
113
|
+
if (!this.guardianExitPromise) throw new Error("Guardian exit is observable only from the launching client")
|
|
114
|
+
await this.guardianExitPromise
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @param {number} graceMs - Event-driven handoff grace while the prior owner disconnects. */
|
|
118
|
+
async claimOwner(graceMs) {
|
|
119
|
+
await this.request({command: "claim-owner", graceMs})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @returns {Promise<{key: string, provenance: string, status: import("./managed-process.js").ManagedProcessStatus}[]>} Exact guardian-owned inventory. */
|
|
123
|
+
async inventory() {
|
|
124
|
+
return /** @type {{key: string, provenance: string, status: import("./managed-process.js").ManagedProcessStatus}[]} */ (await this.request({command: "inventory"}))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Stops and forgets one exact guardian-owned registration.
|
|
129
|
+
* @param {string} key - Stable guardian registration key.
|
|
130
|
+
* @param {string} provenance - Exact expected process-definition provenance.
|
|
131
|
+
*/
|
|
132
|
+
async remove(key, provenance) {
|
|
133
|
+
await this.request({command: "remove", key, provenance})
|
|
134
|
+
this.processes.delete(key)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Stops guardian registrations absent from the reconstructed durable snapshot. */
|
|
138
|
+
async reconcileInventory() {
|
|
139
|
+
const unexpected = (await this.inventory()).filter((entry) => !this.processes.has(entry.key))
|
|
140
|
+
const results = await Promise.allSettled(unexpected.map((entry) => this.remove(entry.key, entry.provenance)))
|
|
141
|
+
const errors = results.filter((result) => result.status === "rejected").map((result) => result.reason)
|
|
142
|
+
|
|
143
|
+
if (errors.length > 0) {
|
|
144
|
+
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("; ")}`)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Disconnects a fenced startup loser without changing guardian-owned processes. */
|
|
149
|
+
disconnect() {
|
|
150
|
+
this.socket?.destroy()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Resolves shutdown barriers after every earlier request settles. */
|
|
154
|
+
resolveIdleWaiters() {
|
|
155
|
+
if (this.pending.size > 0) return
|
|
156
|
+
for (const resolve of this.idleWaiters.splice(0)) resolve()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @param {string} chunk - Protocol bytes. */
|
|
160
|
+
onData(chunk) {
|
|
161
|
+
this.buffer += chunk
|
|
162
|
+
let newline = this.buffer.indexOf("\n")
|
|
163
|
+
|
|
164
|
+
while (newline >= 0) {
|
|
165
|
+
const line = this.buffer.slice(0, newline)
|
|
166
|
+
const message = JSON.parse(line)
|
|
167
|
+
|
|
168
|
+
this.buffer = this.buffer.slice(newline + 1)
|
|
169
|
+
if (message.event) {
|
|
170
|
+
this.processes.get(message.key)?.onGuardianEvent(message)
|
|
171
|
+
} else {
|
|
172
|
+
const pending = this.pending.get(message.id)
|
|
173
|
+
|
|
174
|
+
this.pending.delete(message.id)
|
|
175
|
+
this.resolveIdleWaiters()
|
|
176
|
+
if (message.error) pending?.reject(new Error(message.error))
|
|
177
|
+
else pending?.resolve(message.result)
|
|
178
|
+
}
|
|
179
|
+
newline = this.buffer.indexOf("\n")
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
class GuardianProcess extends ManagedProcess {
|
|
185
|
+
/** @param {{client: GuardianClient, definition: ConstructorParameters<typeof ManagedProcess>[0], key: string}} args - Remote process args. */
|
|
186
|
+
constructor({client, definition, key}) {
|
|
187
|
+
super(definition)
|
|
188
|
+
this.client = client
|
|
189
|
+
this.key = key
|
|
190
|
+
this.definition = serializableDefinition(definition)
|
|
191
|
+
this.provenance = crypto.createHash("sha256").update(JSON.stringify(this.definition)).digest("hex")
|
|
192
|
+
this.cachedStatus = super.status()
|
|
193
|
+
this.registration = /** @type {Promise<void> | undefined} */ (undefined)
|
|
194
|
+
this.pendingUpdate = Promise.resolve()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async ensureRegistered() {
|
|
198
|
+
if (!this.registration) {
|
|
199
|
+
this.registration = this.client.request({command: "register", definition: this.definition, key: this.key, provenance: this.provenance})
|
|
200
|
+
.then((status) => { this.cachedStatus = asProcessStatus(status) })
|
|
201
|
+
}
|
|
202
|
+
await this.registration
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Reconnects to an already registered guardian process without changing its desired state. */
|
|
206
|
+
async recover() {
|
|
207
|
+
await this.ensureRegistered()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async start(reason = "deploy") {
|
|
211
|
+
await this.ensureRegistered()
|
|
212
|
+
await this.pendingUpdate
|
|
213
|
+
this.cachedStatus = asProcessStatus(await this.client.request({command: "start", key: this.key, reason}))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @param {import("./managed-process.js").ManagedProcessDefinition} definition - Updated definition. */
|
|
217
|
+
updateDefinition(definition) {
|
|
218
|
+
const previousProvenance = this.provenance
|
|
219
|
+
const registration = this.ensureRegistered()
|
|
220
|
+
|
|
221
|
+
super.updateDefinition(definition)
|
|
222
|
+
this.definition = serializableDefinition(this)
|
|
223
|
+
this.provenance = crypto.createHash("sha256").update(JSON.stringify(this.definition)).digest("hex")
|
|
224
|
+
this.pendingUpdate = registration.then(async () => {
|
|
225
|
+
this.cachedStatus = asProcessStatus(await this.client.request({command: "update", definition: this.definition, key: this.key, previousProvenance, provenance: this.provenance}))
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async quiesce() {
|
|
230
|
+
await this.ensureRegistered()
|
|
231
|
+
await this.pendingUpdate
|
|
232
|
+
this.cachedStatus = asProcessStatus(await this.client.request({command: "quiesce", key: this.key}))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async quiesceStrict() {
|
|
236
|
+
await this.quiesce()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async stop(options = {}) {
|
|
240
|
+
await this.ensureRegistered()
|
|
241
|
+
await this.pendingUpdate
|
|
242
|
+
this.cachedStatus = asProcessStatus(await this.client.request({command: "stop", key: this.key, options}))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
status() {
|
|
246
|
+
return this.cachedStatus
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** @param {Record<string, import("./json.js").JsonValue>} event - Guardian event. */
|
|
250
|
+
onGuardianEvent(event) {
|
|
251
|
+
if (event.status) this.cachedStatus = asProcessStatus(event.status)
|
|
252
|
+
if (event.message === "process started") this.emit("started")
|
|
253
|
+
if (event.message === "process exited") this.emit("exit", event.data)
|
|
254
|
+
this.logger(typeof event.message === "string" ? event.message : "guardian process status", event.data && typeof event.data === "object" && !Array.isArray(event.data) ? event.data : {})
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {ConstructorParameters<typeof ManagedProcess>[0] | ManagedProcess} definition - Managed definition.
|
|
260
|
+
* @returns {Record<string, import("./json.js").JsonValue>} Serializable definition.
|
|
261
|
+
*/
|
|
262
|
+
function serializableDefinition(definition) {
|
|
263
|
+
return {
|
|
264
|
+
command: definition.command,
|
|
265
|
+
cwd: definition.cwd,
|
|
266
|
+
env: definition.env,
|
|
267
|
+
id: definition.id,
|
|
268
|
+
lifecycle: definition.lifecycle,
|
|
269
|
+
memory: definition.memory,
|
|
270
|
+
outputLines: definition.outputLines,
|
|
271
|
+
restart: definition.restart,
|
|
272
|
+
restartDelayMs: definition.restartDelayMs,
|
|
273
|
+
stopSignal: definition.stopSignal,
|
|
274
|
+
stopTimeoutMs: definition.stopTimeoutMs
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* @param {import("./json.js").JsonValue} value - Protocol value.
|
|
280
|
+
* @returns {import("./managed-process.js").ManagedProcessStatus} Process status.
|
|
281
|
+
*/
|
|
282
|
+
function asProcessStatus(value) {
|
|
283
|
+
return JSON.parse(JSON.stringify(value))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @param {Error | string} error - Error-like value.
|
|
288
|
+
* @returns {string} Error message.
|
|
289
|
+
*/
|
|
290
|
+
function errorMessage(error) {
|
|
291
|
+
return error instanceof Error ? error.message : String(error)
|
|
292
|
+
}
|
|
293
|
+
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs/promises"
|
|
4
|
+
import net from "node:net"
|
|
5
|
+
import ManagedProcess from "../../src/managed-process.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {object} GuardianRequest
|
|
9
|
+
* @property {string} command - Operation name.
|
|
10
|
+
* @property {ConstructorParameters<typeof ManagedProcess>[0]} [definition] - Managed process definition.
|
|
11
|
+
* @property {number} [graceMs] - Owner reconnection grace.
|
|
12
|
+
* @property {number} id - Request id.
|
|
13
|
+
* @property {string} [key] - Stable process key.
|
|
14
|
+
* @property {{timeoutMs?: number}} [options] - Stop options.
|
|
15
|
+
* @property {string} [previousProvenance] - Expected current provenance.
|
|
16
|
+
* @property {string} [provenance] - New or registered provenance.
|
|
17
|
+
* @property {import("../../src/managed-process.js").ManagedProcessStartReason} [reason] - Start reason.
|
|
18
|
+
* @property {string} token - Authentication token.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const [socketPath] = process.argv.slice(2)
|
|
22
|
+
|
|
23
|
+
if (!socketPath || !process.send) throw new Error("process-guardian requires socket path and a private bootstrap channel")
|
|
24
|
+
|
|
25
|
+
const token = await new Promise((resolve, reject) => {
|
|
26
|
+
process.once("disconnect", () => reject(new Error("Guardian bootstrap channel closed before authentication capability arrived")))
|
|
27
|
+
process.once("message", (message) => {
|
|
28
|
+
if (!message || typeof message !== "object" || !("token" in message) || typeof message.token !== "string" || !message.token) {
|
|
29
|
+
reject(new Error("Guardian bootstrap authentication capability is invalid"))
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
resolve(message.token)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/** @type {Map<string, {desired: boolean, process: ManagedProcess, provenance: string}>} */
|
|
37
|
+
const processes = new Map()
|
|
38
|
+
/** @type {Set<net.Socket>} */
|
|
39
|
+
const clients = new Set()
|
|
40
|
+
/** @type {net.Socket | undefined} */
|
|
41
|
+
let ownerClient
|
|
42
|
+
let shuttingDown = false
|
|
43
|
+
/** @type {net.Socket | undefined} */
|
|
44
|
+
let shutdownClient
|
|
45
|
+
let shutdownFinalizing = false
|
|
46
|
+
/** @type {Promise<void> | undefined} */
|
|
47
|
+
let serverClosed
|
|
48
|
+
/** @type {{reject: (error: Error) => void, resolve: (value: {claimed: boolean}) => void, socket: net.Socket, timer: ReturnType<typeof setTimeout>}[]} */
|
|
49
|
+
const claimWaiters = []
|
|
50
|
+
|
|
51
|
+
const server = net.createServer((socket) => {
|
|
52
|
+
clients.add(socket)
|
|
53
|
+
socket.setEncoding("utf8")
|
|
54
|
+
let buffer = ""
|
|
55
|
+
|
|
56
|
+
socket.once("close", () => {
|
|
57
|
+
clients.delete(socket)
|
|
58
|
+
const waiterIndex = claimWaiters.findIndex((waiter) => waiter.socket === socket)
|
|
59
|
+
|
|
60
|
+
if (waiterIndex >= 0) {
|
|
61
|
+
const [waiter] = claimWaiters.splice(waiterIndex, 1)
|
|
62
|
+
clearTimeout(waiter.timer)
|
|
63
|
+
waiter.reject(new Error("Owner claimant disconnected"))
|
|
64
|
+
}
|
|
65
|
+
if (ownerClient === socket) {
|
|
66
|
+
ownerClient = undefined
|
|
67
|
+
if (!shuttingDown) grantNextOwner()
|
|
68
|
+
}
|
|
69
|
+
if (shutdownClient === socket) void finishShutdown()
|
|
70
|
+
})
|
|
71
|
+
socket.on("data", (chunk) => {
|
|
72
|
+
buffer += chunk
|
|
73
|
+
let newline = buffer.indexOf("\n")
|
|
74
|
+
|
|
75
|
+
while (newline >= 0) {
|
|
76
|
+
const line = buffer.slice(0, newline)
|
|
77
|
+
|
|
78
|
+
buffer = buffer.slice(newline + 1)
|
|
79
|
+
void handleLine(socket, line)
|
|
80
|
+
newline = buffer.indexOf("\n")
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
server.on("error", (error) => {
|
|
86
|
+
if (process.send) process.send({error: error.message})
|
|
87
|
+
else throw error
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
server.listen(socketPath, async () => {
|
|
91
|
+
await fs.chmod(socketPath, 0o600)
|
|
92
|
+
if (process.send) {
|
|
93
|
+
process.send({ready: true})
|
|
94
|
+
process.disconnect?.()
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {net.Socket} socket - Client.
|
|
100
|
+
* @param {string} line - JSON request.
|
|
101
|
+
* @returns {Promise<void>} Request completion.
|
|
102
|
+
*/
|
|
103
|
+
async function handleLine(socket, line) {
|
|
104
|
+
/** @type {GuardianRequest | undefined} */
|
|
105
|
+
let request
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
request = /** @type {GuardianRequest} */ (JSON.parse(line))
|
|
109
|
+
if (request.token !== token) throw new Error("Guardian authentication failed")
|
|
110
|
+
const result = await execute(request, socket)
|
|
111
|
+
|
|
112
|
+
socket.write(`${JSON.stringify({id: request.id, result})}\n`)
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (!socket.destroyed) socket.write(`${JSON.stringify({error: error instanceof Error ? error.message : String(error), id: request?.id})}\n`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {GuardianRequest} request - Authenticated request.
|
|
120
|
+
* @param {net.Socket} socket - Requesting client.
|
|
121
|
+
* @returns {Promise<import("../../src/json.js").JsonValue>} Command result.
|
|
122
|
+
*/
|
|
123
|
+
async function execute(request, socket) {
|
|
124
|
+
if (shuttingDown) throw new Error("Process guardian is shutting down")
|
|
125
|
+
|
|
126
|
+
if (request.command === "claim-owner") {
|
|
127
|
+
if (!ownerClient) {
|
|
128
|
+
ownerClient = socket
|
|
129
|
+
return {claimed: true}
|
|
130
|
+
}
|
|
131
|
+
if (ownerClient === socket) return {claimed: true}
|
|
132
|
+
|
|
133
|
+
return await new Promise((resolve, reject) => {
|
|
134
|
+
const timer = setTimeout(() => {
|
|
135
|
+
const index = claimWaiters.findIndex((waiter) => waiter.socket === socket)
|
|
136
|
+
if (index >= 0) claimWaiters.splice(index, 1)
|
|
137
|
+
reject(new Error("Durable owner is already claimed by another matching daemon"))
|
|
138
|
+
}, request.graceMs ?? 30000)
|
|
139
|
+
|
|
140
|
+
claimWaiters.push({reject, resolve, socket, timer})
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (request.command === "shutdown") {
|
|
145
|
+
shuttingDown = true
|
|
146
|
+
try {
|
|
147
|
+
const results = await Promise.allSettled([...processes.values()].map((entry) => entry.process.stop()))
|
|
148
|
+
const errors = results.filter((result) => result.status === "rejected").map((result) => result.reason)
|
|
149
|
+
|
|
150
|
+
if (errors.length > 0) throw new AggregateError(errors, `Guardian failed to stop ${errors.length} owned process${errors.length === 1 ? "" : "es"}: ${errors.map((error) => errorMessage(error instanceof Error ? error : String(error))).join("; ")}`)
|
|
151
|
+
beginSuccessfulShutdown(socket)
|
|
152
|
+
return {stopped: true}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
shutdownClient = undefined
|
|
155
|
+
shuttingDown = false
|
|
156
|
+
throw error
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (request.command === "inventory") {
|
|
161
|
+
return [...processes.entries()].map(([key, entry]) => ({key, provenance: entry.provenance, status: entry.process.status()}))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (request.command === "remove") {
|
|
165
|
+
if (!request.key || !request.provenance) throw new Error("Guardian remove requires key and provenance")
|
|
166
|
+
const existing = processes.get(request.key)
|
|
167
|
+
|
|
168
|
+
if (!existing) throw new Error(`Guardian process ${request.key} is not registered`)
|
|
169
|
+
if (existing.provenance !== request.provenance) throw new Error(`Guardian provenance mismatch for ${request.key}`)
|
|
170
|
+
existing.desired = false
|
|
171
|
+
await existing.process.stop()
|
|
172
|
+
processes.delete(request.key)
|
|
173
|
+
return {removed: true}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (request.command === "register") {
|
|
177
|
+
if (!request.key || !request.definition || !request.provenance) throw new Error("Guardian register requires key, definition, and provenance")
|
|
178
|
+
const existing = processes.get(request.key)
|
|
179
|
+
|
|
180
|
+
if (existing) {
|
|
181
|
+
if (existing.provenance !== request.provenance) throw new Error(`Guardian provenance mismatch for ${request.key}`)
|
|
182
|
+
return existing.process.status()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const record = /** @type {{desired: boolean, process?: ManagedProcess, provenance: string}} */ ({desired: true, provenance: request.provenance})
|
|
186
|
+
const definition = request.definition
|
|
187
|
+
const managedProcess = new ManagedProcess({
|
|
188
|
+
...definition,
|
|
189
|
+
logger: (message, data = {}) => broadcast({event: "process", key: request.key, message, data, status: managedProcess.status()}),
|
|
190
|
+
shouldRestart: () => record.desired
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
record.process = managedProcess
|
|
194
|
+
processes.set(request.key, /** @type {{desired: boolean, process: ManagedProcess, provenance: string}} */ (record))
|
|
195
|
+
return managedProcess.status()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!request.key) throw new Error(`Guardian ${request.command} requires a process key`)
|
|
199
|
+
const record = processes.get(request.key)
|
|
200
|
+
|
|
201
|
+
if (!record) throw new Error(`Guardian process ${request.key} is not registered`)
|
|
202
|
+
|
|
203
|
+
if (request.command === "start") {
|
|
204
|
+
record.desired = true
|
|
205
|
+
await record.process.start(request.reason)
|
|
206
|
+
} else if (request.command === "quiesce") {
|
|
207
|
+
record.desired = false
|
|
208
|
+
await record.process.quiesceStrict()
|
|
209
|
+
} else if (request.command === "stop") {
|
|
210
|
+
record.desired = false
|
|
211
|
+
await record.process.stop(request.options)
|
|
212
|
+
} else if (request.command === "update") {
|
|
213
|
+
if (!request.definition || !request.provenance) throw new Error("Guardian update requires definition and provenance")
|
|
214
|
+
if (record.provenance !== request.previousProvenance) throw new Error(`Guardian provenance mismatch for ${request.key}`)
|
|
215
|
+
record.process.updateDefinition({
|
|
216
|
+
...request.definition,
|
|
217
|
+
lifecycle: request.definition.lifecycle || {drainTimeoutMs: 0},
|
|
218
|
+
logger: record.process.logger,
|
|
219
|
+
memory: request.definition.memory,
|
|
220
|
+
restart: request.definition.restart || {backoffFactor: 1, maxDelayMs: 0, maxRestarts: undefined, windowMs: 0},
|
|
221
|
+
shouldRestart: () => record.desired,
|
|
222
|
+
stopSignal: request.definition.stopSignal || "SIGTERM"
|
|
223
|
+
})
|
|
224
|
+
record.provenance = request.provenance
|
|
225
|
+
} else if (request.command === "status") {
|
|
226
|
+
return record.process.status()
|
|
227
|
+
} else {
|
|
228
|
+
throw new Error(`Unknown guardian command: ${request.command}`)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const status = record.process.status()
|
|
232
|
+
|
|
233
|
+
broadcast({event: "status", key: request.key, status})
|
|
234
|
+
return status
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Stops accepting connections and closes every authority channel except the response caller.
|
|
239
|
+
* @param {net.Socket} caller - Shutdown requester retained until it receives the response.
|
|
240
|
+
*/
|
|
241
|
+
function beginSuccessfulShutdown(caller) {
|
|
242
|
+
shutdownClient = caller
|
|
243
|
+
serverClosed = new Promise((resolve, reject) => {
|
|
244
|
+
server.close((error) => {
|
|
245
|
+
if (error) reject(error)
|
|
246
|
+
else resolve(undefined)
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
for (const client of clients) {
|
|
251
|
+
if (client !== caller) client.destroy()
|
|
252
|
+
}
|
|
253
|
+
if (caller.destroyed) void finishShutdown()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Completes shutdown after the caller has received success and closed its side. */
|
|
257
|
+
async function finishShutdown() {
|
|
258
|
+
if (shutdownFinalizing) return
|
|
259
|
+
shutdownFinalizing = true
|
|
260
|
+
for (const client of clients) client.destroy()
|
|
261
|
+
await serverClosed
|
|
262
|
+
await fs.rm(socketPath, {force: true})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** @param {Record<string, import("../../src/json.js").JsonValue>} event - Event payload. */
|
|
266
|
+
function broadcast(event) {
|
|
267
|
+
const line = `${JSON.stringify(event)}\n`
|
|
268
|
+
|
|
269
|
+
for (const client of clients) if (!client.destroyed) client.write(line)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** @returns {void} Grants the next queued owner claim. */
|
|
273
|
+
function grantNextOwner() {
|
|
274
|
+
const next = claimWaiters.shift()
|
|
275
|
+
|
|
276
|
+
if (!next) return
|
|
277
|
+
clearTimeout(next.timer)
|
|
278
|
+
ownerClient = next.socket
|
|
279
|
+
next.resolve({claimed: true})
|
|
280
|
+
for (const waiter of claimWaiters.splice(0)) {
|
|
281
|
+
clearTimeout(waiter.timer)
|
|
282
|
+
waiter.reject(new Error("Durable owner was claimed by another matching daemon"))
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @param {Error | string} error - Error-like value.
|
|
288
|
+
* @returns {string} Error message.
|
|
289
|
+
*/
|
|
290
|
+
function errorMessage(error) {
|
|
291
|
+
return error instanceof Error ? error.message : String(error)
|
|
292
|
+
}
|
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import fs from "node:fs"
|
|
4
4
|
import http from "node:http"
|
|
5
|
+
import path from "node:path"
|
|
5
6
|
|
|
6
7
|
const logPath = requiredEnv("ROLLBRIDGE_SERVICE_LOG")
|
|
7
8
|
const port = Number(requiredEnv("ROLLBRIDGE_PORT"))
|
|
8
9
|
const releaseId = process.env.ROLLBRIDGE_RELEASE_ID || "unknown"
|
|
10
|
+
const bindGatePath = process.env.ROLLBRIDGE_SERVICE_BIND_GATE
|
|
11
|
+
const bindWaitingPath = process.env.ROLLBRIDGE_SERVICE_BIND_WAITING
|
|
12
|
+
/** @type {fs.FSWatcher | undefined} */
|
|
13
|
+
let bindWatcher
|
|
9
14
|
|
|
10
15
|
writeEvent("start")
|
|
11
16
|
|
|
@@ -16,10 +21,35 @@ const server = http.createServer((_request, response) => {
|
|
|
16
21
|
|
|
17
22
|
process.on("SIGTERM", () => {
|
|
18
23
|
writeEvent("stop")
|
|
19
|
-
|
|
24
|
+
bindWatcher?.close()
|
|
25
|
+
if (server.listening) server.close(() => process.exit(0))
|
|
26
|
+
else process.exit(0)
|
|
20
27
|
})
|
|
21
28
|
|
|
22
|
-
|
|
29
|
+
listenWhenReleased()
|
|
30
|
+
|
|
31
|
+
/** Starts listening immediately or after the release-local test gate opens. */
|
|
32
|
+
function listenWhenReleased() {
|
|
33
|
+
if (!bindGatePath) {
|
|
34
|
+
server.listen(port, "127.0.0.1")
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let bindStarted = false
|
|
39
|
+
const bind = () => {
|
|
40
|
+
if (bindStarted || !fs.existsSync(bindGatePath)) return
|
|
41
|
+
bindStarted = true
|
|
42
|
+
bindWatcher?.close()
|
|
43
|
+
bindWatcher = undefined
|
|
44
|
+
server.listen(port, "127.0.0.1")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
bindWatcher = fs.watch(path.dirname(bindGatePath), (_event, filename) => {
|
|
48
|
+
if (filename === path.basename(bindGatePath)) bind()
|
|
49
|
+
})
|
|
50
|
+
if (fs.existsSync(bindGatePath)) bind()
|
|
51
|
+
else if (bindWaitingPath) fs.writeFileSync(bindWaitingPath, `${process.pid}\n`)
|
|
52
|
+
}
|
|
23
53
|
|
|
24
54
|
/**
|
|
25
55
|
* @param {"start" | "stop"} event - Event.
|