rollbridge 0.1.54 → 0.1.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -0
- package/changelog.d/20260909120000-velocious-testing.md +1 -0
- package/eslint.config.js +8 -0
- package/package.json +3 -2
- package/test/completion.test.js +18 -16
- package/test/config-examples.test.js +16 -17
- package/test/config-path.test.js +10 -11
- package/test/config-validation.test.js +163 -167
- package/test/control-protocol.test.js +22 -21
- package/test/daemon-bootstrap.test.js +104 -104
- package/test/daemon-runtime.test.js +17 -26
- package/test/doctor.test.js +51 -49
- package/test/event-log.test.js +13 -11
- package/test/guardian-client.test.js +140 -148
- package/test/health.test.js +6 -4
- package/test/logs.test.js +17 -18
- package/test/managed-process.test.js +96 -91
- package/test/owner-recovery.test.js +226 -239
- package/test/owner-replacement.test.js +227 -222
- package/test/package-metadata.test.js +48 -39
- package/test/port-allocator.test.js +13 -16
- package/test/predeploy-cleanup.test.js +12 -10
- package/test/process-memory.test.js +17 -15
- package/test/proxy.test.js +10 -8
- package/test/recover.test.js +30 -23
- package/test/release-group.test.js +16 -17
- package/test/release-retention.test.js +10 -8
- package/test/release-runtime-retention.test.js +31 -39
- package/test/rollbridge.test.js +377 -396
- package/test/shutdown-completion.test.js +51 -51
- package/test/state-store.test.js +10 -8
- package/test/system-ids.test.js +15 -13
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import assert from "node:assert/strict"
|
|
4
3
|
import {spawn} from "node:child_process"
|
|
5
4
|
import {once} from "node:events"
|
|
6
5
|
import fs from "node:fs/promises"
|
|
7
6
|
import os from "node:os"
|
|
8
7
|
import path from "node:path"
|
|
9
|
-
import test from "
|
|
8
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
10
9
|
import {fileURLToPath} from "node:url"
|
|
11
10
|
import GuardianClient from "../src/guardian-client.js"
|
|
12
11
|
import {waitForProcessExit} from "./support/process.js"
|
|
13
12
|
|
|
13
|
+
describe("guardian-client", () => {
|
|
14
|
+
|
|
14
15
|
const legacyGuardianPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "pre-split3-process-guardian.js")
|
|
15
16
|
const recoveryOwnerPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "guardian-recovery-owner.js")
|
|
16
17
|
|
|
@@ -18,17 +19,21 @@ test("guardian bootstrap capability is absent from process argv", async () => {
|
|
|
18
19
|
const fixture = await createGuardian()
|
|
19
20
|
|
|
20
21
|
try {
|
|
21
|
-
|
|
22
|
+
expect(await fixture.client.capabilities()).toEqual({daemonRecovery: 1, generationReactivation: 1})
|
|
22
23
|
const commandLine = await fs.readFile(`/proc/${fixture.client.pid}/cmdline`, "utf8")
|
|
23
24
|
const environment = await fs.readFile(`/proc/${fixture.client.pid}/environ`, "utf8")
|
|
24
25
|
const status = await fs.readFile(`/proc/${fixture.client.pid}/status`, "utf8")
|
|
25
26
|
const inventory = JSON.stringify(await fixture.client.inventory())
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
expect(commandLine.includes("process-guardian.js")).toBeTruthy()
|
|
29
|
+
// Guardian capability must not be exposed through argv.
|
|
30
|
+
expect(commandLine.includes(fixture.token)).toBe(false)
|
|
31
|
+
// Guardian capability must not be exposed through env.
|
|
32
|
+
expect(environment.includes(fixture.token)).toBe(false)
|
|
33
|
+
// Guardian capability must not be exposed through process status/title.
|
|
34
|
+
expect(status.includes(fixture.token)).toBe(false)
|
|
35
|
+
// Guardian capability must not be exposed through guardian status.
|
|
36
|
+
expect(inventory.includes(fixture.token)).toBe(false)
|
|
32
37
|
} finally {
|
|
33
38
|
await cleanupGuardian(fixture)
|
|
34
39
|
}
|
|
@@ -43,11 +48,11 @@ test("guardian inventory removes only an exact owned provenance", async () => {
|
|
|
43
48
|
const inventory = await fixture.client.inventory()
|
|
44
49
|
const candidate = inventory.find((entry) => entry.key === "candidate")
|
|
45
50
|
|
|
46
|
-
|
|
47
|
-
await
|
|
48
|
-
|
|
51
|
+
if (!candidate) throw new Error("Missing required fixture: candidate")
|
|
52
|
+
await expect(fixture.client.remove("candidate", `${candidate.provenance}-wrong`)).rejects.toThrow(/provenance mismatch/)
|
|
53
|
+
expect((await fixture.client.inventory()).length).toBe(1)
|
|
49
54
|
await fixture.client.remove("candidate", candidate.provenance)
|
|
50
|
-
|
|
55
|
+
expect(await fixture.client.inventory()).toEqual([])
|
|
51
56
|
} finally {
|
|
52
57
|
await cleanupGuardian(fixture)
|
|
53
58
|
}
|
|
@@ -64,7 +69,7 @@ test("guardian runs a strict activation lifecycle command for the exact register
|
|
|
64
69
|
try {
|
|
65
70
|
await processInstance.start()
|
|
66
71
|
await processInstance.activateStrict()
|
|
67
|
-
|
|
72
|
+
expect(await fs.readFile(activationPath, "utf8")).toBe("activated")
|
|
68
73
|
} finally {
|
|
69
74
|
await cleanupGuardian(fixture)
|
|
70
75
|
}
|
|
@@ -79,7 +84,7 @@ test("guardian preserves a custom activation timeout in the registered process d
|
|
|
79
84
|
|
|
80
85
|
try {
|
|
81
86
|
await processInstance.start()
|
|
82
|
-
await
|
|
87
|
+
await expect(processInstance.activateStrict()).rejects.toThrow(/activate command timed out after 10ms/i)
|
|
83
88
|
} finally {
|
|
84
89
|
await cleanupGuardian(fixture)
|
|
85
90
|
}
|
|
@@ -112,20 +117,20 @@ test("client reactivates a retained process through a guardian without the react
|
|
|
112
117
|
await processInstance.quiesceStrict()
|
|
113
118
|
await processInstance.reactivateStrict()
|
|
114
119
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
expect(processInstance.status().pid).toBe(pid)
|
|
121
|
+
expect(processInstance.status().state).toBe("running")
|
|
122
|
+
expect(processInstance.status().lifecycleRole).toBe("active")
|
|
123
|
+
expect(await fs.readFile(lifecyclePath, "utf8")).toBe("activate\nretire\nactivate\n")
|
|
119
124
|
|
|
120
125
|
const restarted = once(processInstance, "started")
|
|
121
126
|
|
|
122
|
-
|
|
127
|
+
if (!pid) throw new Error("Missing required fixture: pid")
|
|
123
128
|
process.kill(-pid, "SIGKILL")
|
|
124
129
|
await restarted
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
130
|
+
expect(processInstance.status().pid).not.toBe(pid)
|
|
131
|
+
expect(processInstance.status().state).toBe("running")
|
|
132
|
+
expect(processInstance.status().lifecycleRole).toBe("active")
|
|
133
|
+
expect(await fs.readFile(lifecyclePath, "utf8")).toBe("activate\nretire\nactivate\nactivate\n")
|
|
129
134
|
} finally {
|
|
130
135
|
await cleanupGuardian(fixture)
|
|
131
136
|
}
|
|
@@ -157,10 +162,10 @@ test("client reverses a worker quiet hook through a pre-reactivation guardian",
|
|
|
157
162
|
await processInstance.quiesceStrict()
|
|
158
163
|
await processInstance.reactivateStrict()
|
|
159
164
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
expect(processInstance.status().pid).toBe(pid)
|
|
166
|
+
expect(processInstance.status().state).toBe("running")
|
|
167
|
+
expect(processInstance.status().lifecycleRole).toBe("active")
|
|
168
|
+
expect(await fs.readFile(lifecyclePath, "utf8")).toBe("quiet\nresume\n")
|
|
164
169
|
} finally {
|
|
165
170
|
await cleanupGuardian(fixture)
|
|
166
171
|
}
|
|
@@ -178,8 +183,8 @@ test("guardian atomically updates process provenance with private owner state",
|
|
|
178
183
|
const previousProvenance = (await fixture.client.inventory())[0]?.provenance
|
|
179
184
|
|
|
180
185
|
await processInstance.updateDefinition({...definition("service"), env: {RELEASE: "v2"}}, nextOwnerState)
|
|
181
|
-
|
|
182
|
-
|
|
186
|
+
expect(await fixture.client.ownerState()).toEqual(nextOwnerState)
|
|
187
|
+
expect((await fixture.client.inventory())[0]?.provenance).not.toBe(previousProvenance)
|
|
183
188
|
} finally {
|
|
184
189
|
await cleanupGuardian(fixture)
|
|
185
190
|
}
|
|
@@ -201,9 +206,10 @@ test("guardian forwards each retained output line to its exact process proxy", a
|
|
|
201
206
|
const [entry] = await Promise.race([logged, exitedFirst])
|
|
202
207
|
const event = await forwarded
|
|
203
208
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
209
|
+
expect(entry.line).toBe(marker)
|
|
210
|
+
// Log events must not resend the complete retained process status.
|
|
211
|
+
expect(event.status).toBe(undefined)
|
|
212
|
+
expect(processInstance.status().logs.some((candidate) => candidate.line === marker)).toBeTruthy()
|
|
207
213
|
} finally {
|
|
208
214
|
await cleanupGuardian(fixture)
|
|
209
215
|
}
|
|
@@ -219,12 +225,12 @@ test("guardian delivers the final process status after dropping logs for a backp
|
|
|
219
225
|
})
|
|
220
226
|
const socket = fixture.client.socket
|
|
221
227
|
|
|
222
|
-
|
|
228
|
+
if (!socket) throw new Error("Missing required fixture: socket")
|
|
223
229
|
try {
|
|
224
230
|
await processInstance.start()
|
|
225
231
|
const pid = processInstance.status().pid
|
|
226
232
|
|
|
227
|
-
|
|
233
|
+
if (!pid) throw new Error("Missing required fixture: pid")
|
|
228
234
|
const finalStatus = fixture.client.waitForEvent("process")
|
|
229
235
|
|
|
230
236
|
socket.pause()
|
|
@@ -242,7 +248,7 @@ test("guardian delivers the final process status after dropping logs for a backp
|
|
|
242
248
|
} finally {
|
|
243
249
|
clearTimeout(timeout)
|
|
244
250
|
}
|
|
245
|
-
|
|
251
|
+
expect(processInstance.status().state).toBe("failed")
|
|
246
252
|
} finally {
|
|
247
253
|
socket.resume()
|
|
248
254
|
await cleanupGuardian(fixture)
|
|
@@ -262,7 +268,7 @@ test("guardian resynchronizes retained logs after dropping output for a backpres
|
|
|
262
268
|
})
|
|
263
269
|
const socket = fixture.client.socket
|
|
264
270
|
|
|
265
|
-
|
|
271
|
+
if (!socket) throw new Error("Missing required fixture: socket")
|
|
266
272
|
try {
|
|
267
273
|
await processInstance.start()
|
|
268
274
|
socket.pause()
|
|
@@ -282,7 +288,7 @@ test("guardian resynchronizes retained logs after dropping output for a backpres
|
|
|
282
288
|
} finally {
|
|
283
289
|
clearTimeout(timeout)
|
|
284
290
|
}
|
|
285
|
-
|
|
291
|
+
expect(processInstance.status().logs.at(-1)?.line).toBe(finalLine)
|
|
286
292
|
} finally {
|
|
287
293
|
socket.resume()
|
|
288
294
|
await cleanupGuardian(fixture)
|
|
@@ -298,13 +304,14 @@ test("guardian shutdown reports an exact owned process stop failure", async () =
|
|
|
298
304
|
await processInstance.start()
|
|
299
305
|
const [entry] = await fixture.client.inventory()
|
|
300
306
|
|
|
301
|
-
|
|
307
|
+
if (!entry) throw new Error("Missing required fixture: entry")
|
|
302
308
|
fixture.client.disconnect()
|
|
303
309
|
await replacement.connect()
|
|
304
310
|
await replacement.claimOwner(0, null)
|
|
305
|
-
await
|
|
306
|
-
|
|
307
|
-
await
|
|
311
|
+
await expect(replacement.reconcileInventory()).rejects.toThrow(/NOT_A_SIGNAL|Unknown signal/)
|
|
312
|
+
// Failed reconciliation must retain the exact registration.
|
|
313
|
+
expect((await replacement.inventory()).map(({key, provenance}) => ({key, provenance}))).toEqual([{key: entry.key, provenance: entry.provenance}])
|
|
314
|
+
await expect(replacement.shutdown()).rejects.toThrow(/NOT_A_SIGNAL|Unknown signal/)
|
|
308
315
|
} finally {
|
|
309
316
|
const inventory = await replacement.inventory().catch(() => [])
|
|
310
317
|
const entry = inventory[0]
|
|
@@ -327,8 +334,8 @@ test("successful guardian shutdown closes an authenticated waiting contender bef
|
|
|
327
334
|
|
|
328
335
|
try {
|
|
329
336
|
await contender.connect()
|
|
330
|
-
|
|
331
|
-
|
|
337
|
+
if (!fixture.client.socket) throw new Error("Missing required fixture: fixture.client.socket")
|
|
338
|
+
if (!contender.socket) throw new Error("Missing required fixture: contender.socket")
|
|
332
339
|
const shutdownOrder = /** @type {string[]} */ ([])
|
|
333
340
|
const onData = fixture.client.onData.bind(fixture.client)
|
|
334
341
|
|
|
@@ -339,14 +346,17 @@ test("successful guardian shutdown closes an authenticated waiting contender bef
|
|
|
339
346
|
fixture.client.socket.once("close", () => shutdownOrder.push("caller-close"))
|
|
340
347
|
const contenderClosed = new Promise((resolve) => contender.socket?.once("close", () => resolve(undefined)))
|
|
341
348
|
const waitingClaim = contender.claimOwner(250, null)
|
|
342
|
-
const rejectedClaim =
|
|
349
|
+
const rejectedClaim = (async () => {
|
|
350
|
+
await expect(waitingClaim).rejects.toThrow(/connection closed/)
|
|
351
|
+
})()
|
|
343
352
|
|
|
344
353
|
await fixture.client.shutdown()
|
|
345
354
|
await rejectedClaim
|
|
346
355
|
await contenderClosed
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
await
|
|
356
|
+
// Shutdown success must be received before the caller connection closes.
|
|
357
|
+
expect(shutdownOrder).toEqual(["response", "caller-close"])
|
|
358
|
+
await expect(contender.inventory()).rejects.toThrow(/not connected/)
|
|
359
|
+
await expect(fs.access(fixture.client.socketPath)).rejects.toMatchObject({code: "ENOENT"})
|
|
350
360
|
await fixture.client.guardianExit()
|
|
351
361
|
} finally {
|
|
352
362
|
contender.disconnect()
|
|
@@ -370,7 +380,7 @@ test("guardian restart uses the latest accepted command and exact environment",
|
|
|
370
380
|
|
|
371
381
|
const [restart] = await waitForRestartRecords(markerPath, 1)
|
|
372
382
|
|
|
373
|
-
|
|
383
|
+
expect({home: restart.home, marker: restart.marker}).toEqual({home: null, marker: "new"})
|
|
374
384
|
} finally {
|
|
375
385
|
await reconnectAndShutdownGuardian(fixture, authority)
|
|
376
386
|
}
|
|
@@ -390,7 +400,7 @@ test("guardian rearms recovery after an ownerless replacement aborts", async ()
|
|
|
390
400
|
|
|
391
401
|
await new Promise((resolve) => setTimeout(resolve, 80))
|
|
392
402
|
await replacement.abortOwnerReplacement(prepared.replacementId)
|
|
393
|
-
|
|
403
|
+
expect((await waitForRestartRecords(markerPath, 1)).map(({home, marker}) => ({home, marker}))).toEqual([{home: null, marker: "accepted"}])
|
|
394
404
|
} finally {
|
|
395
405
|
replacement.disconnect()
|
|
396
406
|
await reconnectAndShutdownGuardian(fixture, authority)
|
|
@@ -407,9 +417,9 @@ test("guardian retry backoff remains nonzero when reconnect grace is zero", asyn
|
|
|
407
417
|
fixture.client.disconnect()
|
|
408
418
|
const records = await waitForRestartRecords(markerPath, 2)
|
|
409
419
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
420
|
+
expect(typeof records[0]?.at).toBe("number")
|
|
421
|
+
expect(typeof records[1]?.at).toBe("number")
|
|
422
|
+
expect({value: Boolean(Number(records[1].at) - Number(records[0].at) >= 900), context: `failed owner recovery retried after ${Number(records[1].at) - Number(records[0].at)}ms`}).toMatchObject({value: true})
|
|
413
423
|
} finally {
|
|
414
424
|
await reconnectAndShutdownGuardian(fixture, authority)
|
|
415
425
|
}
|
|
@@ -436,7 +446,7 @@ test("a retired guardian-started owner does not block recovery of its replacemen
|
|
|
436
446
|
await committed
|
|
437
447
|
replacement.disconnect()
|
|
438
448
|
|
|
439
|
-
|
|
449
|
+
expect((await waitForRestartRecords(secondMarkerPath, 1)).map(({home, marker}) => ({home, marker}))).toEqual([{home: null, marker: "replacement"}])
|
|
440
450
|
} finally {
|
|
441
451
|
replacement.disconnect()
|
|
442
452
|
if (firstOwnerPid) {
|
|
@@ -559,7 +569,7 @@ test("guardian backs off when a restarted owner exits after claiming but before
|
|
|
559
569
|
const records = await waitForRestartRecords(startedLogPath, 2)
|
|
560
570
|
|
|
561
571
|
await waitForProcessExit(descendantPid)
|
|
562
|
-
|
|
572
|
+
expect({value: Boolean(Number(records[1].at) - Number(records[0].at) >= 900), context: `post-claim failure retried after ${Number(records[1].at) - Number(records[0].at)}ms`}).toMatchObject({value: true})
|
|
563
573
|
} finally {
|
|
564
574
|
if (descendantPid) {
|
|
565
575
|
try { process.kill(descendantPid, "SIGKILL") } catch (_error) { /* Exact descendant already exited. */ }
|
|
@@ -590,7 +600,7 @@ test("guardian preserves restart backoff when an unready owner disconnect aborts
|
|
|
590
600
|
await aborted
|
|
591
601
|
const records = await waitForRestartRecords(startedLogPath, 2)
|
|
592
602
|
|
|
593
|
-
|
|
603
|
+
expect({value: Boolean(Number(records[1].at) - Number(records[0].at) >= 900), context: `replacement abort retried after ${Number(records[1].at) - Number(records[0].at)}ms`}).toMatchObject({value: true})
|
|
594
604
|
} finally {
|
|
595
605
|
replacement.disconnect()
|
|
596
606
|
await reconnectAndShutdownGuardian(fixture, authority)
|
|
@@ -629,7 +639,8 @@ test("replacement commit preserves a claimed guardian restart child through list
|
|
|
629
639
|
process.kill(incumbentPid, "SIGUSR2")
|
|
630
640
|
await Promise.race([waitForFileText(committedPath, new RegExp(prepared.replacementId)), published])
|
|
631
641
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
632
|
-
|
|
642
|
+
// The claimed incumbent must survive replacement commit until its listeners retire.
|
|
643
|
+
await expect(() => process.kill(incumbentPid, 0)).not.toThrow()
|
|
633
644
|
|
|
634
645
|
process.kill(incumbentPid, "SIGUSR1")
|
|
635
646
|
await published
|
|
@@ -670,7 +681,7 @@ test("ownerless replacement commit kills a superseded restart candidate", async
|
|
|
670
681
|
await committed
|
|
671
682
|
replacement.disconnect()
|
|
672
683
|
|
|
673
|
-
|
|
684
|
+
expect((await waitForRestartRecords(secondMarkerPath, 1)).map(({home, marker}) => ({home, marker}))).toEqual([{home: null, marker: "committed"}])
|
|
674
685
|
} finally {
|
|
675
686
|
replacement.disconnect()
|
|
676
687
|
if (delayedOwnerPid) {
|
|
@@ -708,9 +719,10 @@ test("guardian logs an asynchronous daemon spawn failure before retrying", async
|
|
|
708
719
|
const diagnosticPattern = /"code":"ENOENT".*"message":"guardian failed to restart daemon"/
|
|
709
720
|
const diagnostic = await waitForFileText(logPath, diagnosticPattern)
|
|
710
721
|
|
|
711
|
-
|
|
722
|
+
expect(diagnostic).toMatch(diagnosticPattern)
|
|
712
723
|
for (const privateValue of [privateArgs, privateEnvironment, privateExecutable, fixture.root, logPath]) {
|
|
713
|
-
|
|
724
|
+
// Guardian diagnostics must not expose private values, including in assertion failures.
|
|
725
|
+
expect(diagnostic.includes(privateValue)).toBe(false)
|
|
714
726
|
}
|
|
715
727
|
} finally {
|
|
716
728
|
await reconnectAndShutdownGuardian(fixture, authority)
|
|
@@ -744,8 +756,8 @@ test("guardian publishes the authenticated ready owner's PID file", async () =>
|
|
|
744
756
|
await fs.symlink(victimPath, pidPath)
|
|
745
757
|
await fixture.client.ownerReady()
|
|
746
758
|
|
|
747
|
-
|
|
748
|
-
|
|
759
|
+
expect(await fs.readFile(pidPath, "utf8")).toBe(`${process.pid}\n`)
|
|
760
|
+
expect(await fs.readFile(victimPath, "utf8")).toBe("unchanged\n")
|
|
749
761
|
} finally {
|
|
750
762
|
await cleanupGuardian(fixture)
|
|
751
763
|
}
|
|
@@ -767,14 +779,15 @@ test("replacement commit notification waits for incumbent listener retirement",
|
|
|
767
779
|
|
|
768
780
|
await fixture.client.commitOwnerReplacement(prepared.replacementId)
|
|
769
781
|
await new Promise((resolve) => setImmediate(resolve))
|
|
770
|
-
|
|
782
|
+
// Candidate publication must remain fenced while incumbent listener retirement is delayed.
|
|
783
|
+
expect(committed).toBe(false)
|
|
771
784
|
const listenersRetired = candidate.waitForEvent("replacement-listeners-retired")
|
|
772
785
|
|
|
773
786
|
await fixture.client.completeOwnerListenerRetirement(prepared.replacementId)
|
|
774
787
|
await listenersRetired
|
|
775
788
|
await fixture.client.request({command: "finalize-owner-replacement", replacementId: prepared.replacementId})
|
|
776
789
|
await notification
|
|
777
|
-
|
|
790
|
+
expect(committed).toBe(true)
|
|
778
791
|
await candidate.shutdown()
|
|
779
792
|
await fixture.client.guardianExit()
|
|
780
793
|
} finally {
|
|
@@ -783,7 +796,7 @@ test("replacement commit notification waits for incumbent listener retirement",
|
|
|
783
796
|
}
|
|
784
797
|
})
|
|
785
798
|
|
|
786
|
-
test("completed direct listener retirement finalizes when the incumbent disconnects", {
|
|
799
|
+
test("completed direct listener retirement finalizes when the incumbent disconnects", {timeoutMs: 3000}, async () => {
|
|
787
800
|
const fixture = await createGuardian()
|
|
788
801
|
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
789
802
|
const authority = {configDigest: "incumbent", runtime: null}
|
|
@@ -802,7 +815,7 @@ test("completed direct listener retirement finalizes when the incumbent disconne
|
|
|
802
815
|
fixture.client.disconnect()
|
|
803
816
|
await committed
|
|
804
817
|
await candidate.finalizeOwnerReplacement(prepared.replacementId)
|
|
805
|
-
|
|
818
|
+
expect(await candidate.replacementStatus()).toEqual({
|
|
806
819
|
committedReplacementId: prepared.replacementId,
|
|
807
820
|
ownerClaimed: true,
|
|
808
821
|
retirementFailed: false,
|
|
@@ -829,14 +842,11 @@ test("replacement staging rejects owner state published after prepare", async ()
|
|
|
829
842
|
const prepared = await candidate.prepareOwnerReplacement(authority, nextAuthority)
|
|
830
843
|
|
|
831
844
|
await fixture.client.publishOwnerState({authority, snapshot: {activeReleaseId: "v2"}})
|
|
832
|
-
await
|
|
833
|
-
() => candidate.stageOwnerReplacement(prepared.replacementId, {authority: nextAuthority, snapshot: {activeReleaseId: "v1"}}),
|
|
834
|
-
/owner state changed after prepare/i
|
|
835
|
-
)
|
|
845
|
+
await expect(candidate.stageOwnerReplacement(prepared.replacementId, {authority: nextAuthority, snapshot: {activeReleaseId: "v1"}})).rejects.toThrow(/owner state changed after prepare/i)
|
|
836
846
|
await candidate.abortOwnerReplacement(prepared.replacementId)
|
|
837
847
|
const fresh = await candidate.prepareOwnerReplacement(authority, nextAuthority)
|
|
838
848
|
|
|
839
|
-
|
|
849
|
+
expect(fresh.ownerState).toEqual({authority, snapshot: {activeReleaseId: "v2"}})
|
|
840
850
|
await candidate.abortOwnerReplacement(fresh.replacementId)
|
|
841
851
|
await fixture.client.shutdown()
|
|
842
852
|
await fixture.client.guardianExit()
|
|
@@ -846,7 +856,7 @@ test("replacement staging rejects owner state published after prepare", async ()
|
|
|
846
856
|
}
|
|
847
857
|
})
|
|
848
858
|
|
|
849
|
-
test("staged replacement receives cleared local sources when the incumbent disconnects", {
|
|
859
|
+
test("staged replacement receives cleared local sources when the incumbent disconnects", {timeoutMs: 3000}, async () => {
|
|
850
860
|
const fixture = await createGuardian()
|
|
851
861
|
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
852
862
|
const authority = {configDigest: "incumbent", runtime: null}
|
|
@@ -873,7 +883,7 @@ test("staged replacement receives cleared local sources when the incumbent disco
|
|
|
873
883
|
const committed = candidate.waitForEvent("replacement-committed")
|
|
874
884
|
|
|
875
885
|
fixture.client.disconnect()
|
|
876
|
-
|
|
886
|
+
expect(await cleared).toEqual({
|
|
877
887
|
connections: {http: 0, websocket: 0},
|
|
878
888
|
event: "owner-connection-state",
|
|
879
889
|
releaseId: "v1",
|
|
@@ -882,7 +892,7 @@ test("staged replacement receives cleared local sources when the incumbent disco
|
|
|
882
892
|
await committed
|
|
883
893
|
const state = /** @type {{listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>}} */ (await candidate.ownerState())
|
|
884
894
|
|
|
885
|
-
|
|
895
|
+
expect(state.listenerConnectionSources).toEqual({})
|
|
886
896
|
await candidate.shutdown()
|
|
887
897
|
await fixture.client.guardianExit()
|
|
888
898
|
} finally {
|
|
@@ -891,7 +901,7 @@ test("staged replacement receives cleared local sources when the incumbent disco
|
|
|
891
901
|
}
|
|
892
902
|
})
|
|
893
903
|
|
|
894
|
-
test("staged successor state receives tombstones when an older completed listener disconnects", {
|
|
904
|
+
test("staged successor state receives tombstones when an older completed listener disconnects", {timeoutMs: 3000}, async () => {
|
|
895
905
|
const fixture = await createGuardian()
|
|
896
906
|
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
897
907
|
const successor = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
@@ -933,12 +943,12 @@ test("staged successor state receives tombstones when an older completed listene
|
|
|
933
943
|
fixture.client.disconnect()
|
|
934
944
|
const tombstone = {connections: {http: 0, websocket: 0}, event: "owner-connection-state", releaseId: "v1", sourceId: "retired-local"}
|
|
935
945
|
|
|
936
|
-
|
|
946
|
+
expect(await Promise.all([sourceCleared, stagedSourceCleared])).toEqual([tombstone, tombstone])
|
|
937
947
|
|
|
938
948
|
await candidate.commitOwnerReplacement(second.replacementId)
|
|
939
949
|
const successorState = /** @type {{listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>}} */ (await successor.ownerState())
|
|
940
950
|
|
|
941
|
-
|
|
951
|
+
expect(successorState.listenerConnectionSources).toEqual({})
|
|
942
952
|
const secondCommitted = successor.waitForEvent("replacement-committed")
|
|
943
953
|
const secondListenersRetired = successor.waitForEvent("replacement-listeners-retired")
|
|
944
954
|
|
|
@@ -969,7 +979,7 @@ test("replacement abort notifies both the candidate and committed owner", async
|
|
|
969
979
|
const candidateAborted = candidate.waitForEvent("replacement-aborted")
|
|
970
980
|
|
|
971
981
|
await candidate.abortOwnerReplacement(prepared.replacementId)
|
|
972
|
-
|
|
982
|
+
expect(await Promise.all([incumbentAborted, candidateAborted])).toEqual([
|
|
973
983
|
{event: "replacement-aborted", reason: "Replacement candidate aborted the prepared transaction"},
|
|
974
984
|
{event: "replacement-aborted", reason: "Replacement candidate aborted the prepared transaction"}
|
|
975
985
|
])
|
|
@@ -988,7 +998,7 @@ test("retired owner replacement commit carries its exact recovered process key",
|
|
|
988
998
|
|
|
989
999
|
client.request = async (request) => {
|
|
990
1000
|
if (!request.key) throw new Error(`Guardian ${request.command} requires a process key`)
|
|
991
|
-
|
|
1001
|
+
expect(request).toEqual({command: "commit-retired-owner-replacement", key: processKey, replacementId})
|
|
992
1002
|
return {committed: true}
|
|
993
1003
|
}
|
|
994
1004
|
|
|
@@ -999,7 +1009,7 @@ test("guardian owner-replacement capability classification is explicit and fail
|
|
|
999
1009
|
const current = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
|
|
1000
1010
|
|
|
1001
1011
|
current.request = async (request) => {
|
|
1002
|
-
|
|
1012
|
+
expect(request).toEqual({command: "owner-replacement-capabilities"})
|
|
1003
1013
|
return {
|
|
1004
1014
|
commands: ["commit-retired-owner-replacement", "future-command"],
|
|
1005
1015
|
futureField: {supported: true},
|
|
@@ -1007,7 +1017,7 @@ test("guardian owner-replacement capability classification is explicit and fail
|
|
|
1007
1017
|
version: 2
|
|
1008
1018
|
}
|
|
1009
1019
|
}
|
|
1010
|
-
|
|
1020
|
+
expect(await current.ownerReplacementProtocol()).toBe("atomic")
|
|
1011
1021
|
|
|
1012
1022
|
for (const [commitDiagnostic, expected] of [
|
|
1013
1023
|
["Owner replacement transaction is not the prepared candidate", "atomic"],
|
|
@@ -1022,8 +1032,8 @@ test("guardian owner-replacement capability classification is explicit and fail
|
|
|
1022
1032
|
if (request.command === "owner-replacement-capabilities") throw new Error("Guardian owner-replacement-capabilities requires a process key")
|
|
1023
1033
|
throw new Error(commitDiagnostic)
|
|
1024
1034
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1035
|
+
expect(await older.ownerReplacementProtocol()).toBe(expected)
|
|
1036
|
+
expect(requests).toEqual([
|
|
1027
1037
|
{command: "owner-replacement-capabilities"},
|
|
1028
1038
|
{command: "commit-retired-owner-replacement", replacementId: "owner-replacement-capability-probe"}
|
|
1029
1039
|
])
|
|
@@ -1039,7 +1049,7 @@ test("guardian owner-replacement capability classification is explicit and fail
|
|
|
1039
1049
|
const ambiguous = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
|
|
1040
1050
|
|
|
1041
1051
|
ambiguous.request = fixture
|
|
1042
|
-
await
|
|
1052
|
+
await expect(ambiguous.ownerReplacementProtocol()).rejects.toThrow(/invalid owner-replacement capability response|ambiguous retired-owner capability response/)
|
|
1043
1053
|
}
|
|
1044
1054
|
})
|
|
1045
1055
|
|
|
@@ -1052,14 +1062,11 @@ test("reserved process recovery rejects a reconstructed definition with differen
|
|
|
1052
1062
|
await fixture.client.process(processKey, definition("worker")).recover()
|
|
1053
1063
|
const [registration] = await fixture.client.inventory()
|
|
1054
1064
|
|
|
1055
|
-
|
|
1065
|
+
if (!registration) throw new Error("Missing required fixture: registration")
|
|
1056
1066
|
await candidate.connect()
|
|
1057
1067
|
candidate.reserveProcessRecovery(processKey, registration.provenance)
|
|
1058
|
-
await
|
|
1059
|
-
|
|
1060
|
-
/provenance mismatch for reserved process/
|
|
1061
|
-
)
|
|
1062
|
-
assert.deepEqual((await fixture.client.inventory()).map(({key}) => key), [processKey])
|
|
1068
|
+
await expect(candidate.process(processKey, definition("different-worker")).recover()).rejects.toThrow(/provenance mismatch for reserved process/)
|
|
1069
|
+
expect((await fixture.client.inventory()).map(({key}) => key)).toEqual([processKey])
|
|
1063
1070
|
} finally {
|
|
1064
1071
|
candidate.disconnect()
|
|
1065
1072
|
await cleanupGuardian(fixture)
|
|
@@ -1092,10 +1099,7 @@ test("retired owner replacement rejects a registered process absent from committ
|
|
|
1092
1099
|
const prepared = await candidate.prepareOwnerReplacement(authority, authority)
|
|
1093
1100
|
|
|
1094
1101
|
await candidate.stageOwnerReplacement(prepared.replacementId, {authority, snapshot: candidateSnapshot})
|
|
1095
|
-
await
|
|
1096
|
-
() => candidate.commitRetiredOwnerReplacement(prepared.replacementId, candidateProcessKey),
|
|
1097
|
-
/process .* does not belong to the committed owner/
|
|
1098
|
-
)
|
|
1102
|
+
await expect(candidate.commitRetiredOwnerReplacement(prepared.replacementId, candidateProcessKey)).rejects.toThrow(/process .* does not belong to the committed owner/)
|
|
1099
1103
|
} finally {
|
|
1100
1104
|
candidate.disconnect()
|
|
1101
1105
|
await cleanupGuardian(fixture)
|
|
@@ -1125,14 +1129,14 @@ test("retired owner replacement requires unchanged authority and the exact contr
|
|
|
1125
1129
|
const changed = await candidate.prepareOwnerReplacement(authority, nextAuthority)
|
|
1126
1130
|
|
|
1127
1131
|
await candidate.stageOwnerReplacement(changed.replacementId, {authority: nextAuthority, snapshot})
|
|
1128
|
-
await
|
|
1132
|
+
await expect(candidate.commitRetiredOwnerReplacement(changed.replacementId, processKey)).rejects.toThrow(/unchanged owner authority/)
|
|
1129
1133
|
await candidate.abortOwnerReplacement(changed.replacementId)
|
|
1130
1134
|
|
|
1131
1135
|
const occupied = await candidate.prepareOwnerReplacement(authority, authority)
|
|
1132
1136
|
|
|
1133
1137
|
await candidate.stageOwnerReplacement(occupied.replacementId, {authority, snapshot})
|
|
1134
1138
|
await fs.writeFile(controlPath, "occupied\n")
|
|
1135
|
-
await
|
|
1139
|
+
await expect(candidate.commitRetiredOwnerReplacement(occupied.replacementId, processKey)).rejects.toThrow(/control socket .* still exists/)
|
|
1136
1140
|
await candidate.abortOwnerReplacement(occupied.replacementId)
|
|
1137
1141
|
|
|
1138
1142
|
await fs.rm(controlPath)
|
|
@@ -1140,34 +1144,22 @@ test("retired owner replacement requires unchanged authority and the exact contr
|
|
|
1140
1144
|
|
|
1141
1145
|
await candidate.stageOwnerReplacement(ready.replacementId, {authority, snapshot})
|
|
1142
1146
|
await contender.connect()
|
|
1143
|
-
await
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
)
|
|
1147
|
-
await assert.rejects(
|
|
1148
|
-
() => candidate.commitRetiredOwnerReplacement("stale-replacement", processKey),
|
|
1149
|
-
/not the prepared candidate/
|
|
1150
|
-
)
|
|
1151
|
-
await assert.rejects(
|
|
1152
|
-
() => candidate.commitRetiredOwnerReplacement(ready.replacementId, "release:v1:wrong"),
|
|
1153
|
-
/process .* is not registered/
|
|
1154
|
-
)
|
|
1147
|
+
await expect(contender.request({command: "commit-retired-owner-replacement", key: processKey, replacementId: ready.replacementId})).rejects.toThrow(/not the prepared candidate/)
|
|
1148
|
+
await expect(candidate.commitRetiredOwnerReplacement("stale-replacement", processKey)).rejects.toThrow(/not the prepared candidate/)
|
|
1149
|
+
await expect(candidate.commitRetiredOwnerReplacement(ready.replacementId, "release:v1:wrong")).rejects.toThrow(/process .* is not registered/)
|
|
1155
1150
|
const handoffRequested = fixture.client.waitForEvent("replacement-listener-handoff-requested")
|
|
1156
1151
|
|
|
1157
1152
|
void handoffRequested.catch(() => undefined)
|
|
1158
1153
|
|
|
1159
1154
|
await candidate.prepareRetiredOwnerListenerHandoff(ready.replacementId, processKey)
|
|
1160
1155
|
await handoffRequested
|
|
1161
|
-
await
|
|
1162
|
-
() => contender.prepareOwnerReplacement(authority, authority),
|
|
1163
|
-
/listener retirement is pending/
|
|
1164
|
-
)
|
|
1156
|
+
await expect(contender.prepareOwnerReplacement(authority, authority)).rejects.toThrow(/listener retirement is pending/)
|
|
1165
1157
|
const committed = candidate.waitForEvent("replacement-committed")
|
|
1166
1158
|
const listenersRetired = candidate.waitForEvent("replacement-listeners-retired")
|
|
1167
1159
|
const connectionState = candidate.waitForEvent("owner-connection-state")
|
|
1168
1160
|
|
|
1169
1161
|
await fixture.client.publishOwnerConnectionState(ready.replacementId, "listener-a", "v1", {http: 1, websocket: 2}, true)
|
|
1170
|
-
|
|
1162
|
+
expect(await connectionState).toEqual({connections: {http: 1, websocket: 2}, event: "owner-connection-state", releaseId: "v1", sourceId: "listener-a"})
|
|
1171
1163
|
await fixture.client.completeOwnerListenerRetirement(ready.replacementId)
|
|
1172
1164
|
await listenersRetired
|
|
1173
1165
|
const retirementRequested = fixture.client.waitForEvent("replacement-retirement-requested")
|
|
@@ -1221,7 +1213,7 @@ test("completed listener retirement survives owner recovery and clears a crashed
|
|
|
1221
1213
|
const initial = candidate.waitForEvent("owner-connection-state")
|
|
1222
1214
|
|
|
1223
1215
|
await fixture.client.publishOwnerConnectionState(prepared.replacementId, "listener-a", "v1", {http: 0, websocket: 1}, true)
|
|
1224
|
-
|
|
1216
|
+
expect(await initial).toEqual({connections: {http: 0, websocket: 1}, event: "owner-connection-state", releaseId: "v1", sourceId: "listener-a"})
|
|
1225
1217
|
const listenersRetired = candidate.waitForEvent("replacement-listeners-retired")
|
|
1226
1218
|
|
|
1227
1219
|
await fixture.client.completeOwnerListenerRetirement(prepared.replacementId)
|
|
@@ -1232,9 +1224,9 @@ test("completed listener retirement survives owner recovery and clears a crashed
|
|
|
1232
1224
|
await recovered.claimOwner(1000, authority)
|
|
1233
1225
|
const stateAfterCandidateCrash = /** @type {{listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>}} */ (await recovered.ownerState())
|
|
1234
1226
|
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1227
|
+
expect(stateAfterCandidateCrash.listenerConnectionSources?.["candidate-local"]).toBe(undefined)
|
|
1228
|
+
expect(stateAfterCandidateCrash.listenerConnectionSources?.["listener-a"]).toEqual({v1: {http: 0, websocket: 1}})
|
|
1229
|
+
expect(await recovered.replacementStatus()).toEqual({
|
|
1238
1230
|
committedReplacementId: prepared.replacementId,
|
|
1239
1231
|
ownerClaimed: true,
|
|
1240
1232
|
retirementFailed: false,
|
|
@@ -1245,17 +1237,17 @@ test("completed listener retirement survives owner recovery and clears a crashed
|
|
|
1245
1237
|
const cleared = recovered.waitForEvent("owner-connection-state")
|
|
1246
1238
|
|
|
1247
1239
|
fixture.client.disconnect()
|
|
1248
|
-
|
|
1240
|
+
expect(await Promise.race([
|
|
1249
1241
|
cleared,
|
|
1250
1242
|
new Promise((_, reject) => {
|
|
1251
1243
|
const timer = setTimeout(() => reject(new Error("Recovered owner did not receive the retired source tombstone")), 500)
|
|
1252
1244
|
|
|
1253
1245
|
timer.unref()
|
|
1254
1246
|
})
|
|
1255
|
-
])
|
|
1247
|
+
])).toEqual({connections: {http: 0, websocket: 0}, event: "owner-connection-state", releaseId: "v1", sourceId: "listener-a"})
|
|
1256
1248
|
const recoveredOwnerState = /** @type {{listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>}} */ (await recovered.ownerState())
|
|
1257
1249
|
|
|
1258
|
-
|
|
1250
|
+
expect(recoveredOwnerState.listenerConnectionSources).toEqual({})
|
|
1259
1251
|
await recovered.publishOwnerState({
|
|
1260
1252
|
authority,
|
|
1261
1253
|
listenerConnectionSources: {"listener-a": {v1: {http: 0, websocket: 1}}},
|
|
@@ -1264,7 +1256,7 @@ test("completed listener retirement survives owner recovery and clears a crashed
|
|
|
1264
1256
|
})
|
|
1265
1257
|
const afterStalePublication = /** @type {{listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>}} */ (await recovered.ownerState())
|
|
1266
1258
|
|
|
1267
|
-
|
|
1259
|
+
expect(afterStalePublication.listenerConnectionSources).toEqual({})
|
|
1268
1260
|
const recoveredProcess = recovered.process(processKey, definition("worker"))
|
|
1269
1261
|
|
|
1270
1262
|
await recoveredProcess.recover()
|
|
@@ -1276,7 +1268,7 @@ test("completed listener retirement survives owner recovery and clears a crashed
|
|
|
1276
1268
|
})
|
|
1277
1269
|
const afterStaleProcessUpdate = /** @type {{listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>}} */ (await recovered.ownerState())
|
|
1278
1270
|
|
|
1279
|
-
|
|
1271
|
+
expect(afterStaleProcessUpdate.listenerConnectionSources).toEqual({})
|
|
1280
1272
|
await recovered.shutdown()
|
|
1281
1273
|
await fixture.client.guardianExit()
|
|
1282
1274
|
} finally {
|
|
@@ -1303,7 +1295,7 @@ test("direct retired-listener source relay survives committed owner recovery", a
|
|
|
1303
1295
|
const initial = candidate.waitForEvent("owner-connection-state")
|
|
1304
1296
|
|
|
1305
1297
|
await fixture.client.publishOwnerConnectionState(prepared.replacementId, "direct-listener", "v1", {http: 0, websocket: 1}, true)
|
|
1306
|
-
|
|
1298
|
+
expect(await initial).toEqual({connections: {http: 0, websocket: 1}, event: "owner-connection-state", releaseId: "v1", sourceId: "direct-listener"})
|
|
1307
1299
|
const listenersRetired = candidate.waitForEvent("replacement-listeners-retired")
|
|
1308
1300
|
|
|
1309
1301
|
await fixture.client.completeOwnerListenerRetirement(prepared.replacementId)
|
|
@@ -1315,7 +1307,7 @@ test("direct retired-listener source relay survives committed owner recovery", a
|
|
|
1315
1307
|
const cleared = recovered.waitForEvent("owner-connection-state")
|
|
1316
1308
|
|
|
1317
1309
|
await fixture.client.publishOwnerConnectionState(prepared.replacementId, "direct-listener", "v1", {http: 0, websocket: 0}, true)
|
|
1318
|
-
|
|
1310
|
+
expect(await cleared).toEqual({connections: {http: 0, websocket: 0}, event: "owner-connection-state", releaseId: "v1", sourceId: "direct-listener"})
|
|
1319
1311
|
await recovered.shutdown()
|
|
1320
1312
|
await fixture.client.guardianExit()
|
|
1321
1313
|
} finally {
|
|
@@ -1365,17 +1357,17 @@ test("incumbent listener disconnect before state completion aborts without commi
|
|
|
1365
1357
|
await fixture.client.publishOwnerConnectionState(prepared.replacementId, "incumbent-local", "v1", {http: 0, websocket: 1}, true)
|
|
1366
1358
|
await sourcePublished
|
|
1367
1359
|
fixture.client.disconnect()
|
|
1368
|
-
|
|
1360
|
+
expect(await failed).toEqual({
|
|
1369
1361
|
event: "replacement-retirement-failed",
|
|
1370
1362
|
reason: "Incumbent listener disconnected during the prepared handoff",
|
|
1371
1363
|
replacementId: prepared.replacementId
|
|
1372
1364
|
})
|
|
1373
|
-
|
|
1365
|
+
expect(await aborted).toEqual({
|
|
1374
1366
|
event: "replacement-aborted",
|
|
1375
1367
|
reason: "Incumbent listener disconnected during the prepared handoff"
|
|
1376
1368
|
})
|
|
1377
|
-
|
|
1378
|
-
|
|
1369
|
+
expect(tombstones).toBe(1)
|
|
1370
|
+
expect(await candidate.replacementStatus()).toEqual({
|
|
1379
1371
|
committedReplacementId: null,
|
|
1380
1372
|
ownerClaimed: false,
|
|
1381
1373
|
retirementFailed: false,
|
|
@@ -1417,7 +1409,7 @@ test("replacement abort during listener handoff validation leaves the incumbent
|
|
|
1417
1409
|
const abandonedHandoff = candidate.prepareRetiredOwnerListenerHandoff(prepared.replacementId, processKey)
|
|
1418
1410
|
|
|
1419
1411
|
await candidate.abortOwnerReplacement(prepared.replacementId)
|
|
1420
|
-
await
|
|
1412
|
+
await expect(abandonedHandoff).rejects.toThrow(/prepared candidate/)
|
|
1421
1413
|
await contender.connect()
|
|
1422
1414
|
const fresh = await contender.prepareOwnerReplacement(authority, authority)
|
|
1423
1415
|
|
|
@@ -1467,7 +1459,7 @@ test("queued owner claim is revalidated against the latest committed authority",
|
|
|
1467
1459
|
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1468
1460
|
await fixture.client.publishOwnerState({authority: nextAuthority, snapshot: {activeReleaseId: null}})
|
|
1469
1461
|
fixture.client.disconnect()
|
|
1470
|
-
await
|
|
1462
|
+
await expect(claim).rejects.toThrow(/authority changed while the claim was queued/)
|
|
1471
1463
|
await contender.claimOwner(500, nextAuthority)
|
|
1472
1464
|
await contender.shutdown()
|
|
1473
1465
|
await fixture.client.guardianExit()
|
|
@@ -1486,7 +1478,7 @@ test("first upgrade migrates a real pre-split guardian without replacing its own
|
|
|
1486
1478
|
|
|
1487
1479
|
try {
|
|
1488
1480
|
await legacyProcess.start()
|
|
1489
|
-
await
|
|
1481
|
+
await expect(fixture.client.capabilities()).rejects.toThrow(/Guardian capabilities requires a process key/)
|
|
1490
1482
|
legacyPid = legacyProcess.status().pid
|
|
1491
1483
|
const authority = {configDigest: "legacy-config", runtime: {digest: "legacy-runtime", format: 1, path: "/legacy", version: "0.1.28"}}
|
|
1492
1484
|
const nextAuthority = {...authority, runtime: {...authority.runtime, digest: "candidate-runtime", path: "/candidate"}}
|
|
@@ -1500,7 +1492,7 @@ test("first upgrade migrates a real pre-split guardian without replacing its own
|
|
|
1500
1492
|
}
|
|
1501
1493
|
}
|
|
1502
1494
|
|
|
1503
|
-
|
|
1495
|
+
if (!legacyPid) throw new Error("Missing required fixture: legacyPid")
|
|
1504
1496
|
upgraded = await fixture.client.upgradeLegacyGuardian({
|
|
1505
1497
|
ownerState,
|
|
1506
1498
|
socketPath: path.join(fixture.root, "guardian-v2.sock"),
|
|
@@ -1511,19 +1503,19 @@ test("first upgrade migrates a real pre-split guardian without replacing its own
|
|
|
1511
1503
|
const restored = upgraded.process("release:v1:legacy-worker", processDefinition)
|
|
1512
1504
|
|
|
1513
1505
|
await restored.recover()
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1506
|
+
expect(restored.status().pid).toBe(legacyPid)
|
|
1507
|
+
expect(prepared.ownerState).toEqual(ownerState)
|
|
1508
|
+
expect(await upgraded.stageOwnerReplacement(prepared.replacementId, {authority: nextAuthority, snapshot: ownerState.snapshot})).toEqual({committed: true})
|
|
1517
1509
|
await committed
|
|
1518
|
-
|
|
1510
|
+
expect(restored.status().pid).toBe(legacyPid)
|
|
1519
1511
|
|
|
1520
1512
|
const currentProcess = upgraded.process("release:v2:current-worker", {
|
|
1521
1513
|
...definition("current-worker"),
|
|
1522
1514
|
lifecycle: {activateCommand: "sleep 0.05", activateTimeoutMs: 10, drainTimeoutMs: 0}
|
|
1523
1515
|
})
|
|
1524
1516
|
await currentProcess.start()
|
|
1525
|
-
await
|
|
1526
|
-
|
|
1517
|
+
await expect(currentProcess.activateStrict()).rejects.toThrow(/activate command timed out after 10ms/i)
|
|
1518
|
+
expect(restored.status().pid).toBe(legacyPid)
|
|
1527
1519
|
await upgraded.shutdown()
|
|
1528
1520
|
await upgraded.guardianExit()
|
|
1529
1521
|
} finally {
|
|
@@ -1562,11 +1554,8 @@ test("split guardian rejects an owner-state update when its nested legacy defini
|
|
|
1562
1554
|
|
|
1563
1555
|
await restored.recover()
|
|
1564
1556
|
await legacyProcess.updateDefinition({...processDefinition, env: {REVISION: "external"}})
|
|
1565
|
-
await
|
|
1566
|
-
|
|
1567
|
-
/provenance mismatch/
|
|
1568
|
-
)
|
|
1569
|
-
assert.deepEqual(await upgraded.ownerState(), committedOwnerState)
|
|
1557
|
+
await expect(restored.updateDefinition({...processDefinition, env: {REVISION: "candidate"}}, {authority: nextAuthority, snapshot: {...ownerState.snapshot, serviceReleaseIds: {service: "v2"}}})).rejects.toThrow(/provenance mismatch/)
|
|
1558
|
+
expect(await upgraded.ownerState()).toEqual(committedOwnerState)
|
|
1570
1559
|
} finally {
|
|
1571
1560
|
await legacyProcess.stop().catch(() => {})
|
|
1572
1561
|
upgraded?.disconnect()
|
|
@@ -1619,14 +1608,15 @@ test("split guardian defers owner handoff until a nested legacy definition updat
|
|
|
1619
1608
|
void claim.finally(() => { claimSettled = true })
|
|
1620
1609
|
upgraded.disconnect()
|
|
1621
1610
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
1622
|
-
|
|
1611
|
+
// Owner handoff must wait for the nested definition update.
|
|
1612
|
+
expect(claimSettled).toBe(false)
|
|
1623
1613
|
|
|
1624
1614
|
await fs.writeFile(gatePath, "allow\n")
|
|
1625
1615
|
await claim
|
|
1626
1616
|
const updateError = await updateResult
|
|
1627
1617
|
|
|
1628
|
-
|
|
1629
|
-
|
|
1618
|
+
expect(String(updateError)).toMatch(/connection closed while awaiting update/)
|
|
1619
|
+
expect(await contender.ownerState()).toEqual({...committedOwnerState, listenerConnectionSources: {}})
|
|
1630
1620
|
} finally {
|
|
1631
1621
|
await fs.writeFile(gatePath, "allow\n").catch(() => {})
|
|
1632
1622
|
await contender.shutdown().catch(() => {})
|
|
@@ -1665,7 +1655,8 @@ test("a disconnected legacy upgrade candidate does not strand its bridge guardia
|
|
|
1665
1655
|
new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error("Legacy upgrade bridge guardian did not exit after candidate disconnect")), 1000) })
|
|
1666
1656
|
]).finally(() => { if (timeout) clearTimeout(timeout) })
|
|
1667
1657
|
bridgeExited = true
|
|
1668
|
-
|
|
1658
|
+
// The pre-split guardian must remain available after bridge abandonment.
|
|
1659
|
+
expect(fixture.child.exitCode).toBe(null)
|
|
1669
1660
|
} finally {
|
|
1670
1661
|
upgraded?.disconnect()
|
|
1671
1662
|
if (!bridgeExited && upgraded?.pid) killExactProcessGroup(upgraded.pid)
|
|
@@ -1700,7 +1691,7 @@ test("a legacy bridge remains discoverable when its candidate disconnects after
|
|
|
1700
1691
|
await upgraded.beginLegacyOwnerClaim(prepared.replacementId, 1000, statePath, recoverySnapshot)
|
|
1701
1692
|
fixture.client.disconnect()
|
|
1702
1693
|
await upgraded.completeLegacyOwnerClaim(prepared.replacementId)
|
|
1703
|
-
|
|
1694
|
+
expect(JSON.parse(await fs.readFile(statePath, "utf8"))).toEqual(recoverySnapshot)
|
|
1704
1695
|
upgraded.disconnect()
|
|
1705
1696
|
|
|
1706
1697
|
replacement = new GuardianClient({...identity, pid: upgraded.pid})
|
|
@@ -1708,7 +1699,7 @@ test("a legacy bridge remains discoverable when its candidate disconnects after
|
|
|
1708
1699
|
const resumed = await replacement.prepareOwnerReplacement(authority, nextAuthority)
|
|
1709
1700
|
const committed = replacement.waitForEvent("replacement-committed")
|
|
1710
1701
|
|
|
1711
|
-
|
|
1702
|
+
expect(await replacement.stageOwnerReplacement(resumed.replacementId, {authority: nextAuthority, config: {statePath}, snapshot: ownerState.snapshot})).toEqual({committed: true})
|
|
1712
1703
|
await committed
|
|
1713
1704
|
await replacement.shutdown()
|
|
1714
1705
|
await upgraded.guardianExit()
|
|
@@ -1932,3 +1923,4 @@ function killExactProcessGroup(pid) {
|
|
|
1932
1923
|
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ESRCH") throw error
|
|
1933
1924
|
}
|
|
1934
1925
|
}
|
|
1926
|
+
})
|