rollbridge 0.1.33 → 0.1.34
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.
|
@@ -3,3 +3,6 @@
|
|
|
3
3
|
- Carry an exact recovered guardian process key when a ready replacement commits
|
|
4
4
|
after the incumbent control listener has already retired, while retaining the
|
|
5
5
|
replacement transaction, authority, and registered-process fences.
|
|
6
|
+
- Fail closed when an older retained guardian cannot commit that replacement
|
|
7
|
+
atomically after the incumbent control socket disappears, preserving the
|
|
8
|
+
incumbent owner, retained connections, and guardian-managed release processes.
|
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -353,6 +353,7 @@ export default class RollbridgeDaemon {
|
|
|
353
353
|
let retiredIncumbentControl = false
|
|
354
354
|
let retainIncumbentControl = false
|
|
355
355
|
let incumbentControl = legacyBridge?.incumbentControl
|
|
356
|
+
let committed = /** @type {Promise<Error | undefined> | undefined} */ (undefined)
|
|
356
357
|
|
|
357
358
|
try {
|
|
358
359
|
if (this.config.control.path !== transfer.snapshot.control.path) {
|
|
@@ -401,7 +402,10 @@ export default class RollbridgeDaemon {
|
|
|
401
402
|
finalControlPublished = true
|
|
402
403
|
this.boundControlPath = this.config.control.path
|
|
403
404
|
}
|
|
404
|
-
|
|
405
|
+
committed = this.guardian.waitForEvent("replacement-committed").then(
|
|
406
|
+
() => undefined,
|
|
407
|
+
(error) => error instanceof Error ? error : new Error(String(error))
|
|
408
|
+
)
|
|
405
409
|
const staged = await this.guardian.stageOwnerReplacement(prepared.replacementId, {
|
|
406
410
|
authority: this.ownerAuthority(),
|
|
407
411
|
config: this.config,
|
|
@@ -415,7 +419,15 @@ export default class RollbridgeDaemon {
|
|
|
415
419
|
const processKey = this.guardian.processes.keys().next().value
|
|
416
420
|
|
|
417
421
|
if (!processKey) throw new Error("Retired owner replacement requires an exact recovered guardian process registration")
|
|
418
|
-
|
|
422
|
+
try {
|
|
423
|
+
await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId, processKey)
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (!(error instanceof Error) || error.message !== "Guardian commit-retired-owner-replacement requires the committed owner") throw error
|
|
426
|
+
throw new Error(
|
|
427
|
+
"Cannot safely complete atomic owner replacement through the older retained guardian while the incumbent control socket is absent; incumbent owner and connections were preserved",
|
|
428
|
+
{cause: error}
|
|
429
|
+
)
|
|
430
|
+
}
|
|
419
431
|
} else {
|
|
420
432
|
try {
|
|
421
433
|
if (!incumbentControl) throw new Error("Owner replacement incumbent control session is unavailable")
|
|
@@ -428,7 +440,9 @@ export default class RollbridgeDaemon {
|
|
|
428
440
|
}
|
|
429
441
|
}
|
|
430
442
|
}
|
|
431
|
-
await committed
|
|
443
|
+
const commitmentError = await committed
|
|
444
|
+
|
|
445
|
+
if (commitmentError) throw commitmentError
|
|
432
446
|
committedAuthority = true
|
|
433
447
|
if (retiredIncumbentControl) {
|
|
434
448
|
await fs.rename(stagingControlPath, this.config.control.path)
|
|
@@ -475,6 +489,13 @@ export default class RollbridgeDaemon {
|
|
|
475
489
|
} else {
|
|
476
490
|
this.guardian.disconnect()
|
|
477
491
|
}
|
|
492
|
+
if (committed) {
|
|
493
|
+
const commitmentError = await committed
|
|
494
|
+
|
|
495
|
+
if (commitmentError && commitmentError !== error && commitmentError.message !== "Process guardian connection closed") {
|
|
496
|
+
abortError = commitmentError
|
|
497
|
+
}
|
|
498
|
+
}
|
|
478
499
|
incumbentControl?.close()
|
|
479
500
|
if (legacyBridge?.boundaryCrossed) {
|
|
480
501
|
this.logger("legacy disruptive owner replacement failed after incumbent exit", {
|
|
@@ -249,45 +249,135 @@ test("ensure-daemon atomically replaces incompatible config, socket, and package
|
|
|
249
249
|
}
|
|
250
250
|
})
|
|
251
251
|
|
|
252
|
-
test("
|
|
252
|
+
test("cross-version replacement fails closed without dropping a retained WebSocket after the public socket is removed", async () => {
|
|
253
253
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-retired-control-"))
|
|
254
254
|
const socketPath = path.join(root, "rollbridge.sock")
|
|
255
255
|
const statePath = path.join(root, "state.json")
|
|
256
|
+
const compatibilitySocketPath = path.join(root, "retained-guardian.sock")
|
|
257
|
+
const configPath = path.join(root, "rollbridge.cjs")
|
|
256
258
|
const releasePath = path.join(root, "v1")
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
let
|
|
259
|
+
const compatibilitySockets = new Set()
|
|
260
|
+
let compatibilityGuardian
|
|
261
|
+
let committedProcessKey
|
|
262
|
+
let owner = /** @type {import("node:child_process").ChildProcess | undefined} */ (undefined)
|
|
263
|
+
let replacement = /** @type {RollbridgeDaemon | undefined} */ (undefined)
|
|
264
|
+
let retainedConnection
|
|
265
|
+
let retainedConnectionClosed = false
|
|
266
|
+
let transactionAudit
|
|
260
267
|
|
|
261
268
|
try {
|
|
262
269
|
await fs.mkdir(releasePath)
|
|
263
270
|
await makeFifo(path.join(releasePath, "worker.fifo"))
|
|
264
|
-
await
|
|
265
|
-
|
|
266
|
-
await
|
|
267
|
-
await
|
|
268
|
-
await
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
const
|
|
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"})
|
|
271
|
+
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
|
|
272
|
+
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
273
|
+
await waitForLog(owner, "control socket listening")
|
|
274
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: socketPath})
|
|
275
|
+
const retired = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
276
|
+
const retiredReleases = /** @type {{processes: {id: string, pid?: number, state: string}[]}[]} */ (retired.releases)
|
|
277
|
+
const processState = retiredReleases[0]?.processes.map(({id, pid, state}) => ({id, pid, state}))
|
|
278
|
+
const proxyPort = /** @type {{port?: number}} */ (retired.proxy).port
|
|
276
279
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
280
|
+
assert.deepEqual(processState?.map(({state}) => state), ["running", "running"])
|
|
281
|
+
if (typeof proxyPort !== "number") throw new Error("Retained owner proxy is missing its port")
|
|
282
|
+
retainedConnection = await openWebSocket(proxyPort)
|
|
283
|
+
retainedConnection.once("close", () => { retainedConnectionClosed = true })
|
|
284
|
+
await fs.rm(socketPath)
|
|
285
|
+
await assert.rejects(fs.access(socketPath), {code: "ENOENT"})
|
|
286
|
+
const state = JSON.parse(await fs.readFile(statePath, "utf8"))
|
|
287
|
+
const guardianSocketPath = state.recovery.guardian.socketPath
|
|
288
|
+
const expectedProcessKey = "release:v1:worker"
|
|
289
|
+
|
|
290
|
+
compatibilityGuardian = net.createServer((candidateSocket) => {
|
|
291
|
+
const guardianSocket = net.createConnection(guardianSocketPath)
|
|
292
|
+
let buffer = ""
|
|
293
|
+
|
|
294
|
+
compatibilitySockets.add(candidateSocket)
|
|
295
|
+
compatibilitySockets.add(guardianSocket)
|
|
296
|
+
candidateSocket.setEncoding("utf8")
|
|
297
|
+
candidateSocket.once("close", () => {
|
|
298
|
+
compatibilitySockets.delete(candidateSocket)
|
|
299
|
+
guardianSocket.destroy()
|
|
300
|
+
})
|
|
301
|
+
guardianSocket.once("close", () => {
|
|
302
|
+
compatibilitySockets.delete(guardianSocket)
|
|
303
|
+
candidateSocket.destroy()
|
|
304
|
+
})
|
|
305
|
+
guardianSocket.on("data", (chunk) => candidateSocket.write(chunk))
|
|
306
|
+
candidateSocket.on("data", (chunk) => {
|
|
307
|
+
buffer += chunk
|
|
308
|
+
let newline = buffer.indexOf("\n")
|
|
309
|
+
|
|
310
|
+
while (newline >= 0) {
|
|
311
|
+
const line = buffer.slice(0, newline)
|
|
312
|
+
const request = JSON.parse(line)
|
|
313
|
+
|
|
314
|
+
buffer = buffer.slice(newline + 1)
|
|
315
|
+
if (request.command === "commit-retired-owner-replacement") {
|
|
316
|
+
if (request.key !== expectedProcessKey) {
|
|
317
|
+
candidateSocket.write(`${JSON.stringify({error: `Guardian ${request.command} requires a process key`, id: request.id})}\n`)
|
|
318
|
+
} else {
|
|
319
|
+
committedProcessKey = request.key
|
|
320
|
+
candidateSocket.write(`${JSON.stringify({error: `Guardian ${request.command} requires the committed owner`, id: request.id})}\n`)
|
|
321
|
+
}
|
|
322
|
+
newline = buffer.indexOf("\n")
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
guardianSocket.write(`${line}\n`)
|
|
326
|
+
newline = buffer.indexOf("\n")
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
await listenUnix(compatibilityGuardian, compatibilitySocketPath)
|
|
331
|
+
state.recovery.guardian.socketPath = compatibilitySocketPath
|
|
332
|
+
await fs.writeFile(statePath, `${JSON.stringify(state)}\n`)
|
|
333
|
+
|
|
334
|
+
const incumbentPid = owner.pid
|
|
335
|
+
|
|
336
|
+
assert.ok(incumbentPid)
|
|
337
|
+
const candidate = new RollbridgeDaemon({
|
|
338
|
+
config: normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath})),
|
|
339
|
+
configPath,
|
|
340
|
+
legacyIncumbentPid: incumbentPid,
|
|
341
|
+
logger: () => {}
|
|
342
|
+
})
|
|
343
|
+
replacement = candidate
|
|
344
|
+
await assert.rejects(
|
|
345
|
+
() => candidate.replaceIncompatibleOwner(),
|
|
346
|
+
/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
|
|
347
|
+
)
|
|
348
|
+
assert.equal(committedProcessKey, expectedProcessKey)
|
|
349
|
+
assert.equal(owner.exitCode, null)
|
|
350
|
+
assert.equal(owner.signalCode, null)
|
|
351
|
+
assert.doesNotThrow(() => process.kill(incumbentPid, 0))
|
|
352
|
+
assert.equal(retainedConnectionClosed, false, "failed compatibility handoff must leave retained connections serving")
|
|
353
|
+
assert.equal(retainedConnection.destroyed, false, "failed compatibility handoff must preserve the incumbent listener")
|
|
354
|
+
for (const {pid} of processState || []) {
|
|
355
|
+
if (typeof pid !== "number") throw new Error("Retained process is missing its PID")
|
|
356
|
+
assert.doesNotThrow(() => process.kill(pid, 0))
|
|
357
|
+
}
|
|
358
|
+
transactionAudit = new GuardianClient(state.recovery.guardian)
|
|
359
|
+
await transactionAudit.connect()
|
|
360
|
+
const transactionStatus = /** @type {{committedReplacementId: string | null, ownerClaimed: boolean, retirementPending?: boolean}} */ (await transactionAudit.replacementStatus())
|
|
281
361
|
|
|
282
|
-
assert.equal(
|
|
283
|
-
assert.
|
|
284
|
-
assert.
|
|
362
|
+
assert.equal(transactionStatus.committedReplacementId, null)
|
|
363
|
+
assert.equal(transactionStatus.ownerClaimed, true)
|
|
364
|
+
assert.equal(transactionStatus.retirementPending, false)
|
|
285
365
|
} finally {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
366
|
+
if (replacement?.controlCommandsReady) {
|
|
367
|
+
await Promise.all([
|
|
368
|
+
fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => {}),
|
|
369
|
+
replacement.shutdown().catch(() => {})
|
|
370
|
+
])
|
|
371
|
+
}
|
|
372
|
+
transactionAudit?.disconnect()
|
|
373
|
+
retainedConnection?.destroy()
|
|
374
|
+
if (owner && owner.exitCode === null && owner.signalCode === null) {
|
|
375
|
+
owner.kill("SIGKILL")
|
|
376
|
+
await once(owner, "exit")
|
|
377
|
+
}
|
|
290
378
|
replacement?.guardian?.disconnect()
|
|
379
|
+
for (const socket of compatibilitySockets) socket.destroy()
|
|
380
|
+
if (compatibilityGuardian?.listening) await closeServer(compatibilityGuardian)
|
|
291
381
|
await stopGuardian(statePath)
|
|
292
382
|
await fs.rm(root, {force: true, recursive: true})
|
|
293
383
|
}
|