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,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
- server.close(() => process.exit(0))
24
+ bindWatcher?.close()
25
+ if (server.listening) server.close(() => process.exit(0))
26
+ else process.exit(0)
20
27
  })
21
28
 
22
- server.listen(port, "127.0.0.1")
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.