rollbridge 0.1.29 → 0.1.31
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/package.json +1 -1
- package/src/daemon.js +61 -32
- package/src/guardian-client.js +10 -0
- package/src/process-guardian.js +45 -0
- package/test/guardian-client.test.js +31 -0
- package/test/owner-recovery.test.js +94 -2
- package/test/owner-replacement.test.js +46 -1
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -300,6 +300,7 @@ export default class RollbridgeDaemon {
|
|
|
300
300
|
let stagingControlPath
|
|
301
301
|
let finalControlPublished = false
|
|
302
302
|
let listenersYielded = false
|
|
303
|
+
let retiredIncumbentControl = false
|
|
303
304
|
let retainIncumbentControl = false
|
|
304
305
|
let incumbentControl = legacyBridge?.incumbentControl
|
|
305
306
|
|
|
@@ -325,22 +326,31 @@ export default class RollbridgeDaemon {
|
|
|
325
326
|
if (legacyBridge) {
|
|
326
327
|
await this.crossLegacyDisruptiveBoundary(legacyBridge)
|
|
327
328
|
} else if (preparedStatus.ownerClaimed) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
329
|
+
try {
|
|
330
|
+
incumbentControl = await openControlSession(transfer.snapshot.control.path)
|
|
331
|
+
} catch (error) {
|
|
332
|
+
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
|
|
333
|
+
retiredIncumbentControl = true
|
|
334
|
+
}
|
|
335
|
+
if (incumbentControl) {
|
|
336
|
+
const listenerSession = incumbentControl
|
|
337
|
+
|
|
338
|
+
incumbentControl.onEvent((event) => this.handleIncumbentListenerEvent(event, listenerSession))
|
|
339
|
+
await incumbentControl.request({
|
|
340
|
+
command: "yield-owner-listeners",
|
|
341
|
+
control: transfer.snapshot.control.path === this.config.control.path,
|
|
342
|
+
proxy: true,
|
|
343
|
+
replacementId: prepared.replacementId
|
|
344
|
+
})
|
|
345
|
+
listenersYielded = true
|
|
346
|
+
}
|
|
339
347
|
}
|
|
340
348
|
if (sharedFixedProxy) await this.startProxy()
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
349
|
+
if (!retiredIncumbentControl) {
|
|
350
|
+
await fs.rename(stagingControlPath, this.config.control.path)
|
|
351
|
+
finalControlPublished = true
|
|
352
|
+
this.boundControlPath = this.config.control.path
|
|
353
|
+
}
|
|
344
354
|
const committed = this.guardian.waitForEvent("replacement-committed")
|
|
345
355
|
const staged = await this.guardian.stageOwnerReplacement(prepared.replacementId, {
|
|
346
356
|
authority: this.ownerAuthority(),
|
|
@@ -350,18 +360,27 @@ export default class RollbridgeDaemon {
|
|
|
350
360
|
})
|
|
351
361
|
|
|
352
362
|
if (!staged.committed) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
363
|
+
if (retiredIncumbentControl) {
|
|
364
|
+
await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId)
|
|
365
|
+
} else {
|
|
366
|
+
try {
|
|
367
|
+
if (!incumbentControl) throw new Error("Owner replacement incumbent control session is unavailable")
|
|
368
|
+
await incumbentControl.request({command: "commit-owner-replacement", replacementId: prepared.replacementId})
|
|
369
|
+
} catch (error) {
|
|
370
|
+
const status = await this.guardian.replacementStatus()
|
|
371
|
+
|
|
372
|
+
if (status.committedReplacementId !== prepared.replacementId || !status.ownerClaimed) throw error
|
|
373
|
+
this.logger("owner replacement commit response lost; guardian commit confirmed", {replacementId: prepared.replacementId})
|
|
374
|
+
}
|
|
361
375
|
}
|
|
362
376
|
}
|
|
363
377
|
await committed
|
|
364
378
|
committedAuthority = true
|
|
379
|
+
if (retiredIncumbentControl) {
|
|
380
|
+
await fs.rename(stagingControlPath, this.config.control.path)
|
|
381
|
+
finalControlPublished = true
|
|
382
|
+
this.boundControlPath = this.config.control.path
|
|
383
|
+
}
|
|
365
384
|
if (!legacyBridge && incumbentControl && [...this.releases.values()].some((release) => release.hasTransferredConnections())) {
|
|
366
385
|
this.incumbentListenerControl = incumbentControl
|
|
367
386
|
retainIncumbentControl = true
|
|
@@ -1437,11 +1456,11 @@ export default class RollbridgeDaemon {
|
|
|
1437
1456
|
/**
|
|
1438
1457
|
* Persists a state snapshot (status plus recent events) to statePath, atomically and
|
|
1439
1458
|
* fire-and-forget unless the caller awaits the returned write. A failed write is logged.
|
|
1440
|
-
* @param {{throwOnError?: boolean}} [options] -
|
|
1459
|
+
* @param {{allowStopping?: boolean, throwOnError?: boolean}} [options] - Write behavior.
|
|
1441
1460
|
* @returns {Promise<void> | undefined} The queued write, or undefined when persistence is disabled.
|
|
1442
1461
|
*/
|
|
1443
|
-
persistState({throwOnError = false} = {}) {
|
|
1444
|
-
if (!this.statePath || !this.persistenceEnabled || this.stopping) return
|
|
1462
|
+
persistState({allowStopping = false, throwOnError = false} = {}) {
|
|
1463
|
+
if (!this.statePath || !this.persistenceEnabled || (this.stopping && !allowStopping)) return
|
|
1445
1464
|
|
|
1446
1465
|
const statePath = this.statePath
|
|
1447
1466
|
const status = /** @type {Record<string, JsonValue>} */ (secretSafeStateValue(this.status()))
|
|
@@ -1533,25 +1552,35 @@ export default class RollbridgeDaemon {
|
|
|
1533
1552
|
clearInterval(this.persistTimer)
|
|
1534
1553
|
this.persistTimer = undefined
|
|
1535
1554
|
}
|
|
1536
|
-
this.persistenceEnabled = false
|
|
1537
1555
|
if (this.pendingWrite) await this.pendingWrite
|
|
1538
1556
|
this.stateCleanupEnabled = false
|
|
1539
1557
|
this.controlClosePromise = this.closeServer(this.controlServer)
|
|
1540
1558
|
for (const socket of this.controlSockets) if (socket !== completionSocket) socket.destroy()
|
|
1559
|
+
if (this.activeRelease) {
|
|
1560
|
+
await this.activeRelease.beginRetirement(this.activeRelease.config)
|
|
1561
|
+
this.activeRelease = undefined
|
|
1562
|
+
}
|
|
1541
1563
|
await Promise.all([
|
|
1542
1564
|
...[...this.services.values()].map((processInstance) => processInstance.quiesce()),
|
|
1543
1565
|
...[...this.singletons.values()].map((processInstance) => processInstance.quiesce()),
|
|
1544
1566
|
...[...this.startingReleases].map((release) => release.quiesce()),
|
|
1545
1567
|
...[...this.releases.values()].map((release) => release.quiesce())
|
|
1546
1568
|
])
|
|
1569
|
+
await this.persistState({allowStopping: true, throwOnError: true})
|
|
1570
|
+
this.persistenceEnabled = false
|
|
1547
1571
|
await this.removeControlSocket()
|
|
1548
1572
|
void this.closeServer(this.proxyServer)
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1573
|
+
if (this.guardian) {
|
|
1574
|
+
await this.guardian.retireOwner()
|
|
1575
|
+
this.guardian.disconnect()
|
|
1576
|
+
} else {
|
|
1577
|
+
void Promise.allSettled([
|
|
1578
|
+
...[...this.services.values()].map((processInstance) => processInstance.stop()),
|
|
1579
|
+
...[...this.singletons.values()].map((processInstance) => processInstance.stop()),
|
|
1580
|
+
...[...this.startingReleases].map((release) => release.stop()),
|
|
1581
|
+
...[...this.releases.values()].map((release) => release.stop())
|
|
1582
|
+
])
|
|
1583
|
+
}
|
|
1555
1584
|
this.logger("external owner retired", {attestation, status: "draining"})
|
|
1556
1585
|
}
|
|
1557
1586
|
|
package/src/guardian-client.js
CHANGED
|
@@ -156,6 +156,11 @@ export default class GuardianClient {
|
|
|
156
156
|
await this.request({authority, command: "claim-owner", graceMs})
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/** Starts graceful process retirement and relinquishes committed owner authority. */
|
|
160
|
+
async retireOwner() {
|
|
161
|
+
await this.request({command: "retire-owner"})
|
|
162
|
+
}
|
|
163
|
+
|
|
159
164
|
/** @param {import("./json.js").JsonValue} ownerState - Private transferable owner state. */
|
|
160
165
|
async publishOwnerState(ownerState) {
|
|
161
166
|
await this.request({command: "publish-owner-state", ownerState})
|
|
@@ -189,6 +194,11 @@ export default class GuardianClient {
|
|
|
189
194
|
await this.request({command: "commit-owner-replacement", replacementId})
|
|
190
195
|
}
|
|
191
196
|
|
|
197
|
+
/** @param {string} replacementId - Same-authority transaction whose incumbent listener is absent. */
|
|
198
|
+
async commitRetiredOwnerReplacement(replacementId) {
|
|
199
|
+
await this.request({command: "commit-retired-owner-replacement", replacementId})
|
|
200
|
+
}
|
|
201
|
+
|
|
192
202
|
/** @param {string} replacementId - Committed transaction awaiting incumbent retirement. */
|
|
193
203
|
async finalizeOwnerReplacement(replacementId) {
|
|
194
204
|
await this.request({command: "finalize-owner-replacement", replacementId})
|
package/src/process-guardian.js
CHANGED
|
@@ -200,6 +200,19 @@ async function execute(request, socket) {
|
|
|
200
200
|
})
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
if (request.command === "retire-owner") {
|
|
204
|
+
requireOwner(socket, request.command)
|
|
205
|
+
if (replacementClient) throw new Error("Committed owner cannot retire while an owner replacement is prepared")
|
|
206
|
+
for (const entry of processes.values()) entry.desired = false
|
|
207
|
+
void Promise.allSettled([...processes.values()].map((entry) => entry.process.stop()))
|
|
208
|
+
ownerClient = undefined
|
|
209
|
+
ownerMutationClient = undefined
|
|
210
|
+
ownerMutationId = undefined
|
|
211
|
+
ownerRevision += 1
|
|
212
|
+
grantNextOwner()
|
|
213
|
+
return {retired: true}
|
|
214
|
+
}
|
|
215
|
+
|
|
203
216
|
if (request.command === "abandon-legacy-upgrade") {
|
|
204
217
|
if (!legacyGuardian) throw new Error("Guardian is not a legacy upgrade coordinator")
|
|
205
218
|
if (ownerClient || committedReplacementId) throw new Error("Committed guardian authority cannot abandon its legacy backend")
|
|
@@ -284,6 +297,23 @@ async function execute(request, socket) {
|
|
|
284
297
|
return {aborted: true}
|
|
285
298
|
}
|
|
286
299
|
|
|
300
|
+
if (request.command === "commit-retired-owner-replacement") {
|
|
301
|
+
requireReplacement(socket, request)
|
|
302
|
+
if (!replacementOwnerState) throw new Error("Retired owner replacement transaction is not staged")
|
|
303
|
+
if (!isDeepStrictEqual(ownerAuthority(ownerState), replacementAuthority)) throw new Error("Retired owner replacement requires unchanged owner authority")
|
|
304
|
+
const controlPath = ownerControlPath(ownerState)
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
await fs.lstat(controlPath)
|
|
308
|
+
throw new Error(`Retired owner control socket ${controlPath} still exists`)
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
|
|
311
|
+
}
|
|
312
|
+
commitReplacement()
|
|
313
|
+
finalizeReplacementRetirement()
|
|
314
|
+
return {committed: true}
|
|
315
|
+
}
|
|
316
|
+
|
|
287
317
|
if (request.command === "commit-owner-replacement") {
|
|
288
318
|
requireOwner(socket, request.command)
|
|
289
319
|
if (!replacementClient || request.replacementId !== replacementId || !replacementOwnerState) throw new Error("Owner replacement transaction is not the prepared ready candidate")
|
|
@@ -497,6 +527,21 @@ function ownerAuthority(state) {
|
|
|
497
527
|
return state.authority
|
|
498
528
|
}
|
|
499
529
|
|
|
530
|
+
/**
|
|
531
|
+
* @param {import("./json.js").JsonValue | undefined} state - Committed transferable state.
|
|
532
|
+
* @returns {string} Exact incumbent public control path.
|
|
533
|
+
*/
|
|
534
|
+
function ownerControlPath(state) {
|
|
535
|
+
if (!state || typeof state !== "object" || Array.isArray(state) || !("snapshot" in state)) throw new Error("Guardian owner state is missing its committed snapshot")
|
|
536
|
+
const snapshot = state.snapshot
|
|
537
|
+
|
|
538
|
+
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot) || !("control" in snapshot)) throw new Error("Guardian owner snapshot is missing its control identity")
|
|
539
|
+
const control = snapshot.control
|
|
540
|
+
|
|
541
|
+
if (!control || typeof control !== "object" || Array.isArray(control) || !("path" in control) || typeof control.path !== "string") throw new Error("Guardian owner snapshot has an invalid control identity")
|
|
542
|
+
return control.path
|
|
543
|
+
}
|
|
544
|
+
|
|
500
545
|
/**
|
|
501
546
|
* Stops accepting connections and closes every authority channel except the response caller.
|
|
502
547
|
* @param {net.Socket} caller - Shutdown requester retained until it receives the response.
|
|
@@ -171,6 +171,37 @@ test("replacement staging rejects owner state published after prepare", async ()
|
|
|
171
171
|
}
|
|
172
172
|
})
|
|
173
173
|
|
|
174
|
+
test("retired owner replacement requires unchanged authority and the exact control path absent", async () => {
|
|
175
|
+
const fixture = await createGuardian()
|
|
176
|
+
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
177
|
+
const controlPath = path.join(fixture.root, "rollbridge.sock")
|
|
178
|
+
const authority = {configDigest: "incumbent", runtime: null}
|
|
179
|
+
const nextAuthority = {configDigest: "candidate", runtime: null}
|
|
180
|
+
const snapshot = {activeReleaseId: "v1", control: {path: controlPath}}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
await fixture.client.publishOwnerState({authority, snapshot})
|
|
184
|
+
await candidate.connect()
|
|
185
|
+
const changed = await candidate.prepareOwnerReplacement(authority, nextAuthority)
|
|
186
|
+
|
|
187
|
+
await candidate.stageOwnerReplacement(changed.replacementId, {authority: nextAuthority, snapshot})
|
|
188
|
+
await assert.rejects(() => candidate.commitRetiredOwnerReplacement(changed.replacementId), /unchanged owner authority/)
|
|
189
|
+
await candidate.abortOwnerReplacement(changed.replacementId)
|
|
190
|
+
|
|
191
|
+
const occupied = await candidate.prepareOwnerReplacement(authority, authority)
|
|
192
|
+
|
|
193
|
+
await candidate.stageOwnerReplacement(occupied.replacementId, {authority, snapshot})
|
|
194
|
+
await fs.writeFile(controlPath, "occupied\n")
|
|
195
|
+
await assert.rejects(() => candidate.commitRetiredOwnerReplacement(occupied.replacementId), /control socket .* still exists/)
|
|
196
|
+
await candidate.abortOwnerReplacement(occupied.replacementId)
|
|
197
|
+
await fixture.client.shutdown()
|
|
198
|
+
await fixture.client.guardianExit()
|
|
199
|
+
} finally {
|
|
200
|
+
candidate.disconnect()
|
|
201
|
+
await cleanupGuardian(fixture)
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
|
|
174
205
|
test("first upgrade migrates a real pre-split guardian without replacing its owned process", async () => {
|
|
175
206
|
const fixture = await createLegacyGuardian()
|
|
176
207
|
const processDefinition = definition("legacy-worker")
|
|
@@ -9,6 +9,7 @@ import os from "node:os"
|
|
|
9
9
|
import path from "node:path"
|
|
10
10
|
import test from "node:test"
|
|
11
11
|
import {fileURLToPath} from "node:url"
|
|
12
|
+
import {normalizeConfig} from "../src/config.js"
|
|
12
13
|
import {sendControlCommand} from "../src/control-client.js"
|
|
13
14
|
import RollbridgeDaemon from "../src/daemon.js"
|
|
14
15
|
import GuardianClient from "../src/guardian-client.js"
|
|
@@ -21,6 +22,79 @@ const serviceAppPath = path.join(currentDir, "fixtures", "service-app.js")
|
|
|
21
22
|
/** @typedef {import("../src/daemon.js").DaemonStatus} DaemonStatus */
|
|
22
23
|
/** @typedef {DaemonStatus & {recovery: {configDigest: string}}} RecoveryState */
|
|
23
24
|
|
|
25
|
+
test("external owner retirement releases guardian authority without losing its generation", async () => {
|
|
26
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-retirement-recovery-"))
|
|
27
|
+
const socketPath = path.join(root, "rollbridge.sock")
|
|
28
|
+
const statePath = path.join(root, "rollbridge.state.json")
|
|
29
|
+
const v1Path = path.join(root, "v1")
|
|
30
|
+
const v2Path = path.join(root, "v2")
|
|
31
|
+
const config = normalizeConfig({
|
|
32
|
+
application: "owner-retirement-recovery-test",
|
|
33
|
+
control: {path: socketPath},
|
|
34
|
+
ownerRecovery: {reconnectGraceMs: 50},
|
|
35
|
+
processes: [
|
|
36
|
+
{
|
|
37
|
+
command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
|
|
38
|
+
id: "worker",
|
|
39
|
+
lifecycle: {drainCommand: "printf started > \"$ROLLBRIDGE_RELEASE_PATH/drain-started\"; while [ ! -f \"$ROLLBRIDGE_RELEASE_PATH/drained\" ]; do sleep 0.01; done", drainTimeoutMs: 3000},
|
|
40
|
+
nonBlockingDrain: true,
|
|
41
|
+
policy: "companion"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
|
|
45
|
+
health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
|
|
46
|
+
id: "web",
|
|
47
|
+
policy: "proxied",
|
|
48
|
+
port: {from: 0, to: 0}
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
proxy: {forceStopTimeoutMs: 500, healthPath: "/ping", healthTimeoutMs: 3000, host: "127.0.0.1", port: 0},
|
|
52
|
+
statePath
|
|
53
|
+
})
|
|
54
|
+
const retired = new RollbridgeDaemon({config, logger: () => {}})
|
|
55
|
+
let replacement
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
|
|
59
|
+
await retired.start()
|
|
60
|
+
await retired.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
|
|
61
|
+
const before = retired.status()
|
|
62
|
+
const v1WorkerPid = releaseProcessPid(before, "v1", "worker")
|
|
63
|
+
|
|
64
|
+
replacement = new RollbridgeDaemon({config, logger: () => {}})
|
|
65
|
+
await replacement.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
|
|
66
|
+
const v2WorkerPid = releaseProcessPid(replacement.status(), "v2", "worker")
|
|
67
|
+
|
|
68
|
+
await retired.retireOwner({attestation: `sha256:${"a".repeat(64)}`})
|
|
69
|
+
await replacement.start({reportOrphans: false})
|
|
70
|
+
const recovered = replacement.status()
|
|
71
|
+
const v1 = recovered.releases.find(({releaseId}) => releaseId === "v1")
|
|
72
|
+
const v2 = recovered.releases.find(({releaseId}) => releaseId === "v2")
|
|
73
|
+
|
|
74
|
+
assert.equal(recovered.activeReleaseId, "v2", "the prestarted candidate must remain active")
|
|
75
|
+
assert.deepEqual(recovered.releaseReferences.sort((a, b) => a.releaseId.localeCompare(b.releaseId)), [
|
|
76
|
+
{releaseId: "v1", releasePath: v1Path},
|
|
77
|
+
{releaseId: "v2", releasePath: v2Path}
|
|
78
|
+
])
|
|
79
|
+
assert.equal(v1?.state, "draining")
|
|
80
|
+
assert.equal(v1?.processes.find(({id}) => id === "worker")?.pid, v1WorkerPid)
|
|
81
|
+
assert.equal(v1?.processes.find(({id}) => id === "worker")?.state, "quiesced")
|
|
82
|
+
assert.equal(v2?.state, "active")
|
|
83
|
+
assert.equal(v2?.processes.find(({id}) => id === "worker")?.pid, v2WorkerPid)
|
|
84
|
+
assert.equal(v2?.processes.find(({id}) => id === "worker")?.state, "running")
|
|
85
|
+
await waitForFile(path.join(v1Path, "drain-started"), 1000)
|
|
86
|
+
await fs.writeFile(path.join(v1Path, "drained"), "done\n")
|
|
87
|
+
await waitForProcessExit(v1WorkerPid, 1000)
|
|
88
|
+
assert.equal(isAlive(v2WorkerPid), true, "the active candidate worker must remain usable while the old generation drains")
|
|
89
|
+
} finally {
|
|
90
|
+
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "drained"), "done\n").catch(() => {})))
|
|
91
|
+
await replacement?.shutdown().catch(() => {})
|
|
92
|
+
retired.guardian?.disconnect()
|
|
93
|
+
await stopFixtureGuardian(statePath)
|
|
94
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
24
98
|
test("replacement owner reconstructs one active and two draining generations after abrupt daemon exit", async () => {
|
|
25
99
|
const fixture = await createFixture()
|
|
26
100
|
let owner = spawnDaemon(fixture.configPath)
|
|
@@ -687,16 +761,19 @@ async function waitForState(statePath, predicate) {
|
|
|
687
761
|
|
|
688
762
|
/**
|
|
689
763
|
* @param {string} filePath - File whose creation is the transaction-boundary signal.
|
|
764
|
+
* @param {number} [timeoutMs] - Optional bounded wait.
|
|
690
765
|
* @returns {Promise<void>} Resolves when the file exists.
|
|
691
766
|
*/
|
|
692
|
-
async function waitForFile(filePath) {
|
|
767
|
+
async function waitForFile(filePath, timeoutMs) {
|
|
693
768
|
try {
|
|
694
769
|
await fs.access(filePath)
|
|
695
770
|
return
|
|
696
771
|
} catch (error) {
|
|
697
772
|
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
|
|
698
773
|
}
|
|
699
|
-
const
|
|
774
|
+
const controller = timeoutMs === undefined ? undefined : new AbortController()
|
|
775
|
+
const timer = timeoutMs === undefined ? undefined : setTimeout(() => controller?.abort(), timeoutMs)
|
|
776
|
+
const watcher = fs.watch(path.dirname(filePath), {signal: controller?.signal})
|
|
700
777
|
|
|
701
778
|
try {
|
|
702
779
|
for await (const change of watcher) {
|
|
@@ -708,11 +785,26 @@ async function waitForFile(filePath) {
|
|
|
708
785
|
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
|
|
709
786
|
}
|
|
710
787
|
}
|
|
788
|
+
} catch (error) {
|
|
789
|
+
if (error && typeof error === "object" && "name" in error && error.name === "AbortError") throw new Error(`Timed out waiting for ${filePath}`, {cause: error})
|
|
790
|
+
throw error
|
|
711
791
|
} finally {
|
|
792
|
+
clearTimeout(timer)
|
|
712
793
|
await watcher.return?.()
|
|
713
794
|
}
|
|
714
795
|
}
|
|
715
796
|
|
|
797
|
+
/**
|
|
798
|
+
* @param {number} pid - Exact fixture process.
|
|
799
|
+
* @param {number} timeoutMs - Bounded exit wait.
|
|
800
|
+
*/
|
|
801
|
+
async function waitForProcessExit(pid, timeoutMs) {
|
|
802
|
+
const deadline = Date.now() + timeoutMs
|
|
803
|
+
|
|
804
|
+
while (isAlive(pid) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10))
|
|
805
|
+
assert.equal(isAlive(pid), false, `process ${pid} did not exit within ${timeoutMs}ms`)
|
|
806
|
+
}
|
|
807
|
+
|
|
716
808
|
/**
|
|
717
809
|
* Opens a live WebSocket through the fixture proxy.
|
|
718
810
|
* @param {number} port - Proxy port.
|
|
@@ -9,8 +9,9 @@ import os from "node:os"
|
|
|
9
9
|
import path from "node:path"
|
|
10
10
|
import test from "node:test"
|
|
11
11
|
import {fileURLToPath} from "node:url"
|
|
12
|
+
import {normalizeConfig} from "../src/config.js"
|
|
12
13
|
import {sendControlCommand} from "../src/control-client.js"
|
|
13
|
-
import {isLegacyGuardianPrepareDiagnostic} from "../src/daemon.js"
|
|
14
|
+
import RollbridgeDaemon, {isLegacyGuardianPrepareDiagnostic} from "../src/daemon.js"
|
|
14
15
|
import GuardianClient from "../src/guardian-client.js"
|
|
15
16
|
import {findAvailablePort} from "../src/port-allocator.js"
|
|
16
17
|
|
|
@@ -248,6 +249,50 @@ test("ensure-daemon atomically replaces incompatible config, socket, and package
|
|
|
248
249
|
}
|
|
249
250
|
})
|
|
250
251
|
|
|
252
|
+
test("same-authority replacement commits after a retired incumbent already removed its control socket", async () => {
|
|
253
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-retired-control-"))
|
|
254
|
+
const socketPath = path.join(root, "rollbridge.sock")
|
|
255
|
+
const statePath = path.join(root, "state.json")
|
|
256
|
+
const releasePath = path.join(root, "v1")
|
|
257
|
+
const daemonConfig = normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath}))
|
|
258
|
+
const owner = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
|
|
259
|
+
let replacement
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
await fs.mkdir(releasePath)
|
|
263
|
+
await makeFifo(path.join(releasePath, "worker.fifo"))
|
|
264
|
+
await owner.start()
|
|
265
|
+
await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
|
|
266
|
+
await Promise.all([...owner.releases.values()].map((release) => release.quiesce()))
|
|
267
|
+
await owner.closeServer(owner.controlServer)
|
|
268
|
+
await owner.removeControlSocket()
|
|
269
|
+
await owner.closeServer(owner.proxyServer)
|
|
270
|
+
const retired = owner.status()
|
|
271
|
+
const processState = retired.releases[0]?.processes.map(({id, pid, state}) => ({id, pid, state}))
|
|
272
|
+
|
|
273
|
+
assert.deepEqual(processState?.map(({state}) => state), ["quiesced", "quiesced"])
|
|
274
|
+
assert.equal((await owner.guardian?.replacementStatus())?.ownerClaimed, true)
|
|
275
|
+
await assert.rejects(fs.access(socketPath), {code: "ENOENT"})
|
|
276
|
+
|
|
277
|
+
replacement = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
|
|
278
|
+
await replacement.replaceIncompatibleOwner()
|
|
279
|
+
const recovered = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
280
|
+
const recoveredReleases = /** @type {{processes: {id: string, pid?: number, state: string}[]}[]} */ (recovered.releases)
|
|
281
|
+
|
|
282
|
+
assert.equal(recovered.activeReleaseId, "v1")
|
|
283
|
+
assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath}])
|
|
284
|
+
assert.deepEqual(recoveredReleases[0]?.processes.map(({id, pid, state}) => ({id, pid, state})), processState)
|
|
285
|
+
} finally {
|
|
286
|
+
const shutdown = replacement?.controlCommandsReady ? replacement.shutdown().catch(() => {}) : owner.shutdown().catch(() => {})
|
|
287
|
+
|
|
288
|
+
await Promise.all([fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => {}), shutdown])
|
|
289
|
+
owner.guardian?.disconnect()
|
|
290
|
+
replacement?.guardian?.disconnect()
|
|
291
|
+
await stopGuardian(statePath)
|
|
292
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
|
|
251
296
|
test("replacement refuses to overwrite an unrelated live final control socket and preserves the owner", async () => {
|
|
252
297
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-fence-"))
|
|
253
298
|
const oldSocketPath = path.join(root, "old.sock")
|