rollbridge 0.1.30 → 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 +41 -22
- package/src/guardian-client.js +5 -0
- package/src/process-guardian.js +32 -0
- package/test/guardian-client.test.js +31 -0
- 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
|
package/src/guardian-client.js
CHANGED
|
@@ -194,6 +194,11 @@ export default class GuardianClient {
|
|
|
194
194
|
await this.request({command: "commit-owner-replacement", replacementId})
|
|
195
195
|
}
|
|
196
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
|
+
|
|
197
202
|
/** @param {string} replacementId - Committed transaction awaiting incumbent retirement. */
|
|
198
203
|
async finalizeOwnerReplacement(replacementId) {
|
|
199
204
|
await this.request({command: "finalize-owner-replacement", replacementId})
|
package/src/process-guardian.js
CHANGED
|
@@ -297,6 +297,23 @@ async function execute(request, socket) {
|
|
|
297
297
|
return {aborted: true}
|
|
298
298
|
}
|
|
299
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
|
+
|
|
300
317
|
if (request.command === "commit-owner-replacement") {
|
|
301
318
|
requireOwner(socket, request.command)
|
|
302
319
|
if (!replacementClient || request.replacementId !== replacementId || !replacementOwnerState) throw new Error("Owner replacement transaction is not the prepared ready candidate")
|
|
@@ -510,6 +527,21 @@ function ownerAuthority(state) {
|
|
|
510
527
|
return state.authority
|
|
511
528
|
}
|
|
512
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
|
+
|
|
513
545
|
/**
|
|
514
546
|
* Stops accepting connections and closes every authority channel except the response caller.
|
|
515
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,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")
|