rollbridge 0.1.49 → 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/README.md +5 -0
- package/changelog.d/20260909120000-velocious-testing.md +1 -0
- package/docs/cli.md +7 -1
- package/docs/generation-deployment-contract.md +9 -0
- package/eslint.config.js +8 -0
- package/package.json +3 -2
- package/src/cli.js +10 -2
- package/src/daemon.js +102 -12
- package/src/process-guardian.js +5 -1
- package/src/release-group.js +48 -1
- 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 +75 -14
- 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 +160 -145
- package/test/health.test.js +6 -4
- package/test/logs.test.js +23 -17
- package/test/managed-process.test.js +96 -91
- package/test/owner-recovery.test.js +254 -239
- package/test/owner-replacement.test.js +228 -223
- 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 +388 -395
- 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,22 @@ 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")
|
|
73
|
+
} finally {
|
|
74
|
+
await cleanupGuardian(fixture)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("guardian preserves a custom activation timeout in the registered process definition", async () => {
|
|
79
|
+
const fixture = await createGuardian()
|
|
80
|
+
const processInstance = fixture.client.process("candidate-activation-timeout", {
|
|
81
|
+
...definition("candidate-activation-timeout"),
|
|
82
|
+
lifecycle: {activateCommand: "sleep 0.05", activateTimeoutMs: 10, drainTimeoutMs: 0}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await processInstance.start()
|
|
87
|
+
await expect(processInstance.activateStrict()).rejects.toThrow(/activate command timed out after 10ms/i)
|
|
68
88
|
} finally {
|
|
69
89
|
await cleanupGuardian(fixture)
|
|
70
90
|
}
|
|
@@ -97,20 +117,20 @@ test("client reactivates a retained process through a guardian without the react
|
|
|
97
117
|
await processInstance.quiesceStrict()
|
|
98
118
|
await processInstance.reactivateStrict()
|
|
99
119
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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")
|
|
104
124
|
|
|
105
125
|
const restarted = once(processInstance, "started")
|
|
106
126
|
|
|
107
|
-
|
|
127
|
+
if (!pid) throw new Error("Missing required fixture: pid")
|
|
108
128
|
process.kill(-pid, "SIGKILL")
|
|
109
129
|
await restarted
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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")
|
|
114
134
|
} finally {
|
|
115
135
|
await cleanupGuardian(fixture)
|
|
116
136
|
}
|
|
@@ -142,10 +162,10 @@ test("client reverses a worker quiet hook through a pre-reactivation guardian",
|
|
|
142
162
|
await processInstance.quiesceStrict()
|
|
143
163
|
await processInstance.reactivateStrict()
|
|
144
164
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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")
|
|
149
169
|
} finally {
|
|
150
170
|
await cleanupGuardian(fixture)
|
|
151
171
|
}
|
|
@@ -163,8 +183,8 @@ test("guardian atomically updates process provenance with private owner state",
|
|
|
163
183
|
const previousProvenance = (await fixture.client.inventory())[0]?.provenance
|
|
164
184
|
|
|
165
185
|
await processInstance.updateDefinition({...definition("service"), env: {RELEASE: "v2"}}, nextOwnerState)
|
|
166
|
-
|
|
167
|
-
|
|
186
|
+
expect(await fixture.client.ownerState()).toEqual(nextOwnerState)
|
|
187
|
+
expect((await fixture.client.inventory())[0]?.provenance).not.toBe(previousProvenance)
|
|
168
188
|
} finally {
|
|
169
189
|
await cleanupGuardian(fixture)
|
|
170
190
|
}
|
|
@@ -186,9 +206,10 @@ test("guardian forwards each retained output line to its exact process proxy", a
|
|
|
186
206
|
const [entry] = await Promise.race([logged, exitedFirst])
|
|
187
207
|
const event = await forwarded
|
|
188
208
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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()
|
|
192
213
|
} finally {
|
|
193
214
|
await cleanupGuardian(fixture)
|
|
194
215
|
}
|
|
@@ -204,12 +225,12 @@ test("guardian delivers the final process status after dropping logs for a backp
|
|
|
204
225
|
})
|
|
205
226
|
const socket = fixture.client.socket
|
|
206
227
|
|
|
207
|
-
|
|
228
|
+
if (!socket) throw new Error("Missing required fixture: socket")
|
|
208
229
|
try {
|
|
209
230
|
await processInstance.start()
|
|
210
231
|
const pid = processInstance.status().pid
|
|
211
232
|
|
|
212
|
-
|
|
233
|
+
if (!pid) throw new Error("Missing required fixture: pid")
|
|
213
234
|
const finalStatus = fixture.client.waitForEvent("process")
|
|
214
235
|
|
|
215
236
|
socket.pause()
|
|
@@ -227,7 +248,7 @@ test("guardian delivers the final process status after dropping logs for a backp
|
|
|
227
248
|
} finally {
|
|
228
249
|
clearTimeout(timeout)
|
|
229
250
|
}
|
|
230
|
-
|
|
251
|
+
expect(processInstance.status().state).toBe("failed")
|
|
231
252
|
} finally {
|
|
232
253
|
socket.resume()
|
|
233
254
|
await cleanupGuardian(fixture)
|
|
@@ -247,7 +268,7 @@ test("guardian resynchronizes retained logs after dropping output for a backpres
|
|
|
247
268
|
})
|
|
248
269
|
const socket = fixture.client.socket
|
|
249
270
|
|
|
250
|
-
|
|
271
|
+
if (!socket) throw new Error("Missing required fixture: socket")
|
|
251
272
|
try {
|
|
252
273
|
await processInstance.start()
|
|
253
274
|
socket.pause()
|
|
@@ -267,7 +288,7 @@ test("guardian resynchronizes retained logs after dropping output for a backpres
|
|
|
267
288
|
} finally {
|
|
268
289
|
clearTimeout(timeout)
|
|
269
290
|
}
|
|
270
|
-
|
|
291
|
+
expect(processInstance.status().logs.at(-1)?.line).toBe(finalLine)
|
|
271
292
|
} finally {
|
|
272
293
|
socket.resume()
|
|
273
294
|
await cleanupGuardian(fixture)
|
|
@@ -283,13 +304,14 @@ test("guardian shutdown reports an exact owned process stop failure", async () =
|
|
|
283
304
|
await processInstance.start()
|
|
284
305
|
const [entry] = await fixture.client.inventory()
|
|
285
306
|
|
|
286
|
-
|
|
307
|
+
if (!entry) throw new Error("Missing required fixture: entry")
|
|
287
308
|
fixture.client.disconnect()
|
|
288
309
|
await replacement.connect()
|
|
289
310
|
await replacement.claimOwner(0, null)
|
|
290
|
-
await
|
|
291
|
-
|
|
292
|
-
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/)
|
|
293
315
|
} finally {
|
|
294
316
|
const inventory = await replacement.inventory().catch(() => [])
|
|
295
317
|
const entry = inventory[0]
|
|
@@ -312,8 +334,8 @@ test("successful guardian shutdown closes an authenticated waiting contender bef
|
|
|
312
334
|
|
|
313
335
|
try {
|
|
314
336
|
await contender.connect()
|
|
315
|
-
|
|
316
|
-
|
|
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")
|
|
317
339
|
const shutdownOrder = /** @type {string[]} */ ([])
|
|
318
340
|
const onData = fixture.client.onData.bind(fixture.client)
|
|
319
341
|
|
|
@@ -324,14 +346,17 @@ test("successful guardian shutdown closes an authenticated waiting contender bef
|
|
|
324
346
|
fixture.client.socket.once("close", () => shutdownOrder.push("caller-close"))
|
|
325
347
|
const contenderClosed = new Promise((resolve) => contender.socket?.once("close", () => resolve(undefined)))
|
|
326
348
|
const waitingClaim = contender.claimOwner(250, null)
|
|
327
|
-
const rejectedClaim =
|
|
349
|
+
const rejectedClaim = (async () => {
|
|
350
|
+
await expect(waitingClaim).rejects.toThrow(/connection closed/)
|
|
351
|
+
})()
|
|
328
352
|
|
|
329
353
|
await fixture.client.shutdown()
|
|
330
354
|
await rejectedClaim
|
|
331
355
|
await contenderClosed
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
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"})
|
|
335
360
|
await fixture.client.guardianExit()
|
|
336
361
|
} finally {
|
|
337
362
|
contender.disconnect()
|
|
@@ -355,7 +380,7 @@ test("guardian restart uses the latest accepted command and exact environment",
|
|
|
355
380
|
|
|
356
381
|
const [restart] = await waitForRestartRecords(markerPath, 1)
|
|
357
382
|
|
|
358
|
-
|
|
383
|
+
expect({home: restart.home, marker: restart.marker}).toEqual({home: null, marker: "new"})
|
|
359
384
|
} finally {
|
|
360
385
|
await reconnectAndShutdownGuardian(fixture, authority)
|
|
361
386
|
}
|
|
@@ -375,7 +400,7 @@ test("guardian rearms recovery after an ownerless replacement aborts", async ()
|
|
|
375
400
|
|
|
376
401
|
await new Promise((resolve) => setTimeout(resolve, 80))
|
|
377
402
|
await replacement.abortOwnerReplacement(prepared.replacementId)
|
|
378
|
-
|
|
403
|
+
expect((await waitForRestartRecords(markerPath, 1)).map(({home, marker}) => ({home, marker}))).toEqual([{home: null, marker: "accepted"}])
|
|
379
404
|
} finally {
|
|
380
405
|
replacement.disconnect()
|
|
381
406
|
await reconnectAndShutdownGuardian(fixture, authority)
|
|
@@ -392,9 +417,9 @@ test("guardian retry backoff remains nonzero when reconnect grace is zero", asyn
|
|
|
392
417
|
fixture.client.disconnect()
|
|
393
418
|
const records = await waitForRestartRecords(markerPath, 2)
|
|
394
419
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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})
|
|
398
423
|
} finally {
|
|
399
424
|
await reconnectAndShutdownGuardian(fixture, authority)
|
|
400
425
|
}
|
|
@@ -421,7 +446,7 @@ test("a retired guardian-started owner does not block recovery of its replacemen
|
|
|
421
446
|
await committed
|
|
422
447
|
replacement.disconnect()
|
|
423
448
|
|
|
424
|
-
|
|
449
|
+
expect((await waitForRestartRecords(secondMarkerPath, 1)).map(({home, marker}) => ({home, marker}))).toEqual([{home: null, marker: "replacement"}])
|
|
425
450
|
} finally {
|
|
426
451
|
replacement.disconnect()
|
|
427
452
|
if (firstOwnerPid) {
|
|
@@ -544,7 +569,7 @@ test("guardian backs off when a restarted owner exits after claiming but before
|
|
|
544
569
|
const records = await waitForRestartRecords(startedLogPath, 2)
|
|
545
570
|
|
|
546
571
|
await waitForProcessExit(descendantPid)
|
|
547
|
-
|
|
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})
|
|
548
573
|
} finally {
|
|
549
574
|
if (descendantPid) {
|
|
550
575
|
try { process.kill(descendantPid, "SIGKILL") } catch (_error) { /* Exact descendant already exited. */ }
|
|
@@ -575,7 +600,7 @@ test("guardian preserves restart backoff when an unready owner disconnect aborts
|
|
|
575
600
|
await aborted
|
|
576
601
|
const records = await waitForRestartRecords(startedLogPath, 2)
|
|
577
602
|
|
|
578
|
-
|
|
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})
|
|
579
604
|
} finally {
|
|
580
605
|
replacement.disconnect()
|
|
581
606
|
await reconnectAndShutdownGuardian(fixture, authority)
|
|
@@ -614,7 +639,8 @@ test("replacement commit preserves a claimed guardian restart child through list
|
|
|
614
639
|
process.kill(incumbentPid, "SIGUSR2")
|
|
615
640
|
await Promise.race([waitForFileText(committedPath, new RegExp(prepared.replacementId)), published])
|
|
616
641
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
617
|
-
|
|
642
|
+
// The claimed incumbent must survive replacement commit until its listeners retire.
|
|
643
|
+
await expect(() => process.kill(incumbentPid, 0)).not.toThrow()
|
|
618
644
|
|
|
619
645
|
process.kill(incumbentPid, "SIGUSR1")
|
|
620
646
|
await published
|
|
@@ -655,7 +681,7 @@ test("ownerless replacement commit kills a superseded restart candidate", async
|
|
|
655
681
|
await committed
|
|
656
682
|
replacement.disconnect()
|
|
657
683
|
|
|
658
|
-
|
|
684
|
+
expect((await waitForRestartRecords(secondMarkerPath, 1)).map(({home, marker}) => ({home, marker}))).toEqual([{home: null, marker: "committed"}])
|
|
659
685
|
} finally {
|
|
660
686
|
replacement.disconnect()
|
|
661
687
|
if (delayedOwnerPid) {
|
|
@@ -693,9 +719,10 @@ test("guardian logs an asynchronous daemon spawn failure before retrying", async
|
|
|
693
719
|
const diagnosticPattern = /"code":"ENOENT".*"message":"guardian failed to restart daemon"/
|
|
694
720
|
const diagnostic = await waitForFileText(logPath, diagnosticPattern)
|
|
695
721
|
|
|
696
|
-
|
|
722
|
+
expect(diagnostic).toMatch(diagnosticPattern)
|
|
697
723
|
for (const privateValue of [privateArgs, privateEnvironment, privateExecutable, fixture.root, logPath]) {
|
|
698
|
-
|
|
724
|
+
// Guardian diagnostics must not expose private values, including in assertion failures.
|
|
725
|
+
expect(diagnostic.includes(privateValue)).toBe(false)
|
|
699
726
|
}
|
|
700
727
|
} finally {
|
|
701
728
|
await reconnectAndShutdownGuardian(fixture, authority)
|
|
@@ -729,8 +756,8 @@ test("guardian publishes the authenticated ready owner's PID file", async () =>
|
|
|
729
756
|
await fs.symlink(victimPath, pidPath)
|
|
730
757
|
await fixture.client.ownerReady()
|
|
731
758
|
|
|
732
|
-
|
|
733
|
-
|
|
759
|
+
expect(await fs.readFile(pidPath, "utf8")).toBe(`${process.pid}\n`)
|
|
760
|
+
expect(await fs.readFile(victimPath, "utf8")).toBe("unchanged\n")
|
|
734
761
|
} finally {
|
|
735
762
|
await cleanupGuardian(fixture)
|
|
736
763
|
}
|
|
@@ -752,14 +779,15 @@ test("replacement commit notification waits for incumbent listener retirement",
|
|
|
752
779
|
|
|
753
780
|
await fixture.client.commitOwnerReplacement(prepared.replacementId)
|
|
754
781
|
await new Promise((resolve) => setImmediate(resolve))
|
|
755
|
-
|
|
782
|
+
// Candidate publication must remain fenced while incumbent listener retirement is delayed.
|
|
783
|
+
expect(committed).toBe(false)
|
|
756
784
|
const listenersRetired = candidate.waitForEvent("replacement-listeners-retired")
|
|
757
785
|
|
|
758
786
|
await fixture.client.completeOwnerListenerRetirement(prepared.replacementId)
|
|
759
787
|
await listenersRetired
|
|
760
788
|
await fixture.client.request({command: "finalize-owner-replacement", replacementId: prepared.replacementId})
|
|
761
789
|
await notification
|
|
762
|
-
|
|
790
|
+
expect(committed).toBe(true)
|
|
763
791
|
await candidate.shutdown()
|
|
764
792
|
await fixture.client.guardianExit()
|
|
765
793
|
} finally {
|
|
@@ -768,7 +796,7 @@ test("replacement commit notification waits for incumbent listener retirement",
|
|
|
768
796
|
}
|
|
769
797
|
})
|
|
770
798
|
|
|
771
|
-
test("completed direct listener retirement finalizes when the incumbent disconnects", {
|
|
799
|
+
test("completed direct listener retirement finalizes when the incumbent disconnects", {timeoutMs: 3000}, async () => {
|
|
772
800
|
const fixture = await createGuardian()
|
|
773
801
|
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
774
802
|
const authority = {configDigest: "incumbent", runtime: null}
|
|
@@ -787,7 +815,7 @@ test("completed direct listener retirement finalizes when the incumbent disconne
|
|
|
787
815
|
fixture.client.disconnect()
|
|
788
816
|
await committed
|
|
789
817
|
await candidate.finalizeOwnerReplacement(prepared.replacementId)
|
|
790
|
-
|
|
818
|
+
expect(await candidate.replacementStatus()).toEqual({
|
|
791
819
|
committedReplacementId: prepared.replacementId,
|
|
792
820
|
ownerClaimed: true,
|
|
793
821
|
retirementFailed: false,
|
|
@@ -814,14 +842,11 @@ test("replacement staging rejects owner state published after prepare", async ()
|
|
|
814
842
|
const prepared = await candidate.prepareOwnerReplacement(authority, nextAuthority)
|
|
815
843
|
|
|
816
844
|
await fixture.client.publishOwnerState({authority, snapshot: {activeReleaseId: "v2"}})
|
|
817
|
-
await
|
|
818
|
-
() => candidate.stageOwnerReplacement(prepared.replacementId, {authority: nextAuthority, snapshot: {activeReleaseId: "v1"}}),
|
|
819
|
-
/owner state changed after prepare/i
|
|
820
|
-
)
|
|
845
|
+
await expect(candidate.stageOwnerReplacement(prepared.replacementId, {authority: nextAuthority, snapshot: {activeReleaseId: "v1"}})).rejects.toThrow(/owner state changed after prepare/i)
|
|
821
846
|
await candidate.abortOwnerReplacement(prepared.replacementId)
|
|
822
847
|
const fresh = await candidate.prepareOwnerReplacement(authority, nextAuthority)
|
|
823
848
|
|
|
824
|
-
|
|
849
|
+
expect(fresh.ownerState).toEqual({authority, snapshot: {activeReleaseId: "v2"}})
|
|
825
850
|
await candidate.abortOwnerReplacement(fresh.replacementId)
|
|
826
851
|
await fixture.client.shutdown()
|
|
827
852
|
await fixture.client.guardianExit()
|
|
@@ -831,7 +856,7 @@ test("replacement staging rejects owner state published after prepare", async ()
|
|
|
831
856
|
}
|
|
832
857
|
})
|
|
833
858
|
|
|
834
|
-
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 () => {
|
|
835
860
|
const fixture = await createGuardian()
|
|
836
861
|
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
837
862
|
const authority = {configDigest: "incumbent", runtime: null}
|
|
@@ -858,7 +883,7 @@ test("staged replacement receives cleared local sources when the incumbent disco
|
|
|
858
883
|
const committed = candidate.waitForEvent("replacement-committed")
|
|
859
884
|
|
|
860
885
|
fixture.client.disconnect()
|
|
861
|
-
|
|
886
|
+
expect(await cleared).toEqual({
|
|
862
887
|
connections: {http: 0, websocket: 0},
|
|
863
888
|
event: "owner-connection-state",
|
|
864
889
|
releaseId: "v1",
|
|
@@ -867,7 +892,7 @@ test("staged replacement receives cleared local sources when the incumbent disco
|
|
|
867
892
|
await committed
|
|
868
893
|
const state = /** @type {{listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>}} */ (await candidate.ownerState())
|
|
869
894
|
|
|
870
|
-
|
|
895
|
+
expect(state.listenerConnectionSources).toEqual({})
|
|
871
896
|
await candidate.shutdown()
|
|
872
897
|
await fixture.client.guardianExit()
|
|
873
898
|
} finally {
|
|
@@ -876,7 +901,7 @@ test("staged replacement receives cleared local sources when the incumbent disco
|
|
|
876
901
|
}
|
|
877
902
|
})
|
|
878
903
|
|
|
879
|
-
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 () => {
|
|
880
905
|
const fixture = await createGuardian()
|
|
881
906
|
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
882
907
|
const successor = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
@@ -918,12 +943,12 @@ test("staged successor state receives tombstones when an older completed listene
|
|
|
918
943
|
fixture.client.disconnect()
|
|
919
944
|
const tombstone = {connections: {http: 0, websocket: 0}, event: "owner-connection-state", releaseId: "v1", sourceId: "retired-local"}
|
|
920
945
|
|
|
921
|
-
|
|
946
|
+
expect(await Promise.all([sourceCleared, stagedSourceCleared])).toEqual([tombstone, tombstone])
|
|
922
947
|
|
|
923
948
|
await candidate.commitOwnerReplacement(second.replacementId)
|
|
924
949
|
const successorState = /** @type {{listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>}} */ (await successor.ownerState())
|
|
925
950
|
|
|
926
|
-
|
|
951
|
+
expect(successorState.listenerConnectionSources).toEqual({})
|
|
927
952
|
const secondCommitted = successor.waitForEvent("replacement-committed")
|
|
928
953
|
const secondListenersRetired = successor.waitForEvent("replacement-listeners-retired")
|
|
929
954
|
|
|
@@ -954,7 +979,7 @@ test("replacement abort notifies both the candidate and committed owner", async
|
|
|
954
979
|
const candidateAborted = candidate.waitForEvent("replacement-aborted")
|
|
955
980
|
|
|
956
981
|
await candidate.abortOwnerReplacement(prepared.replacementId)
|
|
957
|
-
|
|
982
|
+
expect(await Promise.all([incumbentAborted, candidateAborted])).toEqual([
|
|
958
983
|
{event: "replacement-aborted", reason: "Replacement candidate aborted the prepared transaction"},
|
|
959
984
|
{event: "replacement-aborted", reason: "Replacement candidate aborted the prepared transaction"}
|
|
960
985
|
])
|
|
@@ -973,7 +998,7 @@ test("retired owner replacement commit carries its exact recovered process key",
|
|
|
973
998
|
|
|
974
999
|
client.request = async (request) => {
|
|
975
1000
|
if (!request.key) throw new Error(`Guardian ${request.command} requires a process key`)
|
|
976
|
-
|
|
1001
|
+
expect(request).toEqual({command: "commit-retired-owner-replacement", key: processKey, replacementId})
|
|
977
1002
|
return {committed: true}
|
|
978
1003
|
}
|
|
979
1004
|
|
|
@@ -984,7 +1009,7 @@ test("guardian owner-replacement capability classification is explicit and fail
|
|
|
984
1009
|
const current = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
|
|
985
1010
|
|
|
986
1011
|
current.request = async (request) => {
|
|
987
|
-
|
|
1012
|
+
expect(request).toEqual({command: "owner-replacement-capabilities"})
|
|
988
1013
|
return {
|
|
989
1014
|
commands: ["commit-retired-owner-replacement", "future-command"],
|
|
990
1015
|
futureField: {supported: true},
|
|
@@ -992,7 +1017,7 @@ test("guardian owner-replacement capability classification is explicit and fail
|
|
|
992
1017
|
version: 2
|
|
993
1018
|
}
|
|
994
1019
|
}
|
|
995
|
-
|
|
1020
|
+
expect(await current.ownerReplacementProtocol()).toBe("atomic")
|
|
996
1021
|
|
|
997
1022
|
for (const [commitDiagnostic, expected] of [
|
|
998
1023
|
["Owner replacement transaction is not the prepared candidate", "atomic"],
|
|
@@ -1007,8 +1032,8 @@ test("guardian owner-replacement capability classification is explicit and fail
|
|
|
1007
1032
|
if (request.command === "owner-replacement-capabilities") throw new Error("Guardian owner-replacement-capabilities requires a process key")
|
|
1008
1033
|
throw new Error(commitDiagnostic)
|
|
1009
1034
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1035
|
+
expect(await older.ownerReplacementProtocol()).toBe(expected)
|
|
1036
|
+
expect(requests).toEqual([
|
|
1012
1037
|
{command: "owner-replacement-capabilities"},
|
|
1013
1038
|
{command: "commit-retired-owner-replacement", replacementId: "owner-replacement-capability-probe"}
|
|
1014
1039
|
])
|
|
@@ -1024,7 +1049,7 @@ test("guardian owner-replacement capability classification is explicit and fail
|
|
|
1024
1049
|
const ambiguous = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
|
|
1025
1050
|
|
|
1026
1051
|
ambiguous.request = fixture
|
|
1027
|
-
await
|
|
1052
|
+
await expect(ambiguous.ownerReplacementProtocol()).rejects.toThrow(/invalid owner-replacement capability response|ambiguous retired-owner capability response/)
|
|
1028
1053
|
}
|
|
1029
1054
|
})
|
|
1030
1055
|
|
|
@@ -1037,14 +1062,11 @@ test("reserved process recovery rejects a reconstructed definition with differen
|
|
|
1037
1062
|
await fixture.client.process(processKey, definition("worker")).recover()
|
|
1038
1063
|
const [registration] = await fixture.client.inventory()
|
|
1039
1064
|
|
|
1040
|
-
|
|
1065
|
+
if (!registration) throw new Error("Missing required fixture: registration")
|
|
1041
1066
|
await candidate.connect()
|
|
1042
1067
|
candidate.reserveProcessRecovery(processKey, registration.provenance)
|
|
1043
|
-
await
|
|
1044
|
-
|
|
1045
|
-
/provenance mismatch for reserved process/
|
|
1046
|
-
)
|
|
1047
|
-
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])
|
|
1048
1070
|
} finally {
|
|
1049
1071
|
candidate.disconnect()
|
|
1050
1072
|
await cleanupGuardian(fixture)
|
|
@@ -1077,10 +1099,7 @@ test("retired owner replacement rejects a registered process absent from committ
|
|
|
1077
1099
|
const prepared = await candidate.prepareOwnerReplacement(authority, authority)
|
|
1078
1100
|
|
|
1079
1101
|
await candidate.stageOwnerReplacement(prepared.replacementId, {authority, snapshot: candidateSnapshot})
|
|
1080
|
-
await
|
|
1081
|
-
() => candidate.commitRetiredOwnerReplacement(prepared.replacementId, candidateProcessKey),
|
|
1082
|
-
/process .* does not belong to the committed owner/
|
|
1083
|
-
)
|
|
1102
|
+
await expect(candidate.commitRetiredOwnerReplacement(prepared.replacementId, candidateProcessKey)).rejects.toThrow(/process .* does not belong to the committed owner/)
|
|
1084
1103
|
} finally {
|
|
1085
1104
|
candidate.disconnect()
|
|
1086
1105
|
await cleanupGuardian(fixture)
|
|
@@ -1110,14 +1129,14 @@ test("retired owner replacement requires unchanged authority and the exact contr
|
|
|
1110
1129
|
const changed = await candidate.prepareOwnerReplacement(authority, nextAuthority)
|
|
1111
1130
|
|
|
1112
1131
|
await candidate.stageOwnerReplacement(changed.replacementId, {authority: nextAuthority, snapshot})
|
|
1113
|
-
await
|
|
1132
|
+
await expect(candidate.commitRetiredOwnerReplacement(changed.replacementId, processKey)).rejects.toThrow(/unchanged owner authority/)
|
|
1114
1133
|
await candidate.abortOwnerReplacement(changed.replacementId)
|
|
1115
1134
|
|
|
1116
1135
|
const occupied = await candidate.prepareOwnerReplacement(authority, authority)
|
|
1117
1136
|
|
|
1118
1137
|
await candidate.stageOwnerReplacement(occupied.replacementId, {authority, snapshot})
|
|
1119
1138
|
await fs.writeFile(controlPath, "occupied\n")
|
|
1120
|
-
await
|
|
1139
|
+
await expect(candidate.commitRetiredOwnerReplacement(occupied.replacementId, processKey)).rejects.toThrow(/control socket .* still exists/)
|
|
1121
1140
|
await candidate.abortOwnerReplacement(occupied.replacementId)
|
|
1122
1141
|
|
|
1123
1142
|
await fs.rm(controlPath)
|
|
@@ -1125,34 +1144,22 @@ test("retired owner replacement requires unchanged authority and the exact contr
|
|
|
1125
1144
|
|
|
1126
1145
|
await candidate.stageOwnerReplacement(ready.replacementId, {authority, snapshot})
|
|
1127
1146
|
await contender.connect()
|
|
1128
|
-
await
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
)
|
|
1132
|
-
await assert.rejects(
|
|
1133
|
-
() => candidate.commitRetiredOwnerReplacement("stale-replacement", processKey),
|
|
1134
|
-
/not the prepared candidate/
|
|
1135
|
-
)
|
|
1136
|
-
await assert.rejects(
|
|
1137
|
-
() => candidate.commitRetiredOwnerReplacement(ready.replacementId, "release:v1:wrong"),
|
|
1138
|
-
/process .* is not registered/
|
|
1139
|
-
)
|
|
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/)
|
|
1140
1150
|
const handoffRequested = fixture.client.waitForEvent("replacement-listener-handoff-requested")
|
|
1141
1151
|
|
|
1142
1152
|
void handoffRequested.catch(() => undefined)
|
|
1143
1153
|
|
|
1144
1154
|
await candidate.prepareRetiredOwnerListenerHandoff(ready.replacementId, processKey)
|
|
1145
1155
|
await handoffRequested
|
|
1146
|
-
await
|
|
1147
|
-
() => contender.prepareOwnerReplacement(authority, authority),
|
|
1148
|
-
/listener retirement is pending/
|
|
1149
|
-
)
|
|
1156
|
+
await expect(contender.prepareOwnerReplacement(authority, authority)).rejects.toThrow(/listener retirement is pending/)
|
|
1150
1157
|
const committed = candidate.waitForEvent("replacement-committed")
|
|
1151
1158
|
const listenersRetired = candidate.waitForEvent("replacement-listeners-retired")
|
|
1152
1159
|
const connectionState = candidate.waitForEvent("owner-connection-state")
|
|
1153
1160
|
|
|
1154
1161
|
await fixture.client.publishOwnerConnectionState(ready.replacementId, "listener-a", "v1", {http: 1, websocket: 2}, true)
|
|
1155
|
-
|
|
1162
|
+
expect(await connectionState).toEqual({connections: {http: 1, websocket: 2}, event: "owner-connection-state", releaseId: "v1", sourceId: "listener-a"})
|
|
1156
1163
|
await fixture.client.completeOwnerListenerRetirement(ready.replacementId)
|
|
1157
1164
|
await listenersRetired
|
|
1158
1165
|
const retirementRequested = fixture.client.waitForEvent("replacement-retirement-requested")
|
|
@@ -1206,7 +1213,7 @@ test("completed listener retirement survives owner recovery and clears a crashed
|
|
|
1206
1213
|
const initial = candidate.waitForEvent("owner-connection-state")
|
|
1207
1214
|
|
|
1208
1215
|
await fixture.client.publishOwnerConnectionState(prepared.replacementId, "listener-a", "v1", {http: 0, websocket: 1}, true)
|
|
1209
|
-
|
|
1216
|
+
expect(await initial).toEqual({connections: {http: 0, websocket: 1}, event: "owner-connection-state", releaseId: "v1", sourceId: "listener-a"})
|
|
1210
1217
|
const listenersRetired = candidate.waitForEvent("replacement-listeners-retired")
|
|
1211
1218
|
|
|
1212
1219
|
await fixture.client.completeOwnerListenerRetirement(prepared.replacementId)
|
|
@@ -1217,9 +1224,9 @@ test("completed listener retirement survives owner recovery and clears a crashed
|
|
|
1217
1224
|
await recovered.claimOwner(1000, authority)
|
|
1218
1225
|
const stateAfterCandidateCrash = /** @type {{listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>}} */ (await recovered.ownerState())
|
|
1219
1226
|
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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({
|
|
1223
1230
|
committedReplacementId: prepared.replacementId,
|
|
1224
1231
|
ownerClaimed: true,
|
|
1225
1232
|
retirementFailed: false,
|
|
@@ -1230,17 +1237,17 @@ test("completed listener retirement survives owner recovery and clears a crashed
|
|
|
1230
1237
|
const cleared = recovered.waitForEvent("owner-connection-state")
|
|
1231
1238
|
|
|
1232
1239
|
fixture.client.disconnect()
|
|
1233
|
-
|
|
1240
|
+
expect(await Promise.race([
|
|
1234
1241
|
cleared,
|
|
1235
1242
|
new Promise((_, reject) => {
|
|
1236
1243
|
const timer = setTimeout(() => reject(new Error("Recovered owner did not receive the retired source tombstone")), 500)
|
|
1237
1244
|
|
|
1238
1245
|
timer.unref()
|
|
1239
1246
|
})
|
|
1240
|
-
])
|
|
1247
|
+
])).toEqual({connections: {http: 0, websocket: 0}, event: "owner-connection-state", releaseId: "v1", sourceId: "listener-a"})
|
|
1241
1248
|
const recoveredOwnerState = /** @type {{listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>}} */ (await recovered.ownerState())
|
|
1242
1249
|
|
|
1243
|
-
|
|
1250
|
+
expect(recoveredOwnerState.listenerConnectionSources).toEqual({})
|
|
1244
1251
|
await recovered.publishOwnerState({
|
|
1245
1252
|
authority,
|
|
1246
1253
|
listenerConnectionSources: {"listener-a": {v1: {http: 0, websocket: 1}}},
|
|
@@ -1249,7 +1256,7 @@ test("completed listener retirement survives owner recovery and clears a crashed
|
|
|
1249
1256
|
})
|
|
1250
1257
|
const afterStalePublication = /** @type {{listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>}} */ (await recovered.ownerState())
|
|
1251
1258
|
|
|
1252
|
-
|
|
1259
|
+
expect(afterStalePublication.listenerConnectionSources).toEqual({})
|
|
1253
1260
|
const recoveredProcess = recovered.process(processKey, definition("worker"))
|
|
1254
1261
|
|
|
1255
1262
|
await recoveredProcess.recover()
|
|
@@ -1261,7 +1268,7 @@ test("completed listener retirement survives owner recovery and clears a crashed
|
|
|
1261
1268
|
})
|
|
1262
1269
|
const afterStaleProcessUpdate = /** @type {{listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>}} */ (await recovered.ownerState())
|
|
1263
1270
|
|
|
1264
|
-
|
|
1271
|
+
expect(afterStaleProcessUpdate.listenerConnectionSources).toEqual({})
|
|
1265
1272
|
await recovered.shutdown()
|
|
1266
1273
|
await fixture.client.guardianExit()
|
|
1267
1274
|
} finally {
|
|
@@ -1288,7 +1295,7 @@ test("direct retired-listener source relay survives committed owner recovery", a
|
|
|
1288
1295
|
const initial = candidate.waitForEvent("owner-connection-state")
|
|
1289
1296
|
|
|
1290
1297
|
await fixture.client.publishOwnerConnectionState(prepared.replacementId, "direct-listener", "v1", {http: 0, websocket: 1}, true)
|
|
1291
|
-
|
|
1298
|
+
expect(await initial).toEqual({connections: {http: 0, websocket: 1}, event: "owner-connection-state", releaseId: "v1", sourceId: "direct-listener"})
|
|
1292
1299
|
const listenersRetired = candidate.waitForEvent("replacement-listeners-retired")
|
|
1293
1300
|
|
|
1294
1301
|
await fixture.client.completeOwnerListenerRetirement(prepared.replacementId)
|
|
@@ -1300,7 +1307,7 @@ test("direct retired-listener source relay survives committed owner recovery", a
|
|
|
1300
1307
|
const cleared = recovered.waitForEvent("owner-connection-state")
|
|
1301
1308
|
|
|
1302
1309
|
await fixture.client.publishOwnerConnectionState(prepared.replacementId, "direct-listener", "v1", {http: 0, websocket: 0}, true)
|
|
1303
|
-
|
|
1310
|
+
expect(await cleared).toEqual({connections: {http: 0, websocket: 0}, event: "owner-connection-state", releaseId: "v1", sourceId: "direct-listener"})
|
|
1304
1311
|
await recovered.shutdown()
|
|
1305
1312
|
await fixture.client.guardianExit()
|
|
1306
1313
|
} finally {
|
|
@@ -1350,17 +1357,17 @@ test("incumbent listener disconnect before state completion aborts without commi
|
|
|
1350
1357
|
await fixture.client.publishOwnerConnectionState(prepared.replacementId, "incumbent-local", "v1", {http: 0, websocket: 1}, true)
|
|
1351
1358
|
await sourcePublished
|
|
1352
1359
|
fixture.client.disconnect()
|
|
1353
|
-
|
|
1360
|
+
expect(await failed).toEqual({
|
|
1354
1361
|
event: "replacement-retirement-failed",
|
|
1355
1362
|
reason: "Incumbent listener disconnected during the prepared handoff",
|
|
1356
1363
|
replacementId: prepared.replacementId
|
|
1357
1364
|
})
|
|
1358
|
-
|
|
1365
|
+
expect(await aborted).toEqual({
|
|
1359
1366
|
event: "replacement-aborted",
|
|
1360
1367
|
reason: "Incumbent listener disconnected during the prepared handoff"
|
|
1361
1368
|
})
|
|
1362
|
-
|
|
1363
|
-
|
|
1369
|
+
expect(tombstones).toBe(1)
|
|
1370
|
+
expect(await candidate.replacementStatus()).toEqual({
|
|
1364
1371
|
committedReplacementId: null,
|
|
1365
1372
|
ownerClaimed: false,
|
|
1366
1373
|
retirementFailed: false,
|
|
@@ -1402,7 +1409,7 @@ test("replacement abort during listener handoff validation leaves the incumbent
|
|
|
1402
1409
|
const abandonedHandoff = candidate.prepareRetiredOwnerListenerHandoff(prepared.replacementId, processKey)
|
|
1403
1410
|
|
|
1404
1411
|
await candidate.abortOwnerReplacement(prepared.replacementId)
|
|
1405
|
-
await
|
|
1412
|
+
await expect(abandonedHandoff).rejects.toThrow(/prepared candidate/)
|
|
1406
1413
|
await contender.connect()
|
|
1407
1414
|
const fresh = await contender.prepareOwnerReplacement(authority, authority)
|
|
1408
1415
|
|
|
@@ -1452,7 +1459,7 @@ test("queued owner claim is revalidated against the latest committed authority",
|
|
|
1452
1459
|
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
1453
1460
|
await fixture.client.publishOwnerState({authority: nextAuthority, snapshot: {activeReleaseId: null}})
|
|
1454
1461
|
fixture.client.disconnect()
|
|
1455
|
-
await
|
|
1462
|
+
await expect(claim).rejects.toThrow(/authority changed while the claim was queued/)
|
|
1456
1463
|
await contender.claimOwner(500, nextAuthority)
|
|
1457
1464
|
await contender.shutdown()
|
|
1458
1465
|
await fixture.client.guardianExit()
|
|
@@ -1471,7 +1478,7 @@ test("first upgrade migrates a real pre-split guardian without replacing its own
|
|
|
1471
1478
|
|
|
1472
1479
|
try {
|
|
1473
1480
|
await legacyProcess.start()
|
|
1474
|
-
await
|
|
1481
|
+
await expect(fixture.client.capabilities()).rejects.toThrow(/Guardian capabilities requires a process key/)
|
|
1475
1482
|
legacyPid = legacyProcess.status().pid
|
|
1476
1483
|
const authority = {configDigest: "legacy-config", runtime: {digest: "legacy-runtime", format: 1, path: "/legacy", version: "0.1.28"}}
|
|
1477
1484
|
const nextAuthority = {...authority, runtime: {...authority.runtime, digest: "candidate-runtime", path: "/candidate"}}
|
|
@@ -1485,7 +1492,7 @@ test("first upgrade migrates a real pre-split guardian without replacing its own
|
|
|
1485
1492
|
}
|
|
1486
1493
|
}
|
|
1487
1494
|
|
|
1488
|
-
|
|
1495
|
+
if (!legacyPid) throw new Error("Missing required fixture: legacyPid")
|
|
1489
1496
|
upgraded = await fixture.client.upgradeLegacyGuardian({
|
|
1490
1497
|
ownerState,
|
|
1491
1498
|
socketPath: path.join(fixture.root, "guardian-v2.sock"),
|
|
@@ -1496,11 +1503,19 @@ test("first upgrade migrates a real pre-split guardian without replacing its own
|
|
|
1496
1503
|
const restored = upgraded.process("release:v1:legacy-worker", processDefinition)
|
|
1497
1504
|
|
|
1498
1505
|
await restored.recover()
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
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})
|
|
1502
1509
|
await committed
|
|
1503
|
-
|
|
1510
|
+
expect(restored.status().pid).toBe(legacyPid)
|
|
1511
|
+
|
|
1512
|
+
const currentProcess = upgraded.process("release:v2:current-worker", {
|
|
1513
|
+
...definition("current-worker"),
|
|
1514
|
+
lifecycle: {activateCommand: "sleep 0.05", activateTimeoutMs: 10, drainTimeoutMs: 0}
|
|
1515
|
+
})
|
|
1516
|
+
await currentProcess.start()
|
|
1517
|
+
await expect(currentProcess.activateStrict()).rejects.toThrow(/activate command timed out after 10ms/i)
|
|
1518
|
+
expect(restored.status().pid).toBe(legacyPid)
|
|
1504
1519
|
await upgraded.shutdown()
|
|
1505
1520
|
await upgraded.guardianExit()
|
|
1506
1521
|
} finally {
|
|
@@ -1539,11 +1554,8 @@ test("split guardian rejects an owner-state update when its nested legacy defini
|
|
|
1539
1554
|
|
|
1540
1555
|
await restored.recover()
|
|
1541
1556
|
await legacyProcess.updateDefinition({...processDefinition, env: {REVISION: "external"}})
|
|
1542
|
-
await
|
|
1543
|
-
|
|
1544
|
-
/provenance mismatch/
|
|
1545
|
-
)
|
|
1546
|
-
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)
|
|
1547
1559
|
} finally {
|
|
1548
1560
|
await legacyProcess.stop().catch(() => {})
|
|
1549
1561
|
upgraded?.disconnect()
|
|
@@ -1596,14 +1608,15 @@ test("split guardian defers owner handoff until a nested legacy definition updat
|
|
|
1596
1608
|
void claim.finally(() => { claimSettled = true })
|
|
1597
1609
|
upgraded.disconnect()
|
|
1598
1610
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
1599
|
-
|
|
1611
|
+
// Owner handoff must wait for the nested definition update.
|
|
1612
|
+
expect(claimSettled).toBe(false)
|
|
1600
1613
|
|
|
1601
1614
|
await fs.writeFile(gatePath, "allow\n")
|
|
1602
1615
|
await claim
|
|
1603
1616
|
const updateError = await updateResult
|
|
1604
1617
|
|
|
1605
|
-
|
|
1606
|
-
|
|
1618
|
+
expect(String(updateError)).toMatch(/connection closed while awaiting update/)
|
|
1619
|
+
expect(await contender.ownerState()).toEqual({...committedOwnerState, listenerConnectionSources: {}})
|
|
1607
1620
|
} finally {
|
|
1608
1621
|
await fs.writeFile(gatePath, "allow\n").catch(() => {})
|
|
1609
1622
|
await contender.shutdown().catch(() => {})
|
|
@@ -1642,7 +1655,8 @@ test("a disconnected legacy upgrade candidate does not strand its bridge guardia
|
|
|
1642
1655
|
new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error("Legacy upgrade bridge guardian did not exit after candidate disconnect")), 1000) })
|
|
1643
1656
|
]).finally(() => { if (timeout) clearTimeout(timeout) })
|
|
1644
1657
|
bridgeExited = true
|
|
1645
|
-
|
|
1658
|
+
// The pre-split guardian must remain available after bridge abandonment.
|
|
1659
|
+
expect(fixture.child.exitCode).toBe(null)
|
|
1646
1660
|
} finally {
|
|
1647
1661
|
upgraded?.disconnect()
|
|
1648
1662
|
if (!bridgeExited && upgraded?.pid) killExactProcessGroup(upgraded.pid)
|
|
@@ -1677,7 +1691,7 @@ test("a legacy bridge remains discoverable when its candidate disconnects after
|
|
|
1677
1691
|
await upgraded.beginLegacyOwnerClaim(prepared.replacementId, 1000, statePath, recoverySnapshot)
|
|
1678
1692
|
fixture.client.disconnect()
|
|
1679
1693
|
await upgraded.completeLegacyOwnerClaim(prepared.replacementId)
|
|
1680
|
-
|
|
1694
|
+
expect(JSON.parse(await fs.readFile(statePath, "utf8"))).toEqual(recoverySnapshot)
|
|
1681
1695
|
upgraded.disconnect()
|
|
1682
1696
|
|
|
1683
1697
|
replacement = new GuardianClient({...identity, pid: upgraded.pid})
|
|
@@ -1685,7 +1699,7 @@ test("a legacy bridge remains discoverable when its candidate disconnects after
|
|
|
1685
1699
|
const resumed = await replacement.prepareOwnerReplacement(authority, nextAuthority)
|
|
1686
1700
|
const committed = replacement.waitForEvent("replacement-committed")
|
|
1687
1701
|
|
|
1688
|
-
|
|
1702
|
+
expect(await replacement.stageOwnerReplacement(resumed.replacementId, {authority: nextAuthority, config: {statePath}, snapshot: ownerState.snapshot})).toEqual({committed: true})
|
|
1689
1703
|
await committed
|
|
1690
1704
|
await replacement.shutdown()
|
|
1691
1705
|
await upgraded.guardianExit()
|
|
@@ -1909,3 +1923,4 @@ function killExactProcessGroup(pid) {
|
|
|
1909
1923
|
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ESRCH") throw error
|
|
1910
1924
|
}
|
|
1911
1925
|
}
|
|
1926
|
+
})
|