rollbridge 0.1.35 → 0.1.37

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.
@@ -21,6 +21,8 @@ export default class GuardianClient {
21
21
  this.idleWaiters = /** @type {(() => void)[]} */ ([])
22
22
  this.guardianExitPromise = /** @type {Promise<void> | undefined} */ (undefined)
23
23
  this.processes = /** @type {Map<string, GuardianProcess>} */ (new Map())
24
+ this.reservedProcessKey = /** @type {string | undefined} */ (undefined)
25
+ this.reservedProcessProvenance = /** @type {string | undefined} */ (undefined)
24
26
  this.events = /** @type {Map<string, {reject: (error: Error) => void, resolve: (value: Record<string, import("./json.js").JsonValue>) => void}[]>} */ (new Map())
25
27
  this.eventHandlers = /** @type {Map<string, ((event: Record<string, import("./json.js").JsonValue>) => void)[]>} */ (new Map())
26
28
  }
@@ -52,7 +54,7 @@ export default class GuardianClient {
52
54
  }
53
55
 
54
56
  /**
55
- * Starts a current transaction guardian in front of this authenticated pre-split guardian.
57
+ * Starts a current transaction guardian in front of this authenticated legacy guardian.
56
58
  * @param {{ownerState: import("./json.js").JsonValue, socketPath: string, token: string}} options - Upgrade identity and exact committed state.
57
59
  * @returns {Promise<GuardianClient>} Current guardian client backed by the legacy supervisor.
58
60
  */
@@ -111,6 +113,27 @@ export default class GuardianClient {
111
113
  return processInstance
112
114
  }
113
115
 
116
+ /**
117
+ * @param {string} key - Exact committed-owner registration reserved until replacement commit.
118
+ * @param {string} provenance - Guardian-inventoried definition fence.
119
+ */
120
+ reserveProcessRecovery(key, provenance) {
121
+ if (this.reservedProcessKey) throw new Error(`Guardian process recovery ${this.reservedProcessKey} is already reserved`)
122
+ this.reservedProcessKey = key
123
+ this.reservedProcessProvenance = provenance
124
+ }
125
+
126
+ /** @param {string} key - Exact reserved registration to attach after authority commits. */
127
+ async recoverReservedProcess(key) {
128
+ if (this.reservedProcessKey !== key) throw new Error(`Guardian process recovery ${key} is not reserved`)
129
+ const processInstance = this.processes.get(key)
130
+
131
+ if (!processInstance) throw new Error(`Reserved guardian process ${key} was not reconstructed`)
132
+ await processInstance.attachReserved()
133
+ this.reservedProcessKey = undefined
134
+ this.reservedProcessProvenance = undefined
135
+ }
136
+
114
137
  /**
115
138
  * @param {Record<string, import("./json.js").JsonValue>} command - Command.
116
139
  * @returns {Promise<import("./json.js").JsonValue>} Guardian response.
@@ -166,6 +189,32 @@ export default class GuardianClient {
166
189
  await this.request({command: "publish-owner-state", ownerState})
167
190
  }
168
191
 
192
+ /**
193
+ * Classifies the authenticated guardian's owner-replacement protocol without preparing a transaction.
194
+ * @returns {Promise<"atomic" | "legacy">} Compatible replacement route.
195
+ */
196
+ async ownerReplacementProtocol() {
197
+ try {
198
+ const response = await this.request({command: "owner-replacement-capabilities"})
199
+
200
+ if (!isOwnerReplacementCapabilities(response)) throw new Error("Guardian returned an invalid owner-replacement capability response")
201
+ return "atomic"
202
+ } catch (error) {
203
+ if (!(error instanceof Error) || !isLegacyCapabilityDispatchDiagnostic(error.message)) throw error
204
+ }
205
+
206
+ try {
207
+ await this.request({command: "commit-retired-owner-replacement", replacementId: "owner-replacement-capability-probe"})
208
+ throw new Error("Guardian unexpectedly accepted the retired-owner capability probe")
209
+ } catch (error) {
210
+ if (!(error instanceof Error)) throw error
211
+ if (error.message === "Owner replacement transaction is not the prepared candidate") return "atomic"
212
+ if (error.message === "Guardian commit-retired-owner-replacement requires a process key" ||
213
+ error.message === "Unknown guardian command: commit-retired-owner-replacement") return "legacy"
214
+ throw new Error("Guardian returned an ambiguous retired-owner capability response", {cause: error})
215
+ }
216
+ }
217
+
169
218
  /**
170
219
  * @param {import("./json.js").JsonValue} authority - Persisted current authority.
171
220
  * @param {import("./json.js").JsonValue} nextAuthority - Requested authority.
@@ -202,6 +251,23 @@ export default class GuardianClient {
202
251
  await this.request({command: "commit-retired-owner-replacement", key, replacementId})
203
252
  }
204
253
 
254
+ /**
255
+ * Begins acquiring the authenticated legacy backend owner channel at the disruptive boundary.
256
+ * @param {string} replacementId - Exact prepared candidate transaction.
257
+ * @param {number} graceMs - Event-driven incumbent disconnect grace.
258
+ */
259
+ async beginLegacyOwnerClaim(replacementId, graceMs) {
260
+ await this.request({command: "begin-legacy-owner-claim", graceMs, replacementId})
261
+ }
262
+
263
+ /**
264
+ * Waits until the upgraded guardian owns its authenticated legacy backend.
265
+ * @param {string} replacementId - Exact prepared candidate transaction.
266
+ */
267
+ async completeLegacyOwnerClaim(replacementId) {
268
+ await this.request({command: "complete-legacy-owner-claim", replacementId})
269
+ }
270
+
205
271
  /** @param {string} replacementId - Committed transaction awaiting incumbent retirement. */
206
272
  async finalizeOwnerReplacement(replacementId) {
207
273
  await this.request({command: "finalize-owner-replacement", replacementId})
@@ -354,6 +420,16 @@ class GuardianProcess extends ManagedProcess {
354
420
 
355
421
  /** Reconnects to an already registered guardian process without changing its desired state. */
356
422
  async recover() {
423
+ if (this.client.reservedProcessKey === this.key) {
424
+ if (this.client.reservedProcessProvenance !== this.provenance) throw new Error(`Guardian provenance mismatch for reserved process ${this.key}`)
425
+ this.cachedStatus = asProcessStatus(await this.client.request({command: "status", key: this.key}))
426
+ return
427
+ }
428
+ await this.ensureRegistered()
429
+ }
430
+
431
+ /** Attaches a reconstructed process whose incumbent-owned registration was reserved through commit. */
432
+ async attachReserved() {
357
433
  await this.ensureRegistered()
358
434
  }
359
435
 
@@ -463,6 +539,28 @@ function asProcessStatus(value) {
463
539
  return JSON.parse(JSON.stringify(value))
464
540
  }
465
541
 
542
+ /**
543
+ * @param {import("./json.js").JsonValue} value - Capability response.
544
+ * @returns {boolean} Whether the response explicitly guarantees retired-owner commit support.
545
+ */
546
+ function isOwnerReplacementCapabilities(value) {
547
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false
548
+ const response = /** @type {Record<string, import("./json.js").JsonValue>} */ (value)
549
+
550
+ return response.protocol === "owner-replacement" && Number.isInteger(response.version) && Number(response.version) >= 1 &&
551
+ Array.isArray(response.commands) && response.commands.every((command) => typeof command === "string") &&
552
+ response.commands.includes("commit-retired-owner-replacement")
553
+ }
554
+
555
+ /**
556
+ * @param {string} diagnostic - Exact old generic-dispatch response.
557
+ * @returns {boolean} Whether this is the narrow old unknown-command dispatch signature.
558
+ */
559
+ function isLegacyCapabilityDispatchDiagnostic(diagnostic) {
560
+ return diagnostic === "Guardian owner-replacement-capabilities requires a process key" ||
561
+ diagnostic === "Unknown guardian command: owner-replacement-capabilities"
562
+ }
563
+
466
564
  /**
467
565
  * @param {import("./json.js").JsonValue} value - Protocol value.
468
566
  * @returns {import("./managed-process.js").ManagedProcessLog} Process output entry.
@@ -77,6 +77,8 @@ let ownerMutationId
77
77
  let retiringClient
78
78
  /** @type {string | undefined} */
79
79
  let retiringReplacementId
80
+ /** @type {Promise<Error | undefined> | undefined} */
81
+ let legacyOwnerClaim
80
82
  let shuttingDown = false
81
83
  /** @type {net.Socket | undefined} */
82
84
  let shutdownClient
@@ -117,11 +119,7 @@ const server = net.createServer((socket) => {
117
119
  }
118
120
  if (retiringClient === socket) finalizeReplacementRetirement()
119
121
  if (replacementClient === socket) {
120
- replacementClient = undefined
121
- replacementId = undefined
122
- replacementAuthority = undefined
123
- replacementOwnerState = undefined
124
- if (ownerClient && !ownerClient.destroyed) ownerClient.write(`${JSON.stringify({event: "replacement-aborted"})}\n`)
122
+ abortReplacement("Replacement candidate disconnected before commit")
125
123
  }
126
124
  if (shutdownClient === socket) void finishShutdown()
127
125
  })
@@ -180,6 +178,10 @@ async function handleLine(socket, line) {
180
178
  async function execute(request, socket) {
181
179
  if (shuttingDown) throw new Error("Process guardian is shutting down")
182
180
 
181
+ if (request.command === "owner-replacement-capabilities") {
182
+ return {commands: ["commit-retired-owner-replacement"], protocol: "owner-replacement", version: 1}
183
+ }
184
+
183
185
  if (request.command === "claim-owner") {
184
186
  if (!ownerClient) {
185
187
  if (ownerState !== undefined && !isDeepStrictEqual(request.authority, ownerAuthority(ownerState))) {
@@ -225,6 +227,26 @@ async function execute(request, socket) {
225
227
  return {abandoned: true}
226
228
  }
227
229
 
230
+ if (request.command === "begin-legacy-owner-claim") {
231
+ if (!legacyGuardian) throw new Error("Guardian is not a legacy upgrade coordinator")
232
+ requireReplacement(socket, request)
233
+ if (legacyOwnerClaim) throw new Error("Legacy guardian owner claim is already pending")
234
+ legacyOwnerClaim = legacyGuardian.claimOwner(request.graceMs ?? 30000, ownerAuthority(ownerState)).then(
235
+ () => undefined,
236
+ (error) => error instanceof Error ? error : new Error(String(error))
237
+ )
238
+ return {prepared: true}
239
+ }
240
+
241
+ if (request.command === "complete-legacy-owner-claim") {
242
+ if (!legacyGuardian || !legacyOwnerClaim) throw new Error("Legacy guardian owner claim was not prepared")
243
+ requireReplacement(socket, request)
244
+ const claimError = await legacyOwnerClaim
245
+
246
+ if (claimError) throw claimError
247
+ return {claimed: true}
248
+ }
249
+
228
250
  if (request.command === "publish-owner-state") {
229
251
  requireOwner(socket, request.command)
230
252
  if (request.ownerState === undefined) throw new Error("Guardian owner publication requires ownerState")
@@ -470,7 +492,10 @@ function requireOwner(socket, command) {
470
492
 
471
493
  /** @param {string} reason - Abort diagnostic. */
472
494
  function abortReplacement(reason) {
473
- if (replacementClient && !replacementClient.destroyed) replacementClient.write(`${JSON.stringify({event: "replacement-aborted", reason})}\n`)
495
+ const event = `${JSON.stringify({event: "replacement-aborted", reason})}\n`
496
+
497
+ if (replacementClient && !replacementClient.destroyed) replacementClient.write(event)
498
+ if (ownerClient && !ownerClient.destroyed) ownerClient.write(event)
474
499
  replacementClient = undefined
475
500
  replacementId = undefined
476
501
  replacementAuthority = undefined
@@ -0,0 +1,106 @@
1
+ // @ts-check
2
+
3
+ import fs from "node:fs/promises"
4
+ import net from "node:net"
5
+
6
+ const [socketPath, backendPath, mode = "partial"] = process.argv.slice(2)
7
+
8
+ if (!socketPath || !backendPath || !process.send) throw new Error("partial process-guardian requires frontend and backend sockets plus a private bootstrap channel")
9
+
10
+ const token = await new Promise((resolve, reject) => {
11
+ process.once("disconnect", () => reject(new Error("Partial guardian bootstrap channel closed before authentication capability arrived")))
12
+ process.once("message", (message) => {
13
+ if (!message || typeof message !== "object" || !("token" in message) || typeof message.token !== "string" || !message.token) {
14
+ reject(new Error("Partial guardian bootstrap authentication capability is invalid"))
15
+ return
16
+ }
17
+ resolve(message.token)
18
+ })
19
+ })
20
+
21
+ const clients = new Set()
22
+ const server = net.createServer((frontend) => {
23
+ const backend = net.createConnection(backendPath)
24
+ let frontendBuffer = ""
25
+ let backendBuffer = ""
26
+
27
+ clients.add(frontend)
28
+ frontend.setEncoding("utf8")
29
+ backend.setEncoding("utf8")
30
+ frontend.on("error", () => frontend.destroy())
31
+ backend.on("error", () => backend.destroy())
32
+ frontend.once("close", () => {
33
+ clients.delete(frontend)
34
+ backend.destroy()
35
+ })
36
+ backend.once("close", () => frontend.destroy())
37
+ frontend.on("data", (chunk) => {
38
+ frontendBuffer += chunk
39
+ let newline = frontendBuffer.indexOf("\n")
40
+
41
+ while (newline >= 0) {
42
+ const line = frontendBuffer.slice(0, newline)
43
+ const request = JSON.parse(line)
44
+
45
+ frontendBuffer = frontendBuffer.slice(newline + 1)
46
+ if (request.token !== token) {
47
+ frontend.write(`${JSON.stringify({error: "Guardian authentication failed", id: request.id})}\n`)
48
+ } else if (request.command === "owner-replacement-capabilities") {
49
+ const result = mode === "malformed-capability"
50
+ ? {protocol: "owner-replacement", version: 1}
51
+ : undefined
52
+
53
+ frontend.write(`${JSON.stringify(result
54
+ ? {id: request.id, result}
55
+ : {error: "Guardian owner-replacement-capabilities requires a process key", id: request.id})}\n`)
56
+ } else if (request.command === "commit-retired-owner-replacement") {
57
+ frontend.write(`${JSON.stringify({
58
+ error: request.key
59
+ ? "Guardian commit-retired-owner-replacement requires the committed owner"
60
+ : "Guardian commit-retired-owner-replacement requires a process key",
61
+ id: request.id
62
+ })}\n`)
63
+ } else {
64
+ if (mode === "wrong-provenance" && request.command === "register") {
65
+ request.provenance = `${request.provenance}-tampered`
66
+ backend.write(`${JSON.stringify(request)}\n`)
67
+ } else {
68
+ backend.write(`${line}\n`)
69
+ }
70
+ }
71
+ newline = frontendBuffer.indexOf("\n")
72
+ }
73
+ })
74
+ backend.on("data", (chunk) => {
75
+ backendBuffer += chunk
76
+ let newline = backendBuffer.indexOf("\n")
77
+
78
+ while (newline >= 0) {
79
+ const line = backendBuffer.slice(0, newline)
80
+
81
+ backendBuffer = backendBuffer.slice(newline + 1)
82
+ frontend.write(`${line}\n`)
83
+ newline = backendBuffer.indexOf("\n")
84
+ }
85
+ })
86
+ })
87
+
88
+ server.on("error", (error) => {
89
+ if (process.send) process.send({error: error.message})
90
+ else throw error
91
+ })
92
+ server.listen(socketPath, async () => {
93
+ await fs.chmod(socketPath, 0o600)
94
+ process.send?.({ready: true})
95
+ process.disconnect?.()
96
+ })
97
+
98
+ const shutdown = () => {
99
+ for (const client of clients) client.destroy()
100
+ server.close(() => {
101
+ void fs.rm(socketPath, {force: true}).finally(() => process.exit(0))
102
+ })
103
+ }
104
+
105
+ process.once("SIGINT", shutdown)
106
+ process.once("SIGTERM", shutdown)
@@ -210,6 +210,32 @@ test("replacement staging rejects owner state published after prepare", async ()
210
210
  }
211
211
  })
212
212
 
213
+ test("replacement abort notifies both the candidate and committed owner", async () => {
214
+ const fixture = await createGuardian()
215
+ const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
216
+ const authority = {configDigest: "incumbent", runtime: null}
217
+ const nextAuthority = {configDigest: "candidate", runtime: null}
218
+
219
+ try {
220
+ await fixture.client.publishOwnerState({authority, snapshot: {activeReleaseId: "v1"}})
221
+ await candidate.connect()
222
+ const prepared = await candidate.prepareOwnerReplacement(authority, nextAuthority)
223
+ const incumbentAborted = fixture.client.waitForEvent("replacement-aborted")
224
+ const candidateAborted = candidate.waitForEvent("replacement-aborted")
225
+
226
+ await candidate.abortOwnerReplacement(prepared.replacementId)
227
+ assert.deepEqual(await Promise.all([incumbentAborted, candidateAborted]), [
228
+ {event: "replacement-aborted", reason: "Replacement candidate aborted the prepared transaction"},
229
+ {event: "replacement-aborted", reason: "Replacement candidate aborted the prepared transaction"}
230
+ ])
231
+ await fixture.client.shutdown()
232
+ await fixture.client.guardianExit()
233
+ } finally {
234
+ candidate.disconnect()
235
+ await cleanupGuardian(fixture)
236
+ }
237
+ })
238
+
213
239
  test("retired owner replacement commit carries its exact recovered process key", async () => {
214
240
  const client = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
215
241
  const replacementId = "prepared-replacement"
@@ -224,6 +250,77 @@ test("retired owner replacement commit carries its exact recovered process key",
224
250
  await client.commitRetiredOwnerReplacement(replacementId, processKey)
225
251
  })
226
252
 
253
+ test("guardian owner-replacement capability classification is explicit and fail closed", async () => {
254
+ const current = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
255
+
256
+ current.request = async (request) => {
257
+ assert.deepEqual(request, {command: "owner-replacement-capabilities"})
258
+ return {
259
+ commands: ["commit-retired-owner-replacement", "future-command"],
260
+ futureField: {supported: true},
261
+ protocol: "owner-replacement",
262
+ version: 2
263
+ }
264
+ }
265
+ assert.equal(await current.ownerReplacementProtocol(), "atomic")
266
+
267
+ for (const [commitDiagnostic, expected] of [
268
+ ["Owner replacement transaction is not the prepared candidate", "atomic"],
269
+ ["Guardian commit-retired-owner-replacement requires a process key", "legacy"],
270
+ ["Unknown guardian command: commit-retired-owner-replacement", "legacy"]
271
+ ]) {
272
+ const older = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
273
+ const requests = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ ([])
274
+
275
+ older.request = async (request) => {
276
+ requests.push(request)
277
+ if (request.command === "owner-replacement-capabilities") throw new Error("Guardian owner-replacement-capabilities requires a process key")
278
+ throw new Error(commitDiagnostic)
279
+ }
280
+ assert.equal(await older.ownerReplacementProtocol(), expected)
281
+ assert.deepEqual(requests, [
282
+ {command: "owner-replacement-capabilities"},
283
+ {command: "commit-retired-owner-replacement", replacementId: "owner-replacement-capability-probe"}
284
+ ])
285
+ }
286
+
287
+ for (const fixture of [
288
+ async () => ({commands: [], protocol: "owner-replacement", version: 1}),
289
+ async (/** @type {Record<string, import("../src/json.js").JsonValue>} */ request) => {
290
+ if (request.command === "owner-replacement-capabilities") throw new Error("Guardian owner-replacement-capabilities requires a process key")
291
+ throw new Error("Guardian commit-retired-owner-replacement requires the committed owner")
292
+ }
293
+ ]) {
294
+ const ambiguous = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
295
+
296
+ ambiguous.request = fixture
297
+ await assert.rejects(() => ambiguous.ownerReplacementProtocol(), /invalid owner-replacement capability response|ambiguous retired-owner capability response/)
298
+ }
299
+ })
300
+
301
+ test("reserved process recovery rejects a reconstructed definition with different provenance", async () => {
302
+ const fixture = await createGuardian()
303
+ const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
304
+ const processKey = "release:v1:worker"
305
+
306
+ try {
307
+ await fixture.client.process(processKey, definition("worker")).recover()
308
+ const [registration] = await fixture.client.inventory()
309
+
310
+ assert.ok(registration)
311
+ await candidate.connect()
312
+ candidate.reserveProcessRecovery(processKey, registration.provenance)
313
+ await assert.rejects(
314
+ () => candidate.process(processKey, definition("different-worker")).recover(),
315
+ /provenance mismatch for reserved process/
316
+ )
317
+ assert.deepEqual((await fixture.client.inventory()).map(({key}) => key), [processKey])
318
+ } finally {
319
+ candidate.disconnect()
320
+ await cleanupGuardian(fixture)
321
+ }
322
+ })
323
+
227
324
  test("retired owner replacement rejects a registered process absent from committed owner state", async () => {
228
325
  const fixture = await createGuardian()
229
326
  const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})