rollbridge 0.1.39 → 0.1.40

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.
@@ -1,32 +1,48 @@
1
1
  // @ts-check
2
2
 
3
+ import fsSync from "node:fs"
3
4
  import fs from "node:fs/promises"
4
5
  import net from "node:net"
5
6
  import crypto from "node:crypto"
7
+ import {spawn} from "node:child_process"
6
8
  import {isDeepStrictEqual} from "node:util"
9
+ import path from "node:path"
7
10
  import GuardianClient from "./guardian-client.js"
8
11
  import ManagedProcess from "./managed-process.js"
12
+ import {writeState} from "./state-store.js"
13
+
14
+ const OWNER_RESTART_RETRY_MS = 1000
15
+
16
+ /** @typedef {import("node:child_process").ChildProcess["signalCode"]} ChildExitSignal */
9
17
 
10
18
  /**
11
19
  * @typedef {object} GuardianRequest
12
20
  * @property {string} command - Operation name.
21
+ * @property {{http?: number, websocket?: number}} [connections] - Retired listener counts.
13
22
  * @property {ConstructorParameters<typeof ManagedProcess>[0]} [definition] - Managed process definition.
14
23
  * @property {number} [graceMs] - Owner reconnection grace.
15
24
  * @property {number} id - Request id.
16
25
  * @property {string} [key] - Stable process key.
26
+ * @property {boolean} [localSource] - Whether the retired daemon physically owns the published source.
17
27
  * @property {{timeoutMs?: number}} [options] - Stop options.
18
28
  * @property {string} [previousProvenance] - Expected current provenance.
19
29
  * @property {string} [provenance] - New or registered provenance.
30
+ * @property {string} [releaseId] - Retained release identity.
31
+ * @property {string} [sourceId] - Stable retired-listener source identity.
20
32
  * @property {import("./json.js").JsonValue} [ownerState] - Private owner transfer state.
21
33
  * @property {string} [replacementId] - Prepared replacement transaction id.
22
34
  * @property {string} [mutationId] - Owner mutation lease id.
23
35
  * @property {string} [operation] - Owner mutation diagnostic name.
36
+ * @property {number} [ownerPid] - Exact claimant process PID.
37
+ * @property {import("./json.js").JsonValue} [recoverySnapshot] - Public bridge recovery snapshot.
38
+ * @property {string} [statePath] - Exact public recovery state path.
24
39
  * @property {import("./json.js").JsonValue} [authority] - Expected current authority.
25
40
  * @property {import("./json.js").JsonValue} [nextAuthority] - Requested replacement authority.
26
41
  * @property {import("./managed-process.js").ManagedProcessStartReason} [reason] - Start reason.
27
42
  * @property {import("./managed-process.js").LifecycleRole} [lifecycleRole] - Desired generation role restored during start.
28
43
  * @property {string} token - Authentication token.
29
44
  */
45
+ /** @typedef {{listenerStateComplete: boolean, localSources: Map<string, Map<string, {http: number, websocket: number}>>, replacementId: string, successor: net.Socket}} RetiredListenerTransaction */
30
46
 
31
47
  const [socketPath] = process.argv.slice(2)
32
48
 
@@ -51,10 +67,24 @@ if (legacyGuardian) await legacyGuardian.connect()
51
67
  const processes = new Map()
52
68
  /** @type {Set<net.Socket>} */
53
69
  const clients = new Set()
70
+ /** @type {Set<net.Socket>} */
71
+ const backpressuredClients = new Set()
72
+ /** @type {Map<net.Socket, Map<string, string>>} */
73
+ const pendingStatusEvents = new Map()
74
+ /** @type {Map<net.Socket, number>} */
75
+ const ownerUpdatesInFlight = new Map()
76
+ /** @type {Map<net.Socket, RetiredListenerTransaction>} */
77
+ const retiredListenerClients = new Map()
78
+ /** @type {Set<RetiredListenerTransaction>} */
79
+ const disconnectedOwnerSuccessors = new Set()
54
80
  /** @type {net.Socket | undefined} */
55
81
  let ownerClient
82
+ /** @type {number | undefined} */
83
+ let ownerClientPid
56
84
  /** @type {net.Socket | undefined} */
57
85
  let replacementClient
86
+ /** @type {number | undefined} */
87
+ let replacementOwnerPid
58
88
  /** @type {string | undefined} */
59
89
  let replacementId
60
90
  /** @type {string | undefined} */
@@ -77,15 +107,29 @@ let ownerMutationId
77
107
  let retiringClient
78
108
  /** @type {string | undefined} */
79
109
  let retiringReplacementId
110
+ let retiringListenerReady = false
111
+ let retirementFailed = false
80
112
  /** @type {Promise<Error | undefined> | undefined} */
81
113
  let legacyOwnerClaim
114
+ /** @type {import("./json.js").JsonValue | undefined} */
115
+ let legacyRecoverySnapshot
116
+ /** @type {string | undefined} */
117
+ let legacyRecoveryStatePath
82
118
  let shuttingDown = false
83
119
  /** @type {net.Socket | undefined} */
84
120
  let shutdownClient
85
121
  let shutdownFinalizing = false
86
122
  /** @type {Promise<void> | undefined} */
87
123
  let serverClosed
88
- /** @type {{reject: (error: Error) => void, resolve: (value: {claimed: boolean}) => void, socket: net.Socket, timer: ReturnType<typeof setTimeout>}[]} */
124
+ /** @type {import("node:child_process").ChildProcess | undefined} */
125
+ let ownerRestartChild
126
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
127
+ let ownerRestartTimer
128
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
129
+ let ownerRestartStartupTimer
130
+ /** @type {number | undefined} */
131
+ let ownerRestartRetryDelayMs
132
+ /** @type {{authority: import("./json.js").JsonValue, ownerPid: number | undefined, reject: (error: Error) => void, resolve: (value: {claimed: boolean}) => void, socket: net.Socket, timer: ReturnType<typeof setTimeout>}[]} */
89
133
  const claimWaiters = []
90
134
 
91
135
  const server = net.createServer((socket) => {
@@ -94,10 +138,15 @@ const server = net.createServer((socket) => {
94
138
  // An abruptly killed daemon can reset its private guardian connection. Keep the
95
139
  // durable guardian alive; the close handler below releases only that owner claim.
96
140
  socket.on("error", () => socket.destroy())
141
+ socket.on("drain", () => flushStatusEvents(socket))
97
142
  let buffer = ""
98
143
 
99
144
  socket.once("close", () => {
145
+ const controlLessRetirement = retiredListenerClients.get(socket)
146
+
100
147
  clients.delete(socket)
148
+ backpressuredClients.delete(socket)
149
+ pendingStatusEvents.delete(socket)
101
150
  const waiterIndex = claimWaiters.findIndex((waiter) => waiter.socket === socket)
102
151
 
103
152
  if (waiterIndex >= 0) {
@@ -105,21 +154,46 @@ const server = net.createServer((socket) => {
105
154
  clearTimeout(waiter.timer)
106
155
  waiter.reject(new Error("Owner claimant disconnected"))
107
156
  }
108
- if (ownerClient === socket) {
109
- ownerClient = undefined
110
- if (replacementClient && replacementOwnerState) commitReplacement()
111
- else {
112
- if (replacementClient) abortReplacement("Committed owner disconnected before replacement candidate was ready")
113
- if (!shuttingDown) grantNextOwner()
114
- }
115
- }
116
- if (ownerMutationClient === socket) {
157
+ if (ownerMutationClient === socket && ownerClient !== socket) {
117
158
  ownerMutationClient = undefined
118
159
  ownerMutationId = undefined
119
160
  }
120
- if (retiringClient === socket) finalizeReplacementRetirement()
161
+ if (controlLessRetirement) {
162
+ const preCommitHandoff = replacementClient && ownerClient === socket && retiringReplacementId === replacementId
163
+
164
+ if (preCommitHandoff) {
165
+ publishClosedLocalSources(controlLessRetirement)
166
+ if (!controlLessRetirement.successor.destroyed) {
167
+ controlLessRetirement.successor.write(`${JSON.stringify({event: "replacement-retirement-failed", reason: "Incumbent listener disconnected during the prepared handoff", replacementId: controlLessRetirement.replacementId})}\n`)
168
+ }
169
+ abortReplacement("Incumbent listener disconnected during the prepared handoff", false)
170
+ } else if (retiringClient === socket && !controlLessRetirement.listenerStateComplete) {
171
+ retirementFailed = true
172
+ retiringClient = undefined
173
+ retiringReplacementId = undefined
174
+ if (ownerClient && !ownerClient.destroyed) {
175
+ ownerClient.write(`${JSON.stringify({event: "replacement-retirement-failed", reason: "Retired listener disconnected before publishing complete connection state", replacementId: controlLessRetirement.replacementId})}\n`)
176
+ }
177
+ } else if (controlLessRetirement.listenerStateComplete) {
178
+ publishClosedLocalSources(controlLessRetirement)
179
+ if (retiringClient === socket) finalizeReplacementRetirement()
180
+ }
181
+ } else if (retiringClient === socket) finalizeReplacementRetirement()
182
+ if (controlLessRetirement && retiredListenerClients.has(socket)) {
183
+ for (const transaction of retiredListenerClients.values()) {
184
+ if (transaction.successor === socket) transaction.successor = controlLessRetirement.successor
185
+ }
186
+ disconnectedOwnerSuccessors.delete(controlLessRetirement)
187
+ retiredListenerClients.delete(socket)
188
+ }
189
+ if (ownerClient === socket && !ownerUpdatesInFlight.has(socket)) releaseClosedOwner(socket)
121
190
  if (replacementClient === socket) {
122
191
  abortReplacement("Replacement candidate disconnected before commit")
192
+ if (legacyGuardian && !legacyOwnerClaim && !ownerClient && !committedReplacementId) {
193
+ legacyGuardian.disconnect()
194
+ shuttingDown = true
195
+ beginSuccessfulShutdown(socket)
196
+ }
123
197
  }
124
198
  if (shutdownClient === socket) void finishShutdown()
125
199
  })
@@ -158,15 +232,22 @@ server.listen(socketPath, async () => {
158
232
  async function handleLine(socket, line) {
159
233
  /** @type {GuardianRequest | undefined} */
160
234
  let request
235
+ let ownerUpdate = false
161
236
 
162
237
  try {
163
238
  request = /** @type {GuardianRequest} */ (JSON.parse(line))
164
239
  if (request.token !== token) throw new Error("Guardian authentication failed")
240
+ if (ownerClient === socket && request.command === "update") {
241
+ ownerUpdate = true
242
+ ownerUpdatesInFlight.set(socket, (ownerUpdatesInFlight.get(socket) ?? 0) + 1)
243
+ }
165
244
  const result = await execute(request, socket)
166
245
 
167
246
  socket.write(`${JSON.stringify({id: request.id, result})}\n`)
168
247
  } catch (error) {
169
248
  if (!socket.destroyed) socket.write(`${JSON.stringify({error: error instanceof Error ? error.message : String(error), id: request?.id})}\n`)
249
+ } finally {
250
+ if (ownerUpdate) completeOwnerUpdate(socket)
170
251
  }
171
252
  }
172
253
 
@@ -178,16 +259,24 @@ async function handleLine(socket, line) {
178
259
  async function execute(request, socket) {
179
260
  if (shuttingDown) throw new Error("Process guardian is shutting down")
180
261
 
262
+ if (request.command === "capabilities") return {daemonRecovery: 1}
263
+
181
264
  if (request.command === "owner-replacement-capabilities") {
182
265
  return {commands: ["commit-retired-owner-replacement"], protocol: "owner-replacement", version: 1}
183
266
  }
184
267
 
185
268
  if (request.command === "claim-owner") {
269
+ if (retirementFailed) throw new Error("Guardian owner replacement is fenced after incomplete retired-listener state transfer")
270
+ if (ownerState !== undefined && !isDeepStrictEqual(request.authority, ownerAuthority(ownerState))) {
271
+ const waitsForPreparedAuthority = Boolean(ownerClient && replacementOwnerState && isDeepStrictEqual(request.authority, replacementAuthority))
272
+
273
+ if (!waitsForPreparedAuthority) throw new Error("Owner recovery authority does not match the guardian's committed authority")
274
+ }
186
275
  if (!ownerClient) {
187
- if (ownerState !== undefined && !isDeepStrictEqual(request.authority, ownerAuthority(ownerState))) {
188
- throw new Error("Owner recovery authority does not match the guardian's committed authority")
189
- }
276
+ acceptOwnerClaim(request.ownerPid)
190
277
  ownerClient = socket
278
+ ownerClientPid = request.ownerPid
279
+ reattachRetiredListenerSuccessors(socket)
191
280
  return {claimed: true}
192
281
  }
193
282
  if (ownerClient === socket) return {claimed: true}
@@ -199,7 +288,7 @@ async function execute(request, socket) {
199
288
  reject(new Error("Durable owner is already claimed by another matching daemon"))
200
289
  }, request.graceMs ?? 30000)
201
290
 
202
- claimWaiters.push({reject, resolve, socket, timer})
291
+ claimWaiters.push({authority: request.authority ?? null, ownerPid: request.ownerPid, reject, resolve, socket, timer})
203
292
  })
204
293
  }
205
294
 
@@ -208,7 +297,10 @@ async function execute(request, socket) {
208
297
  if (replacementClient) throw new Error("Committed owner cannot retire while an owner replacement is prepared")
209
298
  for (const entry of processes.values()) entry.desired = false
210
299
  void Promise.allSettled([...processes.values()].map((entry) => entry.process.stop()))
300
+ clearOwnerLocalListenerSource()
301
+ rememberDisconnectedOwnerSuccessors(socket)
211
302
  ownerClient = undefined
303
+ ownerClientPid = undefined
212
304
  ownerMutationClient = undefined
213
305
  ownerMutationId = undefined
214
306
  ownerRevision += 1
@@ -216,6 +308,34 @@ async function execute(request, socket) {
216
308
  return {retired: true}
217
309
  }
218
310
 
311
+ if (request.command === "owner-ready") {
312
+ requireOwner(socket, request.command)
313
+ if (!request.ownerPid || request.ownerPid !== ownerClientPid) throw new Error("Guardian owner readiness PID does not match the claimed owner")
314
+ const recovery = ownerRecoveryDefinition()
315
+ const pidPath = recovery?.command.pidPath
316
+
317
+ if (ownerRestartChild && ownerRestartChild.pid !== request.ownerPid) throw new Error("Guardian owner readiness does not match the tracked recovery child")
318
+ if (pidPath) {
319
+ let temporaryDirectory
320
+
321
+ fsSync.mkdirSync(path.dirname(pidPath), {recursive: true})
322
+ try {
323
+ temporaryDirectory = fsSync.mkdtempSync(path.join(path.dirname(pidPath), `.${path.basename(pidPath)}.`))
324
+ const temporaryPath = path.join(temporaryDirectory, "pid")
325
+
326
+ fsSync.writeFileSync(temporaryPath, `${request.ownerPid}\n`, {flag: "wx"})
327
+ fsSync.renameSync(temporaryPath, pidPath)
328
+ } finally {
329
+ if (temporaryDirectory) fsSync.rmSync(temporaryDirectory, {force: true, recursive: true})
330
+ }
331
+ }
332
+ if (ownerRestartChild) {
333
+ clearOwnerRestartTracking(ownerRestartChild)
334
+ }
335
+ ownerRestartRetryDelayMs = undefined
336
+ return {ready: true}
337
+ }
338
+
219
339
  if (request.command === "abandon-legacy-upgrade") {
220
340
  if (!legacyGuardian) throw new Error("Guardian is not a legacy upgrade coordinator")
221
341
  if (ownerClient || committedReplacementId) throw new Error("Committed guardian authority cannot abandon its legacy backend")
@@ -231,7 +351,13 @@ async function execute(request, socket) {
231
351
  if (!legacyGuardian) throw new Error("Guardian is not a legacy upgrade coordinator")
232
352
  requireReplacement(socket, request)
233
353
  if (legacyOwnerClaim) throw new Error("Legacy guardian owner claim is already pending")
234
- legacyOwnerClaim = legacyGuardian.claimOwner(request.graceMs ?? 30000, ownerAuthority(ownerState)).then(
354
+ if (replacementAuthority === undefined) throw new Error("Legacy guardian owner claim is missing replacement authority")
355
+ if (!request.statePath || !path.isAbsolute(request.statePath)) throw new Error("Legacy guardian owner claim requires an absolute state path")
356
+ if (request.statePath !== ownerStatePath(ownerState)) throw new Error("Legacy guardian owner claim state path does not match the committed owner config")
357
+ assertBridgeRecoverySnapshot(request.recoverySnapshot)
358
+ legacyRecoveryStatePath = request.statePath
359
+ legacyRecoverySnapshot = request.recoverySnapshot
360
+ legacyOwnerClaim = legacyGuardian.claimOwner(request.graceMs ?? 30000, replacementAuthority).then(
235
361
  () => undefined,
236
362
  (error) => error instanceof Error ? error : new Error(String(error))
237
363
  )
@@ -244,15 +370,17 @@ async function execute(request, socket) {
244
370
  const claimError = await legacyOwnerClaim
245
371
 
246
372
  if (claimError) throw claimError
373
+ if (!legacyRecoveryStatePath || legacyRecoverySnapshot === undefined) throw new Error("Legacy guardian owner claim is missing durable recovery state")
374
+ await writeState(legacyRecoveryStatePath, legacyRecoverySnapshot)
247
375
  return {claimed: true}
248
376
  }
249
377
 
250
378
  if (request.command === "publish-owner-state") {
251
379
  requireOwner(socket, request.command)
252
380
  if (request.ownerState === undefined) throw new Error("Guardian owner publication requires ownerState")
253
- ownerState = request.ownerState
381
+ ownerState = mergeOwnerConnectionState(ownerState, request.ownerState)
254
382
  ownerRevision += 1
255
- committedReplacementId = undefined
383
+ if (!retiringClient) committedReplacementId = undefined
256
384
  return {published: true}
257
385
  }
258
386
 
@@ -263,7 +391,7 @@ async function execute(request, socket) {
263
391
  }
264
392
 
265
393
  if (request.command === "replacement-status") {
266
- return {committedReplacementId: committedReplacementId ?? null, ownerClaimed: Boolean(ownerClient), retirementPending: Boolean(retiringClient)}
394
+ return {committedReplacementId: committedReplacementId ?? null, ownerClaimed: Boolean(ownerClient), retirementFailed, retirementPending: Boolean(retiringClient), retirementReady: retiringListenerReady}
267
395
  }
268
396
 
269
397
  if (request.command === "begin-owner-mutation") {
@@ -284,6 +412,8 @@ async function execute(request, socket) {
284
412
  }
285
413
 
286
414
  if (request.command === "prepare-owner-replacement") {
415
+ if (retirementFailed) throw new Error("Guardian owner replacement is fenced after incomplete retired-listener state transfer")
416
+ if (retiringClient) throw new Error("Guardian owner replacement cannot prepare while listener retirement is pending")
287
417
  if (socket === ownerClient) throw new Error("Committed owner cannot prepare itself as a replacement")
288
418
  if (replacementClient && replacementClient !== socket) throw new Error("Another owner replacement candidate is already prepared")
289
419
  if (ownerMutationClient) throw new Error("Owner replacement cannot prepare while an owner mutation is in progress")
@@ -295,8 +425,18 @@ async function execute(request, socket) {
295
425
  throw new Error("Owner replacement authority fence does not match the guardian's committed authority")
296
426
  }
297
427
  if (request.nextAuthority === undefined) throw new Error("Owner replacement requires the requested authority")
428
+ if (!ownerClient) {
429
+ cancelOwnerRestart()
430
+ const supersededRestartChild = ownerRestartChild
431
+
432
+ clearOwnerRestartTracking()
433
+ if (supersededRestartChild) killDetachedProcessGroup(supersededRestartChild)
434
+ }
298
435
  if (!replacementId) replacementId = crypto.randomUUID()
436
+ retirementFailed = false
437
+ retiringListenerReady = false
299
438
  replacementClient = socket
439
+ replacementOwnerPid = request.ownerPid
300
440
  replacementAuthority = request.nextAuthority
301
441
  replacementRevision = ownerRevision
302
442
  if (ownerClient && !ownerClient.destroyed) ownerClient.write(`${JSON.stringify({event: "replacement-prepared", replacementId})}\n`)
@@ -309,7 +449,7 @@ async function execute(request, socket) {
309
449
  if (request.ownerState === undefined || !isDeepStrictEqual(ownerAuthority(request.ownerState), replacementAuthority)) {
310
450
  throw new Error("Prepared replacement state does not match the requested authority")
311
451
  }
312
- replacementOwnerState = request.ownerState
452
+ replacementOwnerState = mergeOwnerConnectionState(ownerState, request.ownerState)
313
453
  if (!ownerClient) commitReplacement()
314
454
  return {committed: ownerClient === socket}
315
455
  }
@@ -320,30 +460,94 @@ async function execute(request, socket) {
320
460
  return {aborted: true}
321
461
  }
322
462
 
463
+ if (request.command === "prepare-retired-owner-listener-handoff") {
464
+ requireReplacement(socket, request)
465
+ requireRetiredOwnerReplacement(request)
466
+ await requireMissingOwnerControlPath(ownerState)
467
+ requireReplacement(socket, request)
468
+ if (!ownerClient || ownerClient.destroyed) throw new Error("Retired owner listener handoff requires the connected committed owner")
469
+ if (retiringClient) throw new Error("Another owner listener retirement is already pending")
470
+ retiringClient = ownerClient
471
+ retiringReplacementId = /** @type {string} */ (request.replacementId)
472
+ retiredListenerClients.set(ownerClient, {
473
+ listenerStateComplete: false,
474
+ localSources: new Map(),
475
+ replacementId: /** @type {string} */ (request.replacementId),
476
+ successor: socket
477
+ })
478
+ ownerClient.write(`${JSON.stringify({event: "replacement-listener-handoff-requested", replacementId: request.replacementId})}\n`)
479
+ return {prepared: true}
480
+ }
481
+
323
482
  if (request.command === "commit-retired-owner-replacement") {
324
483
  requireReplacement(socket, request)
325
- requireProcess(request)
326
- if (!committedOwnerProcessKeys(ownerState).has(/** @type {string} */ (request.key))) {
327
- throw new Error(`Guardian process ${request.key} does not belong to the committed owner`)
328
- }
329
- if (!replacementOwnerState) throw new Error("Retired owner replacement transaction is not staged")
330
- if (!isDeepStrictEqual(ownerAuthority(ownerState), replacementAuthority)) throw new Error("Retired owner replacement requires unchanged owner authority")
331
- const controlPath = ownerControlPath(ownerState)
484
+ requireRetiredOwnerReplacement(request)
485
+ await requireMissingOwnerControlPath(ownerState)
486
+ const listener = retiringClient
332
487
 
333
- try {
334
- await fs.lstat(controlPath)
335
- throw new Error(`Retired owner control socket ${controlPath} still exists`)
336
- } catch (error) {
337
- if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
488
+ if (!listener || listener !== ownerClient || listener.destroyed || !retiringListenerReady || retiredListenerClients.get(listener)?.successor !== socket) {
489
+ throw new Error("Retired owner replacement requires a complete prepared listener handoff")
338
490
  }
339
491
  commitReplacement()
340
- finalizeReplacementRetirement()
492
+ if (ownerClient) {
493
+ const retirement = retiredListenerClients.get(listener)
494
+
495
+ if (!retirement) throw new Error("Retired owner replacement lost its prepared listener handoff")
496
+ retirement.successor = ownerClient
497
+ listener.write(`${JSON.stringify({event: "replacement-retirement-requested", replacementId: request.replacementId})}\n`)
498
+ }
341
499
  return {committed: true}
342
500
  }
343
501
 
502
+ if (request.command === "publish-owner-connection-state") {
503
+ const retirement = retiredListenerClients.get(socket)
504
+
505
+ if (!retirement || retirement.replacementId !== request.replacementId) throw new Error("Owner connection state requires the exact retired listener transaction")
506
+ if (typeof request.sourceId !== "string" || !request.sourceId || typeof request.releaseId !== "string" || !request.connections || typeof request.connections !== "object" || Array.isArray(request.connections)) {
507
+ throw new Error("Owner connection state requires a source, release, and connection counts")
508
+ }
509
+ const connections = request.connections
510
+ const http = connections.http
511
+ const websocket = connections.websocket
512
+
513
+ if (typeof http !== "number" || !Number.isSafeInteger(http) || http < 0 || typeof websocket !== "number" || !Number.isSafeInteger(websocket) || websocket < 0) {
514
+ throw new Error("Owner connection state requires non-negative integer counts")
515
+ }
516
+ const normalized = {http, websocket}
517
+
518
+ if (request.localSource) setRetiredLocalSource(retirement, request.sourceId, request.releaseId, normalized)
519
+ applyOwnerConnectionState(ownerState, request.sourceId, request.releaseId, normalized)
520
+ if (replacementOwnerState) applyOwnerConnectionState(replacementOwnerState, request.sourceId, request.releaseId, normalized)
521
+ publishOwnerConnectionState(retirement.successor, request.sourceId, request.releaseId, normalized)
522
+ return {published: true}
523
+ }
524
+
525
+ if (request.command === "complete-owner-listener-retirement") {
526
+ if (retiredListenerClients.get(socket)?.replacementId !== request.replacementId || retiringClient !== socket) {
527
+ throw new Error("Owner listener retirement completion requires the exact retired listener transaction")
528
+ }
529
+ retiringListenerReady = true
530
+ const retirement = retiredListenerClients.get(socket)
531
+
532
+ if (!retirement) throw new Error("Owner listener retirement completion lost its transaction")
533
+ retirement.listenerStateComplete = true
534
+ const successor = retirement.successor
535
+
536
+ if (successor && !successor.destroyed) {
537
+ successor.write(`${JSON.stringify({event: "replacement-listeners-retired", replacementId: request.replacementId})}\n`)
538
+ }
539
+ return {completed: true}
540
+ }
541
+
344
542
  if (request.command === "commit-owner-replacement") {
345
543
  requireOwner(socket, request.command)
346
544
  if (!replacementClient || request.replacementId !== replacementId || !replacementOwnerState) throw new Error("Owner replacement transaction is not the prepared ready candidate")
545
+ retiredListenerClients.set(socket, {
546
+ listenerStateComplete: false,
547
+ localSources: new Map(),
548
+ replacementId: /** @type {string} */ (request.replacementId),
549
+ successor: replacementClient
550
+ })
347
551
  commitReplacement()
348
552
  return {committed: true}
349
553
  }
@@ -355,7 +559,15 @@ async function execute(request, socket) {
355
559
  }
356
560
 
357
561
  if (request.command === "finalize-owner-replacement") {
358
- if (retiringClient !== socket || request.replacementId !== retiringReplacementId) throw new Error("Owner replacement retirement finalization requires the committed incumbent transaction")
562
+ const incumbentRetirement = retiredListenerClients.get(socket)
563
+ const incumbentFinalization = retiringClient === socket && (!incumbentRetirement || incumbentRetirement.listenerStateComplete)
564
+ const candidateFinalization = ownerClient === socket && retiringListenerReady && !retirementFailed
565
+ const alreadyFinalized = ownerClient === socket && !retiringClient && request.replacementId === committedReplacementId && !retirementFailed
566
+
567
+ if (alreadyFinalized) return {finalized: true}
568
+ if ((!incumbentFinalization && !candidateFinalization) || request.replacementId !== retiringReplacementId) {
569
+ throw new Error("Owner replacement retirement finalization requires the committed listener transaction")
570
+ }
359
571
  finalizeReplacementRetirement()
360
572
  return {finalized: true}
361
573
  }
@@ -424,7 +636,7 @@ async function execute(request, socket) {
424
636
  : new ManagedProcess(managedDefinition)
425
637
 
426
638
  managedProcess.on("log", (entry) => {
427
- broadcast({entry, event: "process-log", key: request.key, status: managedProcess.status()})
639
+ broadcast({entry, event: "process-log", key: request.key})
428
640
  })
429
641
  if (recoversLegacyProcess && "recover" in managedProcess && typeof managedProcess.recover === "function") await managedProcess.recover()
430
642
 
@@ -458,7 +670,7 @@ async function execute(request, socket) {
458
670
  } else if (request.command === "update") {
459
671
  if (!request.definition || !request.provenance) throw new Error("Guardian update requires definition and provenance")
460
672
  if (record.provenance !== request.previousProvenance) throw new Error(`Guardian provenance mismatch for ${request.key}`)
461
- record.process.updateDefinition({
673
+ await record.process.updateDefinition({
462
674
  ...request.definition,
463
675
  lifecycle: request.definition.lifecycle || {drainTimeoutMs: 0},
464
676
  logger: record.process.logger,
@@ -468,6 +680,10 @@ async function execute(request, socket) {
468
680
  stopSignal: request.definition.stopSignal || "SIGTERM"
469
681
  })
470
682
  record.provenance = request.provenance
683
+ if (request.ownerState !== undefined) {
684
+ ownerState = mergeOwnerConnectionState(ownerState, request.ownerState)
685
+ committedReplacementId = undefined
686
+ }
471
687
  } else if (request.command === "status") {
472
688
  return record.process.status()
473
689
  } else {
@@ -490,17 +706,83 @@ function requireOwner(socket, command) {
490
706
  if (ownerClient !== socket) throw new Error(`Guardian ${command} requires the committed owner`)
491
707
  }
492
708
 
493
- /** @param {string} reason - Abort diagnostic. */
494
- function abortReplacement(reason) {
495
- const event = `${JSON.stringify({event: "replacement-aborted", reason})}\n`
709
+ /**
710
+ * Releases authority only after every accepted request from the disconnected owner settles.
711
+ * @param {net.Socket} socket - Disconnected owner socket.
712
+ */
713
+ function releaseClosedOwner(socket) {
714
+ if (ownerClient !== socket) return
715
+ const incompleteRestartChild = ownerRestartChild?.pid === ownerClientPid ? ownerRestartChild : undefined
716
+
717
+ clearOwnerLocalListenerSource()
718
+ rememberDisconnectedOwnerSuccessors(socket)
719
+ ownerClient = undefined
720
+ ownerClientPid = undefined
721
+ if (ownerMutationClient === socket) {
722
+ ownerMutationClient = undefined
723
+ ownerMutationId = undefined
724
+ }
725
+ if (replacementClient && replacementOwnerState) commitReplacement()
726
+ else {
727
+ if (incompleteRestartChild) {
728
+ reportOwnerRestartFailure(ownerRecoveryDefinition(), {code: "OWNER_DISCONNECTED"})
729
+ killDetachedProcessGroup(incompleteRestartChild)
730
+ clearOwnerRestartTracking(incompleteRestartChild)
731
+ ownerRestartRetryDelayMs = OWNER_RESTART_RETRY_MS
732
+ }
733
+ if (replacementClient) abortReplacement("Committed owner disconnected before replacement candidate was ready", false)
734
+ if (retirementFailed) return
735
+ if (!shuttingDown) {
736
+ grantNextOwner()
737
+ const retryDelayMs = ownerRestartRetryDelayMs
738
+
739
+ ownerRestartRetryDelayMs = undefined
740
+ scheduleOwnerRestart(retryDelayMs)
741
+ }
742
+ }
743
+ }
744
+
745
+ /**
746
+ * Completes one accepted owner update and applies a deferred disconnect boundary.
747
+ * @param {net.Socket} socket - Requesting owner socket.
748
+ */
749
+ function completeOwnerUpdate(socket) {
750
+ const remaining = (ownerUpdatesInFlight.get(socket) ?? 1) - 1
751
+
752
+ if (remaining > 0) {
753
+ ownerUpdatesInFlight.set(socket, remaining)
754
+ return
755
+ }
756
+ ownerUpdatesInFlight.delete(socket)
757
+ if (socket.destroyed) releaseClosedOwner(socket)
758
+ }
496
759
 
497
- if (replacementClient && !replacementClient.destroyed) replacementClient.write(event)
498
- if (ownerClient && !ownerClient.destroyed) ownerClient.write(event)
760
+ /**
761
+ * @param {string} reason - Abort diagnostic.
762
+ * @param {boolean} [scheduleRecovery] - Whether this call owns recovery scheduling.
763
+ */
764
+ function abortReplacement(reason, scheduleRecovery = true) {
765
+ const abortedReplacementId = replacementId
766
+
767
+ if (replacementClient && !replacementClient.destroyed) replacementClient.write(`${JSON.stringify({event: "replacement-aborted", reason})}\n`)
768
+ if (ownerClient && !ownerClient.destroyed) ownerClient.write(`${JSON.stringify({event: "replacement-aborted", reason})}\n`)
769
+ if (retiringClient && retiringClient === ownerClient && retiringReplacementId === abortedReplacementId) {
770
+ const retirement = retiredListenerClients.get(retiringClient)
771
+
772
+ if (retirement) disconnectedOwnerSuccessors.delete(retirement)
773
+ retiredListenerClients.delete(retiringClient)
774
+ retiringClient = undefined
775
+ retiringReplacementId = undefined
776
+ retiringListenerReady = false
777
+ retirementFailed = false
778
+ }
499
779
  replacementClient = undefined
500
780
  replacementId = undefined
501
781
  replacementAuthority = undefined
502
782
  replacementOwnerState = undefined
783
+ replacementOwnerPid = undefined
503
784
  replacementRevision = undefined
785
+ if (scheduleRecovery && !ownerClient) scheduleOwnerRestart()
504
786
  }
505
787
 
506
788
  /** Atomically promotes the prepared client and its complete transferable state. */
@@ -510,14 +792,31 @@ function commitReplacement() {
510
792
  const committedClient = replacementClient
511
793
  const committedId = replacementId
512
794
 
795
+ cancelOwnerRestart()
796
+ const supersededRestartChild = ownerRestartChild
797
+ const restartChildIsIncumbent = Boolean(previousOwner && supersededRestartChild?.pid === ownerClientPid)
798
+
799
+ clearOwnerRestartTracking()
800
+ if (supersededRestartChild && !restartChildIsIncumbent) killDetachedProcessGroup(supersededRestartChild)
513
801
  ownerClient = committedClient
802
+ ownerClientPid = replacementOwnerPid
803
+ ownerRestartRetryDelayMs = undefined
514
804
  ownerState = replacementOwnerState
515
805
  ownerRevision += 1
516
806
  committedReplacementId = committedId
807
+ for (const waiter of claimWaiters.splice(0)) {
808
+ if (isDeepStrictEqual(waiter.authority, ownerAuthority(ownerState))) {
809
+ claimWaiters.push(waiter)
810
+ } else {
811
+ clearTimeout(waiter.timer)
812
+ waiter.reject(new Error("Durable owner authority changed while the claim was queued"))
813
+ }
814
+ }
517
815
  replacementClient = undefined
518
816
  replacementId = undefined
519
817
  replacementAuthority = undefined
520
818
  replacementOwnerState = undefined
819
+ replacementOwnerPid = undefined
521
820
  replacementRevision = undefined
522
821
  if (previousOwner && !previousOwner.destroyed) {
523
822
  retiringClient = previousOwner
@@ -535,6 +834,8 @@ function finalizeReplacementRetirement() {
535
834
 
536
835
  retiringClient = undefined
537
836
  retiringReplacementId = undefined
837
+ retiringListenerReady = false
838
+ retirementFailed = false
538
839
  if (!committedClient || !committedId) return
539
840
  publishReplacementCommitted(committedClient, committedId)
540
841
  if (previousOwner && !previousOwner.destroyed) previousOwner.write(`${JSON.stringify({event: "replacement-retired", replacementId: committedId})}\n`)
@@ -568,6 +869,144 @@ function requireProcess(request) {
568
869
  return record
569
870
  }
570
871
 
872
+ /**
873
+ * @param {RetiredListenerTransaction} retirement - Retired listener transaction.
874
+ * @param {string} sourceId - Physically-owned source.
875
+ * @param {string} releaseId - Retained release.
876
+ * @param {{http: number, websocket: number}} connections - Exact live counts.
877
+ */
878
+ function setRetiredLocalSource(retirement, sourceId, releaseId, connections) {
879
+ const releases = retirement.localSources.get(sourceId) || new Map()
880
+
881
+ if (connections.http + connections.websocket === 0) releases.delete(releaseId)
882
+ else releases.set(releaseId, connections)
883
+ if (releases.size === 0) retirement.localSources.delete(sourceId)
884
+ else retirement.localSources.set(sourceId, releases)
885
+ }
886
+
887
+ /**
888
+ * @param {net.Socket | undefined} firstSuccessor - First daemon that inherited the source.
889
+ * @param {string} sourceId - Stable listener source.
890
+ * @param {string} releaseId - Retained release.
891
+ * @param {{http: number, websocket: number}} connections - Exact live counts.
892
+ * @param {Set<net.Socket>} [delivered] - Successors that already received this update.
893
+ */
894
+ function publishOwnerConnectionState(firstSuccessor, sourceId, releaseId, connections, delivered = new Set()) {
895
+ let successor = firstSuccessor
896
+
897
+ while (successor && !delivered.has(successor)) {
898
+ delivered.add(successor)
899
+ if (!successor.destroyed) {
900
+ successor.write(`${JSON.stringify({connections, event: "owner-connection-state", releaseId, sourceId})}\n`)
901
+ }
902
+ successor = retiredListenerClients.get(successor)?.successor
903
+ }
904
+ }
905
+
906
+ /** @param {RetiredListenerTransaction} retirement - Disconnected completed listener. */
907
+ function publishClosedLocalSources(retirement) {
908
+ for (const [sourceId, releases] of retirement.localSources) {
909
+ for (const releaseId of releases.keys()) {
910
+ const connections = {http: 0, websocket: 0}
911
+ const delivered = /** @type {Set<net.Socket>} */ (new Set())
912
+
913
+ applyOwnerConnectionState(ownerState, sourceId, releaseId, connections)
914
+ if (replacementOwnerState) {
915
+ applyOwnerConnectionState(replacementOwnerState, sourceId, releaseId, connections)
916
+ publishOwnerConnectionState(replacementClient, sourceId, releaseId, connections, delivered)
917
+ }
918
+ publishOwnerConnectionState(retirement.successor, sourceId, releaseId, connections, delivered)
919
+ }
920
+ }
921
+ }
922
+
923
+ /**
924
+ * @param {import("./json.js").JsonValue | undefined} state - Candidate or committed private owner state.
925
+ * @param {string} sourceId - Stable listener source.
926
+ * @param {string} releaseId - Retained release.
927
+ * @param {{http: number, websocket: number}} connections - Exact live counts.
928
+ */
929
+ function applyOwnerConnectionState(state, sourceId, releaseId, connections) {
930
+ if (!state || typeof state !== "object" || Array.isArray(state)) throw new Error("Guardian owner connection state requires transferable owner state")
931
+ const transferable = /** @type {{listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>}} */ (state)
932
+ const sources = transferable.listenerConnectionSources || {}
933
+ const releases = sources[sourceId] || {}
934
+
935
+ if (connections.http + connections.websocket === 0) {
936
+ delete releases[releaseId]
937
+ } else {
938
+ releases[releaseId] = connections
939
+ }
940
+ if (Object.keys(releases).length === 0) delete sources[sourceId]
941
+ else sources[sourceId] = releases
942
+ transferable.listenerConnectionSources = sources
943
+ }
944
+
945
+ /** Clears the physically-owned source before another daemon reconstructs committed state. */
946
+ function clearOwnerLocalListenerSource() {
947
+ if (!ownerState || typeof ownerState !== "object" || Array.isArray(ownerState)) return
948
+ const transferable = /** @type {{listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>, listenerSourceId?: string}} */ (ownerState)
949
+ const sourceId = transferable.listenerSourceId
950
+
951
+ if (!sourceId) return
952
+ for (const releaseId of Object.keys(transferable.listenerConnectionSources?.[sourceId] || {})) {
953
+ const connections = {http: 0, websocket: 0}
954
+
955
+ applyOwnerConnectionState(ownerState, sourceId, releaseId, connections)
956
+ if (replacementOwnerState) {
957
+ applyOwnerConnectionState(replacementOwnerState, sourceId, releaseId, connections)
958
+ publishOwnerConnectionState(replacementClient, sourceId, releaseId, connections)
959
+ }
960
+ }
961
+ }
962
+
963
+ /**
964
+ * Preserves guardian-confirmed source updates while accepting the publisher's own local source.
965
+ * @param {import("./json.js").JsonValue | undefined} previousState - Current guardian state.
966
+ * @param {import("./json.js").JsonValue} nextState - Incoming owner publication.
967
+ * @returns {import("./json.js").JsonValue} Publication with guardian source state merged in.
968
+ */
969
+ function mergeOwnerConnectionState(previousState, nextState) {
970
+ if (!nextState || typeof nextState !== "object" || Array.isArray(nextState)) throw new Error("Guardian owner publication requires transferable owner state")
971
+ const next = /** @type {{listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>, listenerSourceId?: string}} */ (nextState)
972
+ const publishesSources = next.listenerConnectionSources !== undefined
973
+ const localSourceId = next.listenerSourceId
974
+
975
+ if (!previousState || typeof previousState !== "object" || Array.isArray(previousState) || !localSourceId) return nextState
976
+ const previous = /** @type {{listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>}} */ (previousState)
977
+ const sources = /** @type {Record<string, Record<string, {http: number, websocket: number}>>} */ ({})
978
+ const localReleases = next.listenerConnectionSources?.[localSourceId]
979
+
980
+ if (localReleases && Object.keys(localReleases).length > 0) sources[localSourceId] = localReleases
981
+ for (const [sourceId, releases] of Object.entries(previous.listenerConnectionSources || {})) {
982
+ if (sourceId !== localSourceId) sources[sourceId] = releases
983
+ }
984
+ if (publishesSources || Object.keys(sources).length > 0) next.listenerConnectionSources = sources
985
+ return nextState
986
+ }
987
+
988
+ /** @param {GuardianRequest} request - Staged same-authority listener handoff request. */
989
+ function requireRetiredOwnerReplacement(request) {
990
+ requireProcess(request)
991
+ if (!committedOwnerProcessKeys(ownerState).has(/** @type {string} */ (request.key))) {
992
+ throw new Error(`Guardian process ${request.key} does not belong to the committed owner`)
993
+ }
994
+ if (!replacementOwnerState) throw new Error("Retired owner replacement transaction is not staged")
995
+ if (!isDeepStrictEqual(ownerAuthority(ownerState), replacementAuthority)) throw new Error("Retired owner replacement requires unchanged owner authority")
996
+ }
997
+
998
+ /** @param {import("./json.js").JsonValue | undefined} state - Committed transferable state. */
999
+ async function requireMissingOwnerControlPath(state) {
1000
+ const controlPath = ownerControlPath(state)
1001
+
1002
+ try {
1003
+ await fs.lstat(controlPath)
1004
+ throw new Error(`Retired owner control socket ${controlPath} still exists`)
1005
+ } catch (error) {
1006
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
1007
+ }
1008
+ }
1009
+
571
1010
  /**
572
1011
  * @param {import("./json.js").JsonValue} state - Transfer state.
573
1012
  * @returns {import("./json.js").JsonValue} Embedded authority fence.
@@ -592,6 +1031,31 @@ function ownerControlPath(state) {
592
1031
  return control.path
593
1032
  }
594
1033
 
1034
+ /**
1035
+ * @param {import("./json.js").JsonValue | undefined} state - Committed transferable state.
1036
+ * @returns {string} Exact configured public state path.
1037
+ */
1038
+ function ownerStatePath(state) {
1039
+ if (!state || typeof state !== "object" || Array.isArray(state) || !("config" in state)) throw new Error("Guardian owner state is missing its committed config")
1040
+ const config = state.config
1041
+
1042
+ if (!config || typeof config !== "object" || Array.isArray(config) || !("statePath" in config) || typeof config.statePath !== "string") throw new Error("Guardian owner config is missing its state path")
1043
+ return config.statePath
1044
+ }
1045
+
1046
+ /** @param {import("./json.js").JsonValue | undefined} snapshot - Candidate public recovery snapshot. */
1047
+ function assertBridgeRecoverySnapshot(snapshot) {
1048
+ if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot) || !("recovery" in snapshot)) throw new Error("Legacy guardian owner claim requires a recovery snapshot")
1049
+ const recovery = snapshot.recovery
1050
+
1051
+ if (!recovery || typeof recovery !== "object" || Array.isArray(recovery) || !("guardian" in recovery)) throw new Error("Legacy guardian owner claim recovery snapshot is missing its guardian")
1052
+ const guardian = recovery.guardian
1053
+
1054
+ if (!guardian || typeof guardian !== "object" || Array.isArray(guardian) || guardian.socketPath !== socketPath || guardian.token !== token) {
1055
+ throw new Error("Legacy guardian owner claim recovery snapshot does not identify this guardian")
1056
+ }
1057
+ }
1058
+
595
1059
  /**
596
1060
  * Stops accepting connections and closes every authority channel except the response caller.
597
1061
  * @param {net.Socket} caller - Shutdown requester retained until it receives the response.
@@ -624,16 +1088,75 @@ async function finishShutdown() {
624
1088
  function broadcast(event) {
625
1089
  const line = `${JSON.stringify(event)}\n`
626
1090
 
627
- for (const client of clients) if (!client.destroyed) client.write(line)
1091
+ for (const client of clients) {
1092
+ if (client.destroyed) continue
1093
+ if (backpressuredClients.has(client)) {
1094
+ if (typeof event.key === "string") {
1095
+ const pending = pendingStatusEvents.get(client) || new Map()
1096
+
1097
+ if (event.event === "process-log") {
1098
+ const record = processes.get(event.key)
1099
+
1100
+ if (record) pending.set(event.key, `${JSON.stringify({event: "status", key: event.key, status: record.process.status()})}\n`)
1101
+ } else {
1102
+ pending.set(event.key, line)
1103
+ }
1104
+ pendingStatusEvents.set(client, pending)
1105
+ }
1106
+ continue
1107
+ }
1108
+ if (!client.write(line)) backpressuredClients.add(client)
1109
+ }
1110
+ }
1111
+
1112
+ /**
1113
+ * Flushes one latest status-bearing event per process after socket backpressure clears.
1114
+ * @param {net.Socket} client - Drained guardian client socket.
1115
+ */
1116
+ function flushStatusEvents(client) {
1117
+ backpressuredClients.delete(client)
1118
+ const pending = pendingStatusEvents.get(client)
1119
+
1120
+ if (!pending || client.destroyed) return
1121
+ for (const [key, line] of pending) {
1122
+ pending.delete(key)
1123
+ if (!client.write(line)) {
1124
+ backpressuredClients.add(client)
1125
+ break
1126
+ }
1127
+ }
1128
+ if (pending.size === 0) pendingStatusEvents.delete(client)
1129
+ }
1130
+
1131
+ /** @param {net.Socket} owner - Disconnected or retiring committed owner. */
1132
+ function rememberDisconnectedOwnerSuccessors(owner) {
1133
+ for (const transaction of retiredListenerClients.values()) {
1134
+ if (transaction.successor === owner) disconnectedOwnerSuccessors.add(transaction)
1135
+ }
1136
+ }
1137
+
1138
+ /** @param {net.Socket} successor - Newly claimed recovery owner. */
1139
+ function reattachRetiredListenerSuccessors(successor) {
1140
+ for (const transaction of disconnectedOwnerSuccessors) transaction.successor = successor
1141
+ disconnectedOwnerSuccessors.clear()
628
1142
  }
629
1143
 
630
1144
  /** @returns {void} Grants the next queued owner claim. */
631
1145
  function grantNextOwner() {
632
- const next = claimWaiters.shift()
1146
+ let next = claimWaiters.shift()
1147
+
1148
+ while (next && ownerState !== undefined && !isDeepStrictEqual(next.authority, ownerAuthority(ownerState))) {
1149
+ clearTimeout(next.timer)
1150
+ next.reject(new Error("Owner recovery authority changed while the claim was queued"))
1151
+ next = claimWaiters.shift()
1152
+ }
633
1153
 
634
1154
  if (!next) return
635
1155
  clearTimeout(next.timer)
1156
+ acceptOwnerClaim(next.ownerPid)
636
1157
  ownerClient = next.socket
1158
+ ownerClientPid = next.ownerPid
1159
+ reattachRetiredListenerSuccessors(next.socket)
637
1160
  next.resolve({claimed: true})
638
1161
  for (const waiter of claimWaiters.splice(0)) {
639
1162
  clearTimeout(waiter.timer)
@@ -641,6 +1164,171 @@ function grantNextOwner() {
641
1164
  }
642
1165
  }
643
1166
 
1167
+ /** @param {number} [delayMs] - Explicit retry delay, otherwise the accepted reconnect grace. */
1168
+ function scheduleOwnerRestart(delayMs) {
1169
+ if (shuttingDown || ownerClient || replacementClient || ownerRestartChild || ownerRestartTimer) return
1170
+ const recovery = ownerRecoveryDefinition()
1171
+
1172
+ if (!recovery) return
1173
+ ownerRestartTimer = setTimeout(() => {
1174
+ ownerRestartTimer = undefined
1175
+ if (shuttingDown || ownerClient || replacementClient || ownerRestartChild) return
1176
+ const currentRecovery = ownerRecoveryDefinition()
1177
+
1178
+ if (!currentRecovery) return
1179
+ let stdoutFd
1180
+ let stderrFd
1181
+ let child
1182
+
1183
+ try {
1184
+ if (currentRecovery.command.logPath) {
1185
+ fsSync.mkdirSync(path.dirname(currentRecovery.command.logPath), {recursive: true})
1186
+ stdoutFd = fsSync.openSync(currentRecovery.command.logPath, "a")
1187
+ stderrFd = fsSync.openSync(currentRecovery.command.logPath, "a")
1188
+ }
1189
+ child = spawn(currentRecovery.command.executable, currentRecovery.command.args, {
1190
+ cwd: currentRecovery.command.cwd,
1191
+ detached: true,
1192
+ env: currentRecovery.command.env,
1193
+ stdio: currentRecovery.command.logPath ? ["ignore", stdoutFd, stderrFd] : ["ignore", "inherit", "inherit"]
1194
+ })
1195
+ } catch (error) {
1196
+ reportOwnerRestartFailure(currentRecovery, restartFailure(error instanceof Error ? error : String(error)))
1197
+ scheduleOwnerRestart(OWNER_RESTART_RETRY_MS)
1198
+ return
1199
+ } finally {
1200
+ if (stdoutFd !== undefined) fsSync.closeSync(stdoutFd)
1201
+ if (stderrFd !== undefined) fsSync.closeSync(stderrFd)
1202
+ }
1203
+
1204
+ ownerRestartChild = child
1205
+ let settled = false
1206
+ const retry = (/** @type {{code: string, exitCode?: number | null, signal?: ChildExitSignal}} */ failure) => {
1207
+ if (settled || ownerRestartChild !== child) return
1208
+ settled = true
1209
+ killDetachedProcessGroup(child)
1210
+ reportOwnerRestartFailure(currentRecovery, failure)
1211
+ clearOwnerRestartTracking(child)
1212
+ ownerRestartRetryDelayMs = OWNER_RESTART_RETRY_MS
1213
+ if (ownerClient && ownerClientPid === child.pid) ownerClient.destroy()
1214
+ else {
1215
+ ownerRestartRetryDelayMs = undefined
1216
+ scheduleOwnerRestart(OWNER_RESTART_RETRY_MS)
1217
+ }
1218
+ }
1219
+
1220
+ ownerRestartStartupTimer = setTimeout(() => {
1221
+ if (ownerRestartChild !== child) return
1222
+ retry({code: "STARTUP_TIMEOUT"})
1223
+ }, currentRecovery.startupTimeoutMs)
1224
+ ownerRestartStartupTimer.unref()
1225
+ child.once("error", (error) => retry(restartFailure(error)))
1226
+ child.once("exit", (code, signal) => retry({code: "EARLY_EXIT", exitCode: code, signal}))
1227
+ child.unref()
1228
+ }, delayMs ?? recovery.reconnectGraceMs)
1229
+ ownerRestartTimer.unref()
1230
+ }
1231
+
1232
+ /** Cancels recovery for a superseded owner command. */
1233
+ function cancelOwnerRestart() {
1234
+ if (!ownerRestartTimer) return
1235
+ clearTimeout(ownerRestartTimer)
1236
+ ownerRestartTimer = undefined
1237
+ }
1238
+
1239
+ /**
1240
+ * Accepts an exact claimant and retires any different in-flight recovery child.
1241
+ * @param {number | undefined} ownerPid - Exact claimant process PID.
1242
+ */
1243
+ function acceptOwnerClaim(ownerPid) {
1244
+ cancelOwnerRestart()
1245
+ const child = ownerRestartChild
1246
+
1247
+ if (!child || child.pid === ownerPid) return
1248
+ clearOwnerRestartTracking(child)
1249
+ killDetachedProcessGroup(child)
1250
+ }
1251
+
1252
+ /**
1253
+ * Clears recovery-child startup tracking without disturbing a committed retired listener owner.
1254
+ * @param {import("node:child_process").ChildProcess} [expectedChild] - Optional exact child fence.
1255
+ * @returns {boolean} Whether matching tracking was cleared.
1256
+ */
1257
+ function clearOwnerRestartTracking(expectedChild) {
1258
+ if (expectedChild && ownerRestartChild !== expectedChild) return false
1259
+ if (ownerRestartStartupTimer) clearTimeout(ownerRestartStartupTimer)
1260
+ ownerRestartStartupTimer = undefined
1261
+ ownerRestartChild = undefined
1262
+ return true
1263
+ }
1264
+
1265
+ /**
1266
+ * Records an owner restart failure without exposing the private command or environment.
1267
+ * @param {{command: {cwd?: string, logPath?: string, pidPath?: string}} | undefined} recovery - Accepted recovery definition.
1268
+ * @param {{code: string, exitCode?: number | null, signal?: ChildExitSignal}} failure - Secret-safe startup failure.
1269
+ */
1270
+ function reportOwnerRestartFailure(recovery, failure) {
1271
+ const diagnostic = `${JSON.stringify({at: new Date().toISOString(), ...failure, message: "guardian failed to restart daemon"})}\n`
1272
+
1273
+ if (recovery?.command.logPath) {
1274
+ try {
1275
+ fsSync.appendFileSync(recovery.command.logPath, diagnostic)
1276
+ return
1277
+ } catch (logError) {
1278
+ process.stderr.write(`${JSON.stringify({at: new Date().toISOString(), code: errorCode(logError instanceof Error ? logError : String(logError)), message: "guardian failed to write daemon restart diagnostic"})}\n`)
1279
+ return
1280
+ }
1281
+ }
1282
+ process.stderr.write(diagnostic)
1283
+ }
1284
+
1285
+ /**
1286
+ * Terminates a detached recovery candidate and all descendants in its process group.
1287
+ * @param {import("node:child_process").ChildProcess} child - Exact detached process-group leader.
1288
+ */
1289
+ function killDetachedProcessGroup(child) {
1290
+ if (!child.pid) return
1291
+ try {
1292
+ process.kill(-child.pid, "SIGKILL")
1293
+ } catch (error) {
1294
+ const failure = error instanceof Error ? error : String(error)
1295
+
1296
+ if (errorCode(failure) === "ESRCH") return
1297
+ child.kill("SIGKILL")
1298
+ process.stderr.write(`${JSON.stringify({at: new Date().toISOString(), code: errorCode(failure), message: "guardian failed to kill daemon restart process group"})}\n`)
1299
+ }
1300
+ }
1301
+
1302
+ /**
1303
+ * @param {Error | string} error - Spawn failure.
1304
+ * @returns {{code: string}} Secret-safe failure.
1305
+ */
1306
+ function restartFailure(error) {
1307
+ return {code: errorCode(error)}
1308
+ }
1309
+
1310
+ /**
1311
+ * @param {Error | string} error - Error-like value.
1312
+ * @returns {string} Stable non-secret error code.
1313
+ */
1314
+ function errorCode(error) {
1315
+ if (error && typeof error === "object" && "code" in error && (typeof error.code === "string" || typeof error.code === "number")) return String(error.code)
1316
+ return "UNKNOWN"
1317
+ }
1318
+
1319
+ /**
1320
+ * @returns {{command: {args: string[], cwd: string, env: Record<string, string>, executable: string, logPath?: string, pidPath?: string}, reconnectGraceMs: number, startupTimeoutMs: number} | undefined} Recovery definition.
1321
+ */
1322
+ function ownerRecoveryDefinition() {
1323
+ if (!ownerState || typeof ownerState !== "object" || Array.isArray(ownerState) || !("recovery" in ownerState)) return
1324
+ const recovery = ownerState.recovery
1325
+
1326
+ if (!recovery || typeof recovery !== "object" || Array.isArray(recovery) || !("command" in recovery) || !("reconnectGraceMs" in recovery)) {
1327
+ throw new Error("Guardian owner recovery definition is incomplete")
1328
+ }
1329
+ return /** @type {{command: {args: string[], cwd: string, env: Record<string, string>, executable: string, logPath?: string, pidPath?: string}, reconnectGraceMs: number, startupTimeoutMs: number}} */ (recovery)
1330
+ }
1331
+
644
1332
  /**
645
1333
  * @param {Error | string} error - Error-like value.
646
1334
  * @returns {string} Error message.