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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
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
- incumbentControl = await openControlSession(transfer.snapshot.control.path)
329
- const listenerSession = incumbentControl
330
-
331
- incumbentControl.onEvent((event) => this.handleIncumbentListenerEvent(event, listenerSession))
332
- await incumbentControl.request({
333
- command: "yield-owner-listeners",
334
- control: transfer.snapshot.control.path === this.config.control.path,
335
- proxy: true,
336
- replacementId: prepared.replacementId
337
- })
338
- listenersYielded = true
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
- await fs.rename(stagingControlPath, this.config.control.path)
342
- finalControlPublished = true
343
- this.boundControlPath = this.config.control.path
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
- try {
354
- if (!incumbentControl) throw new Error("Owner replacement incumbent control session is unavailable")
355
- await incumbentControl.request({command: "commit-owner-replacement", replacementId: prepared.replacementId})
356
- } catch (error) {
357
- const status = await this.guardian.replacementStatus()
358
-
359
- if (status.committedReplacementId !== prepared.replacementId || !status.ownerClaimed) throw error
360
- this.logger("owner replacement commit response lost; guardian commit confirmed", {replacementId: prepared.replacementId})
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
@@ -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})
@@ -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")