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,13 +1,12 @@
|
|
|
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 net from "node:net"
|
|
8
7
|
import os from "node:os"
|
|
9
8
|
import path from "node:path"
|
|
10
|
-
import test from "
|
|
9
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
11
10
|
import {fileURLToPath} from "node:url"
|
|
12
11
|
import {normalizeConfig} from "../src/config.js"
|
|
13
12
|
import {openControlSession, sendControlCommand} from "../src/control-client.js"
|
|
@@ -16,6 +15,8 @@ import GuardianClient from "../src/guardian-client.js"
|
|
|
16
15
|
import {findAvailablePort} from "../src/port-allocator.js"
|
|
17
16
|
import {waitForProcessExit} from "./support/process.js"
|
|
18
17
|
|
|
18
|
+
describe("owner-replacement", () => {
|
|
19
|
+
|
|
19
20
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
20
21
|
const binPath = path.join(repoRoot, "bin", "rollbridge")
|
|
21
22
|
const dummyAppPath = path.join(repoRoot, "test", "fixtures", "dummy-app.js")
|
|
@@ -43,7 +44,7 @@ test("partial owner-replacement guardian crosses the authenticated legacy bridge
|
|
|
43
44
|
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
|
|
44
45
|
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
45
46
|
await waitForLog(owner, "control socket listening")
|
|
46
|
-
|
|
47
|
+
if (!owner.pid) throw new Error("Missing required fixture: owner.pid")
|
|
47
48
|
await fs.writeFile(daemonPidPath, `${owner.pid}\n`)
|
|
48
49
|
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: socketPath})
|
|
49
50
|
const before = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
@@ -70,15 +71,16 @@ test("partial owner-replacement guardian crosses the authenticated legacy bridge
|
|
|
70
71
|
const ownerExit = once(owner, "exit")
|
|
71
72
|
const ensured = await runEnsureDaemon({configPath, daemonPidPath, logPath: path.join(root, "candidate.log"), packagePath, runtimePath})
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
expect({value: ensured.code, context: `${ensured.stderr}\n${await fs.readFile(path.join(root, "candidate.log"), "utf8")}`}).toMatchObject({value: 0})
|
|
75
|
+
// The exact authenticated incumbent boundary is crossed once.
|
|
76
|
+
expect(await ownerExit).toEqual([null, "SIGKILL"])
|
|
75
77
|
await retainedClosed
|
|
76
78
|
const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
expect(status.activeReleaseId).toBe("v1")
|
|
81
|
+
expect(releaseProcessPid(status, "v1", "worker")).toBe(workerPid)
|
|
82
|
+
expect(releaseProcessPid(status, "v1", "web")).toBe(webPid)
|
|
83
|
+
expect(status.ownerTransition).toEqual({
|
|
82
84
|
disruptive: true,
|
|
83
85
|
mode: "legacy-first-upgrade",
|
|
84
86
|
reason: "retained guardian and daemon lacked atomic replacement protocol"
|
|
@@ -132,7 +134,7 @@ test("partial guardian replacement remains fenced through coordinator reconstruc
|
|
|
132
134
|
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
|
|
133
135
|
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
134
136
|
await waitForLog(owner, "control socket listening")
|
|
135
|
-
|
|
137
|
+
if (!owner.pid) throw new Error("Missing required fixture: owner.pid")
|
|
136
138
|
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: socketPath})
|
|
137
139
|
const state = JSON.parse(await fs.readFile(statePath, "utf8"))
|
|
138
140
|
|
|
@@ -160,7 +162,7 @@ test("partial guardian replacement remains fenced through coordinator reconstruc
|
|
|
160
162
|
replacementPromise = replacement.replaceIncompatibleOwner()
|
|
161
163
|
void replacementPromise.catch(() => {})
|
|
162
164
|
await restoreStarted.promise
|
|
163
|
-
|
|
165
|
+
expect(JSON.parse(await fs.readFile(statePath, "utf8")).recovery.guardian.socketPath).toBe(partialSocketPath)
|
|
164
166
|
|
|
165
167
|
let mutationError
|
|
166
168
|
|
|
@@ -186,12 +188,12 @@ test("partial guardian replacement remains fenced through coordinator reconstruc
|
|
|
186
188
|
contender.disconnect()
|
|
187
189
|
contender = undefined
|
|
188
190
|
continueRestore.resolve(undefined)
|
|
189
|
-
await
|
|
191
|
+
await expect(replacementPromise).rejects.toThrow(/injected reconstruction stop after fence audit/)
|
|
190
192
|
replacementPromise = undefined
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
expect(mutationError instanceof Error ? mutationError.message : "").toMatch(/fenced while an owner replacement is prepared/)
|
|
194
|
+
expect(contenderError instanceof Error ? contenderError.message : "").toMatch(/another owner replacement candidate is already prepared/i)
|
|
195
|
+
expect(JSON.parse(await fs.readFile(statePath, "utf8")).recovery.guardian.socketPath).not.toBe(`${statePath}.split3-guardian.sock`)
|
|
196
|
+
expect((await sendControlCommand({command: {command: "status"}, path: socketPath})).daemonPid).toBe(owner.pid)
|
|
195
197
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: socketPath})
|
|
196
198
|
|
|
197
199
|
await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
|
|
@@ -243,7 +245,7 @@ test("partial guardian replacement persists the coordinator only after ownership
|
|
|
243
245
|
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
|
|
244
246
|
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
245
247
|
await waitForLog(owner, "control socket listening")
|
|
246
|
-
|
|
248
|
+
if (!owner.pid) throw new Error("Missing required fixture: owner.pid")
|
|
247
249
|
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: socketPath})
|
|
248
250
|
const state = JSON.parse(await fs.readFile(statePath, "utf8"))
|
|
249
251
|
|
|
@@ -281,11 +283,8 @@ test("partial guardian replacement persists the coordinator only after ownership
|
|
|
281
283
|
replacementPromise = replacement.replaceIncompatibleOwner()
|
|
282
284
|
void replacementPromise.catch(() => {})
|
|
283
285
|
await ownershipConfirmationStarted.promise
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
`${statePath}.split3-guardian.sock`,
|
|
287
|
-
"durable state must not name the coordinator before its legacy ownership claim is confirmed"
|
|
288
|
-
)
|
|
286
|
+
// Durable state must not name the coordinator before its legacy ownership claim is confirmed.
|
|
287
|
+
expect(JSON.parse(await fs.readFile(statePath, "utf8")).recovery.guardian.socketPath).not.toBe(`${statePath}.split3-guardian.sock`)
|
|
289
288
|
continueOwnershipConfirmation.resolve(undefined)
|
|
290
289
|
await replacementPromise
|
|
291
290
|
replacementPromise = undefined
|
|
@@ -325,7 +324,7 @@ test("partial guardian replacement persists the coordinator only after ownership
|
|
|
325
324
|
}
|
|
326
325
|
})
|
|
327
326
|
|
|
328
|
-
test("failed partial upgrade resumes an incumbent retired release drain", {
|
|
327
|
+
test("failed partial upgrade resumes an incumbent retired release drain", {timeoutMs: 5000}, async () => {
|
|
329
328
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-partial-drain-"))
|
|
330
329
|
const socketPath = path.join(root, "rollbridge.sock")
|
|
331
330
|
const statePath = path.join(root, "state.json")
|
|
@@ -345,7 +344,7 @@ test("failed partial upgrade resumes an incumbent retired release drain", {timeo
|
|
|
345
344
|
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
|
|
346
345
|
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
347
346
|
await waitForLog(owner, "control socket listening")
|
|
348
|
-
|
|
347
|
+
if (!owner.pid) throw new Error("Missing required fixture: owner.pid")
|
|
349
348
|
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: socketPath})
|
|
350
349
|
const v1Status = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
351
350
|
const proxyPort = /** @type {{port?: number}} */ (v1Status.proxy).port
|
|
@@ -355,7 +354,7 @@ test("failed partial upgrade resumes an incumbent retired release drain", {timeo
|
|
|
355
354
|
await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: socketPath})
|
|
356
355
|
const draining = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
357
356
|
|
|
358
|
-
|
|
357
|
+
expect(releaseState(draining, "v1")).toBe("draining")
|
|
359
358
|
const state = JSON.parse(await fs.readFile(statePath, "utf8"))
|
|
360
359
|
|
|
361
360
|
backendGuardianIdentity = {...state.recovery.guardian}
|
|
@@ -375,15 +374,15 @@ test("failed partial upgrade resumes an incumbent retired release drain", {timeo
|
|
|
375
374
|
})
|
|
376
375
|
|
|
377
376
|
candidate = replacement
|
|
378
|
-
await
|
|
379
|
-
|
|
380
|
-
|
|
377
|
+
await expect(replacement.replaceIncompatibleOwner()).rejects.toThrow(/provenance mismatch/)
|
|
378
|
+
expect(owner.exitCode).toBe(null)
|
|
379
|
+
expect(owner.signalCode).toBe(null)
|
|
381
380
|
const releaseDrained = waitForLog(owner, "release drained")
|
|
382
381
|
|
|
383
382
|
retainedConnection.destroy()
|
|
384
383
|
await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
|
|
385
384
|
await releaseDrained
|
|
386
|
-
|
|
385
|
+
expect(releaseState(await sendControlCommand({command: {command: "status"}, path: socketPath}), "v1")).toBe("stopped")
|
|
387
386
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: socketPath})
|
|
388
387
|
|
|
389
388
|
await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
|
|
@@ -407,9 +406,7 @@ test("failed partial upgrade resumes an incumbent retired release drain", {timeo
|
|
|
407
406
|
}
|
|
408
407
|
})
|
|
409
408
|
|
|
410
|
-
test("partial guardian classification
|
|
411
|
-
for (const fault of ["malformed-capability", "wrong-pid", "wrong-provenance"]) {
|
|
412
|
-
await t.test(fault, async () => {
|
|
409
|
+
test.each(["malformed-capability", "wrong-pid", "wrong-provenance"])("partial guardian classification failure %s preserves the incumbent, children, and retained stream", async fault => {
|
|
413
410
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), `rollbridge-owner-replacement-partial-${fault}-`))
|
|
414
411
|
const socketPath = path.join(root, "rollbridge.sock")
|
|
415
412
|
const statePath = path.join(root, "state.json")
|
|
@@ -427,7 +424,7 @@ test("partial guardian classification failures preserve the incumbent, children,
|
|
|
427
424
|
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
|
|
428
425
|
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
429
426
|
await waitForLog(owner, "control socket listening")
|
|
430
|
-
|
|
427
|
+
if (!owner.pid) throw new Error("Missing required fixture: owner.pid")
|
|
431
428
|
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: socketPath})
|
|
432
429
|
const before = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
433
430
|
const processPids = [releaseProcessPid(before, "v1", "worker"), releaseProcessPid(before, "v1", "web")]
|
|
@@ -465,14 +462,14 @@ test("partial guardian classification failures preserve the incumbent, children,
|
|
|
465
462
|
? /does not own socket|does not match the retained guardian command and socket/
|
|
466
463
|
: /provenance mismatch/
|
|
467
464
|
|
|
468
|
-
await
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
for (const pid of processPids)
|
|
474
|
-
|
|
475
|
-
await
|
|
465
|
+
await expect(replacement.replaceIncompatibleOwner()).rejects.toThrow(expected)
|
|
466
|
+
expect(owner.exitCode).toBe(null)
|
|
467
|
+
expect(owner.signalCode).toBe(null)
|
|
468
|
+
expect(retainedClosed).toBe(false)
|
|
469
|
+
expect(retainedConnection.destroyed).toBe(false)
|
|
470
|
+
for (const pid of processPids) await expect(() => process.kill(pid, 0)).not.toThrow()
|
|
471
|
+
expect(releaseProcessPid(await sendControlCommand({command: {command: "status"}, path: socketPath}), "v1", "worker")).toBe(processPids[0])
|
|
472
|
+
await expect(fs.access(`${statePath}.split3-guardian.sock`)).rejects.toMatchObject({code: "ENOENT"})
|
|
476
473
|
|
|
477
474
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: socketPath})
|
|
478
475
|
|
|
@@ -487,8 +484,6 @@ test("partial guardian classification failures preserve the incumbent, children,
|
|
|
487
484
|
await stopGuardian(statePath)
|
|
488
485
|
await fs.rm(root, {force: true, recursive: true})
|
|
489
486
|
}
|
|
490
|
-
})
|
|
491
|
-
}
|
|
492
487
|
})
|
|
493
488
|
|
|
494
489
|
test("first pre-split package upgrade is explicitly disruptive and later replacements are atomic", async () => {
|
|
@@ -515,7 +510,7 @@ test("first pre-split package upgrade is explicitly disruptive and later replace
|
|
|
515
510
|
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, proxyPort, statePath}))
|
|
516
511
|
owner = spawn(process.execPath, [legacyDaemonPath, "daemon", "--config", path.basename(configPath)], {cwd: root, stdio: ["ignore", "pipe", "pipe"]})
|
|
517
512
|
await waitForLog(owner, "control socket listening")
|
|
518
|
-
|
|
513
|
+
if (!owner.pid) throw new Error("Missing required fixture: owner.pid")
|
|
519
514
|
await fs.writeFile(daemonPidPath, `${owner.pid}\n`)
|
|
520
515
|
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: socketPath})
|
|
521
516
|
const legacyStatus = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
@@ -527,20 +522,22 @@ test("first pre-split package upgrade is explicitly disruptive and later replace
|
|
|
527
522
|
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: true, proxyPort, statePath}))
|
|
528
523
|
const mismatchedUpgrade = await runEnsureDaemon({configPath, daemonPidPath, logPath: path.join(root, "mismatch.log"), packagePath: firstPackagePath, runtimePath})
|
|
529
524
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
525
|
+
expect(mismatchedUpgrade.code).toBe(1)
|
|
526
|
+
expect(await fs.readFile(path.join(root, "mismatch.log"), "utf8")).toMatch(/legacy guardian bridge requires the incumbent config identity unchanged/)
|
|
527
|
+
// Config mismatch must leave the legacy listener serving.
|
|
528
|
+
expect(interruptedConnection.destroyed).toBe(false)
|
|
529
|
+
expect(releaseProcessPid(await sendControlCommand({command: {command: "status"}, path: socketPath}), "v1", "worker")).toBe(legacyWorkerPid)
|
|
534
530
|
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, proxyPort, statePath}))
|
|
535
531
|
blockedUpgradeGuardian = net.createServer()
|
|
536
532
|
|
|
537
533
|
await listenUnix(blockedUpgradeGuardian, `${statePath}.split3-guardian.sock`)
|
|
538
534
|
const blockedUpgrade = await runEnsureDaemon({configPath, daemonPidPath, logPath: path.join(root, "blocked.log"), packagePath: firstPackagePath, runtimePath})
|
|
539
535
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
536
|
+
expect(blockedUpgrade.code).toBe(1)
|
|
537
|
+
expect(await fs.readFile(path.join(root, "blocked.log"), "utf8")).toMatch(/Legacy upgrade guardian socket .* already exists; refusing legacy upgrade/)
|
|
538
|
+
// Candidate preparation failure must leave the legacy listener serving.
|
|
539
|
+
expect(interruptedConnection.destroyed).toBe(false)
|
|
540
|
+
expect(releaseProcessPid(await sendControlCommand({command: {command: "status"}, path: socketPath}), "v1", "worker")).toBe(legacyWorkerPid)
|
|
544
541
|
await closeServer(blockedUpgradeGuardian)
|
|
545
542
|
const firstUpgrade = await run(process.execPath, [
|
|
546
543
|
path.join(firstPackagePath, "bin", "rollbridge"), "ensure-daemon", "--config", configPath,
|
|
@@ -548,8 +545,8 @@ test("first pre-split package upgrade is explicitly disruptive and later replace
|
|
|
548
545
|
"--daemon-pid-path", daemonPidPath, "--daemon-start-timeout-ms", "3000"
|
|
549
546
|
])
|
|
550
547
|
|
|
551
|
-
|
|
552
|
-
|
|
548
|
+
expect({value: firstUpgrade.code, context: `${firstUpgrade.stderr}\n${await fs.readFile(path.join(root, "first.log"), "utf8")}`}).toMatchObject({value: 0})
|
|
549
|
+
expect(JSON.parse(firstUpgrade.stdout).ownerTransition).toEqual({
|
|
553
550
|
disruptive: true,
|
|
554
551
|
mode: "legacy-first-upgrade",
|
|
555
552
|
reason: "retained guardian and daemon lacked atomic replacement protocol"
|
|
@@ -557,13 +554,13 @@ test("first pre-split package upgrade is explicitly disruptive and later replace
|
|
|
557
554
|
await interrupted
|
|
558
555
|
const bridged = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
559
556
|
|
|
560
|
-
|
|
557
|
+
expect(bridged.ownerTransition).toEqual({
|
|
561
558
|
disruptive: true,
|
|
562
559
|
mode: "legacy-first-upgrade",
|
|
563
560
|
reason: "retained guardian and daemon lacked atomic replacement protocol"
|
|
564
561
|
})
|
|
565
|
-
|
|
566
|
-
|
|
562
|
+
expect(releaseProcessPid(bridged, "v1", "worker")).toBe(legacyWorkerPid)
|
|
563
|
+
expect(bridged.activeReleaseId).toBe("v1")
|
|
567
564
|
|
|
568
565
|
retainedConnection = await openWebSocket(proxyPort)
|
|
569
566
|
let retainedConnectionClosed = false
|
|
@@ -576,13 +573,14 @@ test("first pre-split package upgrade is explicitly disruptive and later replace
|
|
|
576
573
|
"--daemon-pid-path", daemonPidPath, "--daemon-start-timeout-ms", "3000"
|
|
577
574
|
])
|
|
578
575
|
|
|
579
|
-
|
|
576
|
+
expect({value: secondUpgrade.code, context: `${secondUpgrade.stderr}\n${await fs.readFile(path.join(root, "second.log"), "utf8")}`}).toMatchObject({value: 0})
|
|
580
577
|
currentControlPath = nextSocketPath
|
|
581
|
-
|
|
578
|
+
// Protocol-capable replacement must retain established proxy connections.
|
|
579
|
+
expect(retainedConnectionClosed).toBe(false)
|
|
582
580
|
const replaced = await sendControlCommand({command: {command: "status"}, path: nextSocketPath})
|
|
583
581
|
|
|
584
|
-
|
|
585
|
-
|
|
582
|
+
expect(releaseProcessPid(replaced, "v1", "worker")).toBe(legacyWorkerPid)
|
|
583
|
+
expect(replaced.activeReleaseId).toBe("v1")
|
|
586
584
|
retainedConnection.resetAndDestroy()
|
|
587
585
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: nextSocketPath})
|
|
588
586
|
await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
|
|
@@ -630,7 +628,7 @@ test("replacement-capable guardian without daemon recovery aborts before handoff
|
|
|
630
628
|
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
|
|
631
629
|
owner = spawn(process.execPath, [path.join(intermediatePackagePath, "bin", "rollbridge"), "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
632
630
|
await waitForLog(owner, "control socket listening")
|
|
633
|
-
|
|
631
|
+
if (!owner.pid) throw new Error("Missing required fixture: owner.pid")
|
|
634
632
|
await fs.writeFile(daemonPidPath, `${owner.pid}\n`)
|
|
635
633
|
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: socketPath})
|
|
636
634
|
const active = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
@@ -641,13 +639,14 @@ test("replacement-capable guardian without daemon recovery aborts before handoff
|
|
|
641
639
|
const workerPid = releaseProcessPid(before, "v1", "worker")
|
|
642
640
|
const replacement = await runEnsureDaemon({configPath, daemonPidPath, logPath: daemonLogPath, packagePath: repoRoot, runtimePath})
|
|
643
641
|
|
|
644
|
-
|
|
645
|
-
|
|
642
|
+
expect(replacement.code).toBe(1)
|
|
643
|
+
expect(await fs.readFile(daemonLogPath, "utf8")).toMatch(/persistent Rollbridge guardian predates daemon recovery/)
|
|
646
644
|
await waitForFile(abortedPath)
|
|
647
645
|
const preserved = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
648
646
|
|
|
649
|
-
|
|
650
|
-
|
|
647
|
+
// Capability rejection must leave the incumbent daemon serving.
|
|
648
|
+
expect(preserved.daemonPid).toBe(before.daemonPid)
|
|
649
|
+
expect(releaseProcessPid(preserved, "v1", "worker")).toBe(workerPid)
|
|
651
650
|
retainedConnection.destroy()
|
|
652
651
|
retainedConnection = undefined
|
|
653
652
|
await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
|
|
@@ -685,17 +684,18 @@ test("ensure-daemon owns and reports the exact candidate exit before readiness",
|
|
|
685
684
|
])
|
|
686
685
|
candidate = JSON.parse(await fs.readFile(evidencePath, "utf8"))
|
|
687
686
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
687
|
+
// The recorded process must be the candidate spawned by this exact ensuring CLI.
|
|
688
|
+
expect(candidate.ppid).toBe(ensured.pid)
|
|
689
|
+
expect(candidate.pid).not.toBe(ensured.pid)
|
|
690
|
+
expect(candidate.argv.slice(2)).toEqual([
|
|
691
691
|
"daemon", "--config", configPath,
|
|
692
692
|
"--guardian-daemon-log-path", path.join(root, "daemon.log"),
|
|
693
693
|
"--guardian-daemon-pid-path", path.join(root, "daemon.pid"),
|
|
694
694
|
"--guardian-daemon-start-timeout-ms", "3000"
|
|
695
695
|
])
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
696
|
+
expect(ensured.code).toBe(1)
|
|
697
|
+
expect(ensured.stderr).toMatch(new RegExp(`Rollbridge daemon candidate ${candidate.pid} exited before readiness \\(code 47, signal none\\)`))
|
|
698
|
+
expect(ensured.stderr).not.toMatch(/did not become ready within/)
|
|
699
699
|
await waitForProcessExit(candidate.descendantPid)
|
|
700
700
|
} finally {
|
|
701
701
|
if (candidate?.descendantPid) {
|
|
@@ -706,8 +706,8 @@ test("ensure-daemon owns and reports the exact candidate exit before readiness",
|
|
|
706
706
|
})
|
|
707
707
|
|
|
708
708
|
test("legacy disruptive bridge rejects non-protocol guardian failures exactly", () => {
|
|
709
|
-
|
|
710
|
-
|
|
709
|
+
expect(isLegacyGuardianPrepareDiagnostic("Guardian prepare-owner-replacement requires a process key")).toBe(true)
|
|
710
|
+
expect(isLegacyGuardianPrepareDiagnostic("Unknown guardian command: prepare-owner-replacement")).toBe(true)
|
|
711
711
|
for (const diagnostic of [
|
|
712
712
|
"Unknown guardian command: deploy",
|
|
713
713
|
"Unknown guardian command: prepare-owner-replacement ",
|
|
@@ -716,7 +716,7 @@ test("legacy disruptive bridge rejects non-protocol guardian failures exactly",
|
|
|
716
716
|
"Process guardian connection closed while awaiting prepare-owner-replacement",
|
|
717
717
|
"Guardian owner authority mismatch",
|
|
718
718
|
"Malformed guardian response"
|
|
719
|
-
])
|
|
719
|
+
]) expect({value: isLegacyGuardianPrepareDiagnostic(diagnostic), context: diagnostic}).toMatchObject({value: false})
|
|
720
720
|
})
|
|
721
721
|
|
|
722
722
|
test("ensure-daemon atomically replaces incompatible config, socket, and package authority", async () => {
|
|
@@ -753,26 +753,26 @@ test("ensure-daemon atomically replaces incompatible config, socket, and package
|
|
|
753
753
|
|
|
754
754
|
const daemonLog = await fs.readFile(path.join(root, "daemon.log"), "utf8")
|
|
755
755
|
|
|
756
|
-
|
|
756
|
+
expect({value: ensured.code, context: `${ensured.stderr}\n${daemonLog}`}).toMatchObject({value: 0})
|
|
757
757
|
const transferred = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
|
|
758
758
|
const newRuntime = /** @type {{digest: string, path: string}} */ (transferred.daemonRuntime)
|
|
759
759
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
760
|
+
expect(transferred.activeReleaseId).toBe("v1")
|
|
761
|
+
expect(transferred.releaseReferences).toEqual([{releaseId: "v1", releasePath: v1Path}])
|
|
762
|
+
expect(releaseProcessPid(transferred, "v1", "worker")).toBe(workerPid)
|
|
763
|
+
expect(newRuntime.digest).not.toBe(oldRuntime.digest)
|
|
764
|
+
expect(path.dirname(newRuntime.path)).toBe(runtimePath)
|
|
765
765
|
|
|
766
766
|
await fs.rm(packagePath, {force: true, recursive: true})
|
|
767
767
|
await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: newSocketPath})
|
|
768
768
|
const deployed = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
|
|
769
769
|
|
|
770
|
-
|
|
771
|
-
|
|
770
|
+
expect(deployed.activeReleaseId).toBe("v2")
|
|
771
|
+
expect(deployed.releaseReferences).toEqual([
|
|
772
772
|
{releaseId: "v1", releasePath: v1Path},
|
|
773
773
|
{releaseId: "v2", releasePath: v2Path}
|
|
774
774
|
])
|
|
775
|
-
|
|
775
|
+
expect(releaseProcessPid(deployed, "v1", "worker")).toBe(workerPid)
|
|
776
776
|
|
|
777
777
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
|
|
778
778
|
await Promise.all([
|
|
@@ -816,12 +816,12 @@ test("cross-version replacement fails closed without dropping a retained WebSock
|
|
|
816
816
|
const processState = retiredReleases[0]?.processes.map(({id, pid, state}) => ({id, pid, state}))
|
|
817
817
|
const proxyPort = /** @type {{port?: number}} */ (retired.proxy).port
|
|
818
818
|
|
|
819
|
-
|
|
819
|
+
expect(processState?.map(({state}) => state)).toEqual(["running", "running"])
|
|
820
820
|
if (typeof proxyPort !== "number") throw new Error("Retained owner proxy is missing its port")
|
|
821
821
|
retainedConnection = await openWebSocket(proxyPort)
|
|
822
822
|
retainedConnection.once("close", () => { retainedConnectionClosed = true })
|
|
823
823
|
await fs.rm(socketPath)
|
|
824
|
-
await
|
|
824
|
+
await expect(fs.access(socketPath)).rejects.toMatchObject({code: "ENOENT"})
|
|
825
825
|
const state = JSON.parse(await fs.readFile(statePath, "utf8"))
|
|
826
826
|
const guardianSocketPath = state.recovery.guardian.socketPath
|
|
827
827
|
const expectedProcessKey = "release:v1:worker"
|
|
@@ -875,7 +875,7 @@ test("cross-version replacement fails closed without dropping a retained WebSock
|
|
|
875
875
|
|
|
876
876
|
const incumbentPid = owner.pid
|
|
877
877
|
|
|
878
|
-
|
|
878
|
+
if (!incumbentPid) throw new Error("Missing required fixture: incumbentPid")
|
|
879
879
|
const candidate = new RollbridgeDaemon({
|
|
880
880
|
config: normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath})),
|
|
881
881
|
configPath,
|
|
@@ -883,27 +883,26 @@ test("cross-version replacement fails closed without dropping a retained WebSock
|
|
|
883
883
|
logger: () => {}
|
|
884
884
|
})
|
|
885
885
|
replacement = candidate
|
|
886
|
-
await
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
)
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
assert.equal(retainedConnection.destroyed, false, "failed compatibility handoff must preserve the incumbent listener")
|
|
886
|
+
await expect(candidate.replaceIncompatibleOwner()).rejects.toThrow(/cannot safely complete atomic owner replacement through the older retained guardian while the incumbent control socket is absent; incumbent owner and connections were preserved/i)
|
|
887
|
+
expect(committedProcessKey).toBe(expectedProcessKey)
|
|
888
|
+
expect(owner.exitCode).toBe(null)
|
|
889
|
+
expect(owner.signalCode).toBe(null)
|
|
890
|
+
await expect(() => process.kill(incumbentPid, 0)).not.toThrow()
|
|
891
|
+
// Failed compatibility handoff must leave retained connections serving.
|
|
892
|
+
expect(retainedConnectionClosed).toBe(false)
|
|
893
|
+
// Failed compatibility handoff must preserve the incumbent listener.
|
|
894
|
+
expect(retainedConnection.destroyed).toBe(false)
|
|
896
895
|
for (const {pid} of processState || []) {
|
|
897
896
|
if (typeof pid !== "number") throw new Error("Retained process is missing its PID")
|
|
898
|
-
|
|
897
|
+
await expect(() => process.kill(pid, 0)).not.toThrow()
|
|
899
898
|
}
|
|
900
899
|
transactionAudit = new GuardianClient(state.recovery.guardian)
|
|
901
900
|
await transactionAudit.connect()
|
|
902
901
|
const transactionStatus = /** @type {{committedReplacementId: string | null, ownerClaimed: boolean, retirementPending?: boolean}} */ (await transactionAudit.replacementStatus())
|
|
903
902
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
903
|
+
expect(transactionStatus.committedReplacementId).toBe(null)
|
|
904
|
+
expect(transactionStatus.ownerClaimed).toBe(true)
|
|
905
|
+
expect(transactionStatus.retirementPending).toBe(false)
|
|
907
906
|
} finally {
|
|
908
907
|
if (replacement?.controlCommandsReady) {
|
|
909
908
|
await Promise.all([
|
|
@@ -964,8 +963,8 @@ test("cross-version replacement preserves committed-owner proof until commit the
|
|
|
964
963
|
await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
|
|
965
964
|
const ownerProcess = owner.guardian?.processes.values().next().value
|
|
966
965
|
|
|
967
|
-
|
|
968
|
-
|
|
966
|
+
if (!owner.guardian) throw new Error("Missing required fixture: owner.guardian")
|
|
967
|
+
if (!ownerProcess) throw new Error("Missing required fixture: ownerProcess")
|
|
969
968
|
await owner.guardian.request({
|
|
970
969
|
command: "register",
|
|
971
970
|
definition: ownerProcess.definition,
|
|
@@ -977,7 +976,7 @@ test("cross-version replacement preserves committed-owner proof until commit the
|
|
|
977
976
|
const expectedProcessKeys = new Set(running.releases[0]?.processes.map(({id}) => `release:v1:${id}`))
|
|
978
977
|
const runningProxyPort = /** @type {{port?: number}} */ (running.proxy).port
|
|
979
978
|
|
|
980
|
-
|
|
979
|
+
expect(processState?.map(({state}) => state)).toEqual(["running", "running"])
|
|
981
980
|
if (typeof runningProxyPort !== "number") throw new Error("Retained owner proxy is missing its port")
|
|
982
981
|
retainedConnection = await openWebSocket(runningProxyPort)
|
|
983
982
|
retainedConnection.once("close", () => { retainedConnectionClosed = true })
|
|
@@ -1032,8 +1031,8 @@ test("cross-version replacement preserves committed-owner proof until commit the
|
|
|
1032
1031
|
state.recovery.guardian.socketPath = compatibilitySocketPath
|
|
1033
1032
|
await fs.writeFile(statePath, `${JSON.stringify(state)}\n`)
|
|
1034
1033
|
|
|
1035
|
-
|
|
1036
|
-
await
|
|
1034
|
+
expect((await owner.guardian?.replacementStatus())?.ownerClaimed).toBe(true)
|
|
1035
|
+
await expect(fs.access(socketPath)).rejects.toMatchObject({code: "ENOENT"})
|
|
1037
1036
|
|
|
1038
1037
|
replacement = new RollbridgeDaemon({
|
|
1039
1038
|
config: daemonConfig,
|
|
@@ -1059,21 +1058,26 @@ test("cross-version replacement preserves committed-owner proof until commit the
|
|
|
1059
1058
|
const recovered = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
1060
1059
|
const recoveredReleases = /** @type {{connectionCount: number, connections: {http: number, websocket: number}, processes: {id: string, pid?: number, state: string}[], releaseId: string, state: string}[]} */ (recovered.releases)
|
|
1061
1060
|
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1061
|
+
// Committed incumbent must observe retirement without a public control socket.
|
|
1062
|
+
expect(owner.ownerRetired).toBe(true)
|
|
1063
|
+
// Committed incumbent must stop accepting stale proxy traffic.
|
|
1064
|
+
expect(owner.proxyServer?.listening).toBe(false)
|
|
1065
|
+
expect(recovered.activeReleaseId).toBe("v1")
|
|
1066
|
+
expect(committedProcessKey).toBe(committedOwnerProcessKey)
|
|
1067
|
+
expect(recoveredKeysAtCommit?.has(committedOwnerProcessKey)).toBe(false)
|
|
1068
|
+
expect(candidateRecoveredKeys).toEqual(expectedProcessKeys)
|
|
1069
|
+
expect([...replacement.guardian?.processes.keys() || []][0]).toBe(candidateProcessKey)
|
|
1070
|
+
// Successful compatibility handoff must preserve retained connections.
|
|
1071
|
+
expect(retainedConnectionClosed).toBe(false)
|
|
1072
|
+
// Successful compatibility handoff must leave the retained listener serving.
|
|
1073
|
+
expect(retainedConnection.destroyed).toBe(false)
|
|
1074
|
+
// Candidate must inherit exact live incumbent connection counts.
|
|
1075
|
+
expect(recoveredReleases[0]?.connections).toEqual({http: 0, websocket: 1})
|
|
1076
|
+
expect(recovered.releaseReferences).toEqual([{releaseId: "v1", releasePath}])
|
|
1077
|
+
expect(recoveredReleases[0]?.processes.map(({id, pid, state}) => ({id, pid, state}))).toEqual(processState)
|
|
1074
1078
|
for (const {pid} of processState || []) {
|
|
1075
1079
|
if (typeof pid !== "number") throw new Error("Retained process is missing its PID")
|
|
1076
|
-
|
|
1080
|
+
await expect(() => process.kill(pid, 0)).not.toThrow()
|
|
1077
1081
|
}
|
|
1078
1082
|
secondRetainedConnection = await openWebSocket(runningProxyPort)
|
|
1079
1083
|
intermediate = replacement
|
|
@@ -1087,18 +1091,21 @@ test("cross-version replacement preserves committed-owner proof until commit the
|
|
|
1087
1091
|
await replacement.replaceIncompatibleOwner()
|
|
1088
1092
|
const repeatedHandoff = /** @type {{connections: {http: number, websocket: number}, releaseId: string}[]} */ ((await sendControlCommand({command: {command: "status"}, path: socketPath})).releases)
|
|
1089
1093
|
|
|
1090
|
-
|
|
1094
|
+
// Successive control-less owners must aggregate each physical listener source.
|
|
1095
|
+
expect(repeatedHandoff[0]?.connections).toEqual({http: 0, websocket: 2})
|
|
1091
1096
|
await replacement.deploy({releaseId: "v2", releasePath: nextReleasePath, revision: "v2"})
|
|
1092
1097
|
const draining = /** @type {{connectionCount: number, releaseId: string, state: string}[]} */ ((await sendControlCommand({command: {command: "status"}, path: socketPath})).releases)
|
|
1093
1098
|
const retainedRelease = draining.find(({releaseId}) => releaseId === "v1")
|
|
1094
1099
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1100
|
+
expect(retainedRelease?.state).toBe("draining")
|
|
1101
|
+
// Candidate must not stop the retained upstream while retired WebSockets are live.
|
|
1102
|
+
expect(retainedRelease?.connectionCount).toBe(2)
|
|
1097
1103
|
// Send a masked empty WebSocket close frame so both retained proxy legs drain.
|
|
1098
1104
|
retainedConnection.write(Buffer.from([0x88, 0x80, 0, 0, 0, 0]))
|
|
1099
1105
|
if (owner.proxyClosePromise) await owner.proxyClosePromise
|
|
1100
1106
|
if (!retainedConnectionClosed) await once(retainedConnection, "close")
|
|
1101
|
-
|
|
1107
|
+
// Retired incumbent must finish after its retained connections drain.
|
|
1108
|
+
expect(retainedConnectionClosed).toBe(true)
|
|
1102
1109
|
const oneSourceDeadline = Date.now() + 3000
|
|
1103
1110
|
let oneRetained
|
|
1104
1111
|
|
|
@@ -1109,8 +1116,9 @@ test("cross-version replacement preserves committed-owner proof until commit the
|
|
|
1109
1116
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
1110
1117
|
}
|
|
1111
1118
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1119
|
+
// One retired listener source must not clear another source's live connection.
|
|
1120
|
+
expect(oneRetained?.connectionCount).toBe(1)
|
|
1121
|
+
expect(oneRetained?.processes.find(({id}) => id === "web")?.state).toBe("running")
|
|
1114
1122
|
secondRetainedConnection.write(Buffer.from([0x88, 0x80, 0, 0, 0, 0]))
|
|
1115
1123
|
if (intermediate.proxyClosePromise) await intermediate.proxyClosePromise
|
|
1116
1124
|
const stopDeadline = Date.now() + 3000
|
|
@@ -1123,7 +1131,8 @@ test("cross-version replacement preserves committed-owner proof until commit the
|
|
|
1123
1131
|
retainedWebStopped = !retained || retained.processes.find(({id}) => id === "web")?.state === "stopped"
|
|
1124
1132
|
if (!retainedWebStopped) await new Promise((resolve) => setTimeout(resolve, 25))
|
|
1125
1133
|
}
|
|
1126
|
-
|
|
1134
|
+
// Retained upstream must stop after the incumbent reports its final connection drain.
|
|
1135
|
+
expect(retainedWebStopped).toBe(true)
|
|
1127
1136
|
} finally {
|
|
1128
1137
|
if (retainedGuardianSocketPath) {
|
|
1129
1138
|
const cleanupState = JSON.parse(await fs.readFile(statePath, "utf8"))
|
|
@@ -1182,9 +1191,9 @@ test("replacement refuses to overwrite an unrelated live final control socket an
|
|
|
1182
1191
|
"--daemon-pid-path", path.join(root, "daemon.pid"), "--daemon-start-timeout-ms", "1500"
|
|
1183
1192
|
])
|
|
1184
1193
|
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1194
|
+
expect(replacement.code).not.toBe(0)
|
|
1195
|
+
expect(await fs.readFile(path.join(root, "daemon.log"), "utf8")).toMatch(/final control socket.*already answers another live process/)
|
|
1196
|
+
expect((await sendControlCommand({command: {command: "status"}, path: oldSocketPath})).activeReleaseId).toBe("v1")
|
|
1188
1197
|
|
|
1189
1198
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: oldSocketPath})
|
|
1190
1199
|
await fs.writeFile(path.join(root, "v1", "worker.fifo"), "drained\n")
|
|
@@ -1229,8 +1238,8 @@ test("a committed replacement crash converges from stale public state", async ()
|
|
|
1229
1238
|
await waitForLog(recovered, "control socket listening")
|
|
1230
1239
|
const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
|
|
1231
1240
|
|
|
1232
|
-
|
|
1233
|
-
|
|
1241
|
+
expect(status.activeReleaseId).toBe("v1")
|
|
1242
|
+
expect(status.releaseReferences).toEqual([{releaseId: "v1", releasePath}])
|
|
1234
1243
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
|
|
1235
1244
|
await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
|
|
1236
1245
|
await shutdown
|
|
@@ -1266,11 +1275,11 @@ test("replacement transfers an unchanged fixed proxy listener without reusePort"
|
|
|
1266
1275
|
candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1267
1276
|
const output = await collectUntilExitOrLog(candidate, "owner replacement committed")
|
|
1268
1277
|
|
|
1269
|
-
|
|
1278
|
+
expect({value: output.message, context: output.output}).toMatchObject({value: "owner replacement committed"})
|
|
1270
1279
|
const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
|
|
1271
1280
|
const proxy = /** @type {{port: number}} */ (status.proxy)
|
|
1272
1281
|
|
|
1273
|
-
|
|
1282
|
+
expect(proxy.port).toBe(proxyPort)
|
|
1274
1283
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
|
|
1275
1284
|
await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
|
|
1276
1285
|
await shutdown
|
|
@@ -1301,7 +1310,7 @@ test("direct retired-listener drain updates reach a recovered committed owner",
|
|
|
1301
1310
|
retainedConnection = await openWebSocket(proxyPort)
|
|
1302
1311
|
candidate = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
|
|
1303
1312
|
await candidate.replaceIncompatibleOwner()
|
|
1304
|
-
|
|
1313
|
+
expect(candidate.status().releases[0]?.connectionCount).toBe(1)
|
|
1305
1314
|
|
|
1306
1315
|
candidate.incumbentListenerControl?.close()
|
|
1307
1316
|
await candidate.closeServer(candidate.controlServer)
|
|
@@ -1309,7 +1318,7 @@ test("direct retired-listener drain updates reach a recovered committed owner",
|
|
|
1309
1318
|
candidate.guardian?.disconnect()
|
|
1310
1319
|
recovered = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
|
|
1311
1320
|
await recovered.start({exposeControl: false})
|
|
1312
|
-
|
|
1321
|
+
expect(recovered.status().releases[0]?.connectionCount).toBe(1)
|
|
1313
1322
|
|
|
1314
1323
|
retainedConnection.write(Buffer.from([0x88, 0x80, 0, 0, 0, 0]))
|
|
1315
1324
|
if (owner.proxyClosePromise) await owner.proxyClosePromise
|
|
@@ -1318,7 +1327,7 @@ test("direct retired-listener drain updates reach a recovered committed owner",
|
|
|
1318
1327
|
while (Date.now() < deadline && recovered.status().releases[0]?.connectionCount !== 0) {
|
|
1319
1328
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
1320
1329
|
}
|
|
1321
|
-
|
|
1330
|
+
expect(recovered.status().releases[0]?.connectionCount).toBe(0)
|
|
1322
1331
|
} finally {
|
|
1323
1332
|
retainedConnection?.destroy()
|
|
1324
1333
|
await candidate?.closeServer(candidate.controlServer)
|
|
@@ -1337,7 +1346,7 @@ test("direct retired-listener drain updates reach a recovered committed owner",
|
|
|
1337
1346
|
}
|
|
1338
1347
|
})
|
|
1339
1348
|
|
|
1340
|
-
test("direct listener publication failure rejects the committed replacement", {
|
|
1349
|
+
test("direct listener publication failure rejects the committed replacement", {timeoutMs: 5000}, async () => {
|
|
1341
1350
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-direct-listener-failure-"))
|
|
1342
1351
|
const socketPath = path.join(root, "rollbridge.sock")
|
|
1343
1352
|
const statePath = path.join(root, "state.json")
|
|
@@ -1355,10 +1364,7 @@ test("direct listener publication failure rejects the committed replacement", {t
|
|
|
1355
1364
|
const replacement = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
|
|
1356
1365
|
|
|
1357
1366
|
candidate = replacement
|
|
1358
|
-
await
|
|
1359
|
-
() => replacement.replaceIncompatibleOwner(),
|
|
1360
|
-
/Retired listener disconnected before publishing complete connection state/
|
|
1361
|
-
)
|
|
1367
|
+
await expect(replacement.replaceIncompatibleOwner()).rejects.toThrow(/Retired listener disconnected before publishing complete connection state/)
|
|
1362
1368
|
} finally {
|
|
1363
1369
|
await candidate?.closeServer(candidate.controlServer)
|
|
1364
1370
|
await candidate?.closeServer(candidate.proxyServer)
|
|
@@ -1393,7 +1399,7 @@ test("control-less fixed-proxy bind failure preserves incumbent authority", asyn
|
|
|
1393
1399
|
|
|
1394
1400
|
candidate = replacement
|
|
1395
1401
|
replacement.startProxy = async () => { throw new Error("injected fixed proxy bind failure") }
|
|
1396
|
-
await
|
|
1402
|
+
await expect(replacement.replaceIncompatibleOwner()).rejects.toThrow(/injected fixed proxy bind failure/)
|
|
1397
1403
|
const deadline = Date.now() + 3000
|
|
1398
1404
|
|
|
1399
1405
|
while (Date.now() < deadline) {
|
|
@@ -1406,9 +1412,10 @@ test("control-less fixed-proxy bind failure preserves incumbent authority", asyn
|
|
|
1406
1412
|
}
|
|
1407
1413
|
}
|
|
1408
1414
|
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1415
|
+
// Incumbent proxy must resume after candidate bind failure.
|
|
1416
|
+
expect(resumedConnection).toBeTruthy()
|
|
1417
|
+
expect(owner.ownerRetired).toBe(false)
|
|
1418
|
+
expect(await owner.guardian?.replacementStatus()).toEqual({
|
|
1412
1419
|
committedReplacementId: null,
|
|
1413
1420
|
ownerClaimed: true,
|
|
1414
1421
|
retirementFailed: false,
|
|
@@ -1458,15 +1465,15 @@ test("owner recovery finalizes a completed control-less listener handoff after c
|
|
|
1458
1465
|
throw new Error("injected candidate exit before listener finalization")
|
|
1459
1466
|
}
|
|
1460
1467
|
}
|
|
1461
|
-
await
|
|
1468
|
+
await expect(replacement.replaceIncompatibleOwner()).rejects.toThrow(/injected candidate exit before listener finalization/)
|
|
1462
1469
|
await replacement.closeServer(replacement.controlServer)
|
|
1463
1470
|
await replacement.closeServer(replacement.proxyServer)
|
|
1464
1471
|
|
|
1465
1472
|
recovered = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
|
|
1466
1473
|
await recovered.start({exposeControl: false})
|
|
1467
1474
|
|
|
1468
|
-
|
|
1469
|
-
|
|
1475
|
+
expect(recovered.getProxyPort()).toBe(proxyPort)
|
|
1476
|
+
expect((await recovered.guardian?.replacementStatus())?.retirementPending).toBe(false)
|
|
1470
1477
|
} finally {
|
|
1471
1478
|
await candidate?.closeServer(candidate.controlServer)
|
|
1472
1479
|
await candidate?.closeServer(candidate.proxyServer)
|
|
@@ -1518,8 +1525,8 @@ test("control-less retirement clears an active source but omits a stopped releas
|
|
|
1518
1525
|
await daemon.yieldControlLessOwnerListeners("replacement")
|
|
1519
1526
|
await daemon.completeControlLessOwnerRetirement("replacement")
|
|
1520
1527
|
|
|
1521
|
-
|
|
1522
|
-
|
|
1528
|
+
expect(published).toEqual([{releaseId: "active", sourceId: daemon.listenerSourceId}])
|
|
1529
|
+
expect(disconnected).toBe(true)
|
|
1523
1530
|
} finally {
|
|
1524
1531
|
await fs.rm(root, {force: true, recursive: true})
|
|
1525
1532
|
}
|
|
@@ -1543,7 +1550,7 @@ test("owner replacement preserves committed generation metadata without firing l
|
|
|
1543
1550
|
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1544
1551
|
await waitForLog(owner, "control socket listening")
|
|
1545
1552
|
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: oldSocketPath})
|
|
1546
|
-
|
|
1553
|
+
expect(await fs.readFile(lifecycleLogPath, "utf8")).toBe("activate:v1\n")
|
|
1547
1554
|
|
|
1548
1555
|
await writeConfig(configPath, config({activationLogPath: lifecycleLogPath, controlPath: newSocketPath, extraCompanion: true, statePath}))
|
|
1549
1556
|
candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
@@ -1552,10 +1559,11 @@ test("owner replacement preserves committed generation metadata without firing l
|
|
|
1552
1559
|
const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
|
|
1553
1560
|
const generationTransition = status.generationTransition
|
|
1554
1561
|
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1562
|
+
expect(status.activeReleaseId).toBe("v1")
|
|
1563
|
+
if (!(generationTransition && typeof generationTransition === "object" && !Array.isArray(generationTransition))) throw new Error("Expected generation transition status")
|
|
1564
|
+
expect(generationTransition.phase).toBe("committed")
|
|
1565
|
+
// Owner replacement must not reactivate an already committed generation.
|
|
1566
|
+
expect(await fs.readFile(lifecycleLogPath, "utf8")).toBe("activate:v1\n")
|
|
1559
1567
|
|
|
1560
1568
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
|
|
1561
1569
|
await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
|
|
@@ -1594,23 +1602,24 @@ test("owner replacement excludes stopped retained releases from reserved process
|
|
|
1594
1602
|
const activeWorkerPid = releaseProcessPid(before, "v2", "worker")
|
|
1595
1603
|
const retained = /** @type {{releaseId: string, state: string}[]} */ (before.releases)
|
|
1596
1604
|
|
|
1597
|
-
|
|
1605
|
+
expect(retained.find(({releaseId}) => releaseId === "v1")?.state).toBe("stopped")
|
|
1598
1606
|
const persisted = JSON.parse(await fs.readFile(statePath, "utf8"))
|
|
1599
1607
|
const guardian = new GuardianClient(persisted.recovery.guardian)
|
|
1600
1608
|
|
|
1601
1609
|
await guardian.connect()
|
|
1602
|
-
|
|
1610
|
+
// Stopped release registration remains in authenticated guardian inventory.
|
|
1611
|
+
expect((await guardian.inventory()).some(({key}) => key === "release:v1:worker")).toBe(true)
|
|
1603
1612
|
guardian.disconnect()
|
|
1604
1613
|
await writeConfig(configPath, config({controlPath: newSocketPath, extraCompanion: true, statePath}))
|
|
1605
1614
|
candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1606
1615
|
const output = await collectUntilExitOrLog(candidate, "owner replacement committed")
|
|
1607
1616
|
|
|
1608
|
-
|
|
1617
|
+
expect({value: output.message, context: output.output}).toMatchObject({value: "owner replacement committed"})
|
|
1609
1618
|
const recovered = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
|
|
1610
1619
|
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1620
|
+
expect(recovered.activeReleaseId).toBe("v2")
|
|
1621
|
+
expect(releaseProcessPid(recovered, "v2", "worker")).toBe(activeWorkerPid)
|
|
1622
|
+
expect(/** @type {{releaseId: string}[]} */ (recovered.releases).some(({releaseId}) => releaseId === "v1")).toBe(false)
|
|
1614
1623
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
|
|
1615
1624
|
|
|
1616
1625
|
await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
|
|
@@ -1622,7 +1631,7 @@ test("owner replacement excludes stopped retained releases from reserved process
|
|
|
1622
1631
|
}
|
|
1623
1632
|
})
|
|
1624
1633
|
|
|
1625
|
-
test("pruned release connection completion closes the incumbent listener session", () => {
|
|
1634
|
+
test("pruned release connection completion closes the incumbent listener session", async () => {
|
|
1626
1635
|
const daemon = new RollbridgeDaemon({
|
|
1627
1636
|
config: normalizeConfig(config({controlPath: "/unused/control.sock", extraCompanion: false, statePath: "/unused/state.json"})),
|
|
1628
1637
|
logger: () => {}
|
|
@@ -1637,16 +1646,13 @@ test("pruned release connection completion closes the incumbent listener session
|
|
|
1637
1646
|
daemon.incumbentListenerControl = controlSession
|
|
1638
1647
|
daemon.handleIncumbentListenerEvent({connections: {http: 0, websocket: 0}, event: "owner-connection-state", releaseId: "pruned"}, session)
|
|
1639
1648
|
|
|
1640
|
-
|
|
1641
|
-
|
|
1649
|
+
expect(closeCount).toBe(1)
|
|
1650
|
+
expect(daemon.incumbentListenerControl).toBe(undefined)
|
|
1642
1651
|
|
|
1643
1652
|
daemon.incumbentListenerControl = controlSession
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
)
|
|
1648
|
-
assert.equal(closeCount, 1)
|
|
1649
|
-
assert.equal(daemon.incumbentListenerControl, session)
|
|
1653
|
+
await expect(() => daemon.handleIncumbentListenerEvent({connections: {http: 1, websocket: 0}, event: "owner-connection-state", releaseId: "pruned"}, session)).toThrow(/unknown release pruned/)
|
|
1654
|
+
expect(closeCount).toBe(1)
|
|
1655
|
+
expect(daemon.incumbentListenerControl).toBe(session)
|
|
1650
1656
|
})
|
|
1651
1657
|
|
|
1652
1658
|
test("same-authority owner replacement preserves completed activation compensation without replaying hooks", async () => {
|
|
@@ -1676,16 +1682,17 @@ test("same-authority owner replacement preserves completed activation compensati
|
|
|
1676
1682
|
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1677
1683
|
await waitForLog(owner, "control socket listening")
|
|
1678
1684
|
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: oldSocketPath})
|
|
1679
|
-
await
|
|
1680
|
-
|
|
1685
|
+
await expect(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: oldSocketPath})).rejects.toThrow(/activate command exited non-zero/)
|
|
1686
|
+
expect(await fs.readFile(lifecycleLogPath, "utf8")).toBe("activate:v1\nretire:v1\nretire:v2\nactivate:v1\n")
|
|
1681
1687
|
|
|
1682
1688
|
await writeConfig(configPath, failedConfig(oldSocketPath, false))
|
|
1683
1689
|
candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1684
1690
|
await waitForLog(candidate, "owner replacement committed")
|
|
1685
1691
|
const status = await sendControlCommand({command: {command: "status"}, path: oldSocketPath})
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1692
|
+
expect(status.activeReleaseId).toBe("v1")
|
|
1693
|
+
expect(status.generationTransition).toBe(undefined)
|
|
1694
|
+
// Replacement must not replay completed compensation hooks.
|
|
1695
|
+
expect(await fs.readFile(lifecycleLogPath, "utf8")).toBe("activate:v1\nretire:v1\nretire:v2\nactivate:v1\n")
|
|
1689
1696
|
|
|
1690
1697
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: oldSocketPath})
|
|
1691
1698
|
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
|
|
@@ -1725,18 +1732,18 @@ test("config-changing owner replacement proceeds after activation compensation c
|
|
|
1725
1732
|
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1726
1733
|
await waitForLog(owner, "control socket listening")
|
|
1727
1734
|
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: oldSocketPath})
|
|
1728
|
-
await
|
|
1735
|
+
await expect(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: oldSocketPath})).rejects.toThrow(/activate command exited non-zero/)
|
|
1729
1736
|
await writeConfig(configPath, failedConfig(newSocketPath, true))
|
|
1730
1737
|
|
|
1731
1738
|
candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1732
1739
|
const result = await collectUntilExitOrLog(candidate, "owner replacement committed")
|
|
1733
1740
|
|
|
1734
|
-
|
|
1741
|
+
expect({value: result.message, context: result.output}).toMatchObject({value: "owner replacement committed"})
|
|
1735
1742
|
const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
|
|
1736
1743
|
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1744
|
+
expect(status.activeReleaseId).toBe("v1")
|
|
1745
|
+
expect(status.generationTransition).toBe(undefined)
|
|
1746
|
+
expect(await fs.readFile(lifecycleLogPath, "utf8")).toBe("activate:v1\nretire:v1\nretire:v2\nactivate:v1\n")
|
|
1740
1747
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
|
|
1741
1748
|
|
|
1742
1749
|
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
|
|
@@ -1775,9 +1782,9 @@ test("owner replacement admits only the unresolved transition's exact retained c
|
|
|
1775
1782
|
ownerConfigDigest(replacementConfig)
|
|
1776
1783
|
)
|
|
1777
1784
|
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1785
|
+
expect(admitted(exactConfig)).toBe(true)
|
|
1786
|
+
expect(admitted(unrelatedConfig)).toBe(false)
|
|
1787
|
+
expect(admitted(exactConfig, "/srv/releases/wrong")).toBe(false)
|
|
1781
1788
|
})
|
|
1782
1789
|
|
|
1783
1790
|
test("owner replacement preserves accepted degraded incumbent web authority", async () => {
|
|
@@ -1813,11 +1820,11 @@ test("owner replacement preserves accepted degraded incumbent web authority", as
|
|
|
1813
1820
|
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1814
1821
|
await waitForLog(owner, "control socket listening")
|
|
1815
1822
|
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: socketPath})
|
|
1816
|
-
await
|
|
1823
|
+
await expect(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: socketPath})).rejects.toThrow(/pre-commit compensation failed.*status 26/i)
|
|
1817
1824
|
const before = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
1818
1825
|
const webPid = releaseProcessPid(before, "v1", "web")
|
|
1819
1826
|
|
|
1820
|
-
|
|
1827
|
+
expect(/** @type {{releaseId: string, state: string}[]} */ (before.releases).find(({releaseId}) => releaseId === "v2")?.state).toBe("draining")
|
|
1821
1828
|
const recovery = await sendControlCommand({command: {
|
|
1822
1829
|
acceptRetiredIncumbent: true,
|
|
1823
1830
|
command: "recover-generation-transition",
|
|
@@ -1826,23 +1833,23 @@ test("owner replacement preserves accepted degraded incumbent web authority", as
|
|
|
1826
1833
|
releasePath: v2Path,
|
|
1827
1834
|
revision: "v2"
|
|
1828
1835
|
}, path: socketPath})
|
|
1836
|
+
await waitForReleaseState(socketPath, "v2", "stopped")
|
|
1829
1837
|
const accepted = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
1830
1838
|
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1839
|
+
expect(recovery.jobsStatus).toBe("degraded")
|
|
1840
|
+
expect(/** @type {{releaseId: string, state: string}[]} */ (accepted.releases).find(({releaseId}) => releaseId === "v2")?.state).toBe("stopped")
|
|
1841
|
+
expect(/** @type {{phase?: string}} */ (accepted.generationTransition).phase).toBe("degraded_active")
|
|
1834
1842
|
replacement = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1835
1843
|
await waitForLog(replacement, "owner replacement committed")
|
|
1836
|
-
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
1837
1844
|
const recovered = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
1838
1845
|
const response = await fetch(`http://127.0.0.1:${proxyPort}/release`)
|
|
1839
1846
|
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1847
|
+
expect(recovered.activeReleaseId).toBe("v1")
|
|
1848
|
+
expect(/** @type {{phase?: string}} */ (recovered.generationTransition).phase).toBe("degraded_active")
|
|
1849
|
+
expect(releaseProcessPid(recovered, "v1", "web")).toBe(webPid)
|
|
1850
|
+
expect(response.status).toBe(200)
|
|
1851
|
+
expect((await response.text()).trim()).toBe("v1")
|
|
1852
|
+
expect(await fs.readFile(lifecycleLogPath, "utf8")).toBe("activate:v1\nretire:v1\nretire:v2\n")
|
|
1846
1853
|
} finally {
|
|
1847
1854
|
for (const child of [owner, replacement]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
|
|
1848
1855
|
await stopGuardian(statePath)
|
|
@@ -1871,8 +1878,8 @@ test("replacement publishes an unchanged control path only after incumbent retir
|
|
|
1871
1878
|
candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1872
1879
|
const output = await collectUntilExitOrLog(candidate, "owner replacement committed")
|
|
1873
1880
|
|
|
1874
|
-
|
|
1875
|
-
|
|
1881
|
+
expect({value: output.message, context: output.output}).toMatchObject({value: "owner replacement committed"})
|
|
1882
|
+
expect((await sendControlCommand({command: {command: "status"}, path: socketPath})).activeReleaseId).toBe("v1")
|
|
1876
1883
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: socketPath})
|
|
1877
1884
|
await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
|
|
1878
1885
|
await shutdown
|
|
@@ -1908,24 +1915,21 @@ test("prepared replacement fences incumbent mutations until abort", async () =>
|
|
|
1908
1915
|
await transactionClient.connect()
|
|
1909
1916
|
const prepared = await transactionClient.prepareOwnerReplacement(authority, {...authority, configDigest: "candidate-authority"})
|
|
1910
1917
|
|
|
1911
|
-
await
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
}),
|
|
1917
|
-
/replacement.*prepared|mutation.*fenced/i
|
|
1918
|
-
)
|
|
1918
|
+
await expect(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: socketPath})
|
|
1919
|
+
.then((response) => {
|
|
1920
|
+
v2Started = true
|
|
1921
|
+
return response
|
|
1922
|
+
})).rejects.toThrow(/replacement.*prepared|mutation.*fenced/i)
|
|
1919
1923
|
const retained = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
1920
|
-
|
|
1921
|
-
|
|
1924
|
+
expect(retained.activeReleaseId).toBe("v1")
|
|
1925
|
+
expect(retained.releaseReferences).toEqual([{releaseId: "v1", releasePath: v1Path}])
|
|
1922
1926
|
await transactionClient.abortOwnerReplacement(prepared.replacementId)
|
|
1923
1927
|
await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: socketPath})
|
|
1924
1928
|
v2Started = true
|
|
1925
1929
|
const afterAbort = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
1926
1930
|
|
|
1927
|
-
|
|
1928
|
-
|
|
1931
|
+
expect(afterAbort.activeReleaseId).toBe("v2")
|
|
1932
|
+
expect(afterAbort.releaseReferences).toEqual([
|
|
1929
1933
|
{releaseId: "v1", releasePath: v1Path},
|
|
1930
1934
|
{releaseId: "v2", releasePath: v2Path}
|
|
1931
1935
|
])
|
|
@@ -2008,8 +2012,8 @@ async function prepareCandidatePackage(destination, options = {}) {
|
|
|
2008
2012
|
const sessionMarker = " this.socket.write(`${JSON.stringify(command)}\\n`)\n return await response\n"
|
|
2009
2013
|
const sessionInjection = " this.socket.write(`${JSON.stringify(command)}\\n`)\n const result = await response\n if (command.command === \"commit-owner-replacement\") throw new Error(\"injected lost commit response\")\n return result\n"
|
|
2010
2014
|
|
|
2011
|
-
|
|
2012
|
-
|
|
2015
|
+
expect(source.includes(marker)).toBeTruthy()
|
|
2016
|
+
expect(source.includes(sessionMarker)).toBeTruthy()
|
|
2013
2017
|
await fs.writeFile(clientPath, source.replace(marker, injected).replace(sessionMarker, sessionInjection))
|
|
2014
2018
|
}
|
|
2015
2019
|
}
|
|
@@ -2033,15 +2037,15 @@ async function removeDaemonRecoveryCapability(packagePath, {abortedPath, prepare
|
|
|
2033
2037
|
|
|
2034
2038
|
`
|
|
2035
2039
|
|
|
2036
|
-
|
|
2037
|
-
|
|
2040
|
+
expect(source.includes(capability)).toBeTruthy()
|
|
2041
|
+
expect(source.includes(incumbentAbortNotification)).toBeTruthy()
|
|
2038
2042
|
await fs.writeFile(guardianPath, source.replace(capability, legacyCapability))
|
|
2039
2043
|
const daemonSource = await fs.readFile(daemonPath, "utf8")
|
|
2040
2044
|
const preparedHandler = " this.guardian.onEvent(\"replacement-prepared\", () => {\n for (const release of this.releases.values()) release.pauseDrainForOwnerHandoff()\n"
|
|
2041
2045
|
const abortedHandler = " this.guardian.onEvent(\"replacement-aborted\", () => {\n"
|
|
2042
2046
|
|
|
2043
|
-
|
|
2044
|
-
|
|
2047
|
+
expect(daemonSource.includes(preparedHandler)).toBeTruthy()
|
|
2048
|
+
expect(daemonSource.includes(abortedHandler)).toBeTruthy()
|
|
2045
2049
|
await fs.writeFile(daemonPath, daemonSource
|
|
2046
2050
|
.replace(preparedHandler, `${preparedHandler} void fs.writeFile(${JSON.stringify(preparedPath)}, "paused\\n")\n`)
|
|
2047
2051
|
.replace(abortedHandler, `${abortedHandler} void fs.writeFile(${JSON.stringify(abortedPath)}, "aborted\\n")\n`))
|
|
@@ -2108,7 +2112,7 @@ async function startPartialGuardian({backendPath, mode, socketPath, token}) {
|
|
|
2108
2112
|
})
|
|
2109
2113
|
})
|
|
2110
2114
|
if (child.connected) await once(child, "disconnect")
|
|
2111
|
-
|
|
2115
|
+
if (!child.pid) throw new Error("Missing required fixture: child.pid")
|
|
2112
2116
|
return child
|
|
2113
2117
|
}
|
|
2114
2118
|
|
|
@@ -2138,7 +2142,7 @@ async function closeServer(server) {
|
|
|
2138
2142
|
*/
|
|
2139
2143
|
async function makeFifo(fifoPath) {
|
|
2140
2144
|
const child = spawn("mkfifo", [fifoPath])
|
|
2141
|
-
|
|
2145
|
+
expect((await once(child, "exit"))[0]).toBe(0)
|
|
2142
2146
|
}
|
|
2143
2147
|
|
|
2144
2148
|
/**
|
|
@@ -2224,7 +2228,7 @@ async function run(command, args) {
|
|
|
2224
2228
|
child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk })
|
|
2225
2229
|
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk })
|
|
2226
2230
|
const [code] = await once(child, "exit")
|
|
2227
|
-
|
|
2231
|
+
if (!child.pid) throw new Error("Missing required fixture: child.pid")
|
|
2228
2232
|
return {code, pid: child.pid, stderr, stdout}
|
|
2229
2233
|
}
|
|
2230
2234
|
|
|
@@ -2234,8 +2238,8 @@ async function run(command, args) {
|
|
|
2234
2238
|
* @returns {Promise<void>} Resolves after the matching log event.
|
|
2235
2239
|
*/
|
|
2236
2240
|
async function waitForLog(child, message) {
|
|
2237
|
-
|
|
2238
|
-
|
|
2241
|
+
if (!child.stdout) throw new Error("Missing required fixture: child.stdout")
|
|
2242
|
+
if (!child.stderr) throw new Error("Missing required fixture: child.stderr")
|
|
2239
2243
|
child.stdout.setEncoding("utf8")
|
|
2240
2244
|
child.stderr.setEncoding("utf8")
|
|
2241
2245
|
await new Promise((resolve, reject) => {
|
|
@@ -2270,8 +2274,8 @@ async function waitForLog(child, message) {
|
|
|
2270
2274
|
* @returns {Promise<{message?: string, output: string}>} Exit output or matched message.
|
|
2271
2275
|
*/
|
|
2272
2276
|
async function collectUntilExitOrLog(child, message) {
|
|
2273
|
-
|
|
2274
|
-
|
|
2277
|
+
if (!child.stdout) throw new Error("Missing required fixture: child.stdout")
|
|
2278
|
+
if (!child.stderr) throw new Error("Missing required fixture: child.stderr")
|
|
2275
2279
|
const stdout = child.stdout
|
|
2276
2280
|
const stderr = child.stderr
|
|
2277
2281
|
|
|
@@ -2325,7 +2329,7 @@ async function openWebSocket(port) {
|
|
|
2325
2329
|
].join("\r\n"))
|
|
2326
2330
|
const [response] = await once(socket, "data")
|
|
2327
2331
|
|
|
2328
|
-
|
|
2332
|
+
expect(String(response)).toMatch(/^HTTP\/1\.1 101 /)
|
|
2329
2333
|
return socket
|
|
2330
2334
|
}
|
|
2331
2335
|
|
|
@@ -2372,3 +2376,4 @@ async function stopGuardian(statePath) {
|
|
|
2372
2376
|
if (!error || typeof error !== "object" || !("code" in error) || !["ENOENT", "ESRCH"].includes(String(error.code))) throw error
|
|
2373
2377
|
}
|
|
2374
2378
|
}
|
|
2379
|
+
})
|