-
Notifications
You must be signed in to change notification settings - Fork 0
fix(update): fence Bun runtime stop ownership #618
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,7 +11,10 @@ import { readPid, readRuntimePort } from "../config/process-state"; | |
| import { pendingTeardownOutstanding } from "../config/pending-teardown"; | ||
| import type { ServiceOwnership } from "../service/state"; | ||
| import { planUpdateRuntimeHandling } from "./runtime-ownership.mjs"; | ||
| import { acquireOwnershipMutationLease } from "../service/ownership-mutation-lease.mjs"; | ||
| import { | ||
| acquireOwnershipMutationLease, | ||
| ownershipMutationLeaseChildEnvironment, | ||
| } from "../service/ownership-mutation-lease.mjs"; | ||
| import { npmInvocation } from "./npm-invocation.mjs"; | ||
| import { pnpmInvocation, pnpmInvocationForPath, resolvePnpmCommands } from "./pnpm-invocation.mjs"; | ||
| import { detectInstallFromPath } from "./install-detection.mjs"; | ||
|
|
@@ -421,7 +424,7 @@ export async function runUpdate(): Promise<void> { | |
| // What this update may do to the runtime. A desktop takeover vetoes both the stop and the | ||
| // service refresh below; see `planUpdateRuntimeHandling` for why each half is wrong. | ||
| const initialOwnership = await resolvedRuntimeOwnership(); | ||
| const runtimePlan = planUpdateRuntimeHandling({ | ||
| let runtimePlan = planUpdateRuntimeHandling({ | ||
| ...initialOwnership, | ||
| serviceInstalled: serviceWasInstalled, | ||
| }); | ||
|
|
@@ -466,6 +469,36 @@ export async function runUpdate(): Promise<void> { | |
| ...(runtimeTrusted && livePid ? { oldPid: livePid } : {}), | ||
| }; | ||
|
|
||
| const { serviceStatePaths } = await import("../service"); | ||
| const replacementLease = acquireOwnershipMutationLease(serviceStatePaths()); | ||
| const lockedOwnership = await resolvedRuntimeOwnership(); | ||
| const lockedPlan = planUpdateRuntimeHandling({ | ||
| ...lockedOwnership, | ||
| serviceInstalled: serviceWasInstalled, | ||
| }); | ||
| if (lockedOwnership.subjectToken !== initialOwnership.subjectToken || !lockedPlan.mayReplacePackage) { | ||
| replacementLease.release(); | ||
| console.error(lockedPlan.notice | ||
| ?? "⚠️ Update stopped because runtime ownership changed before stop authorization; rerun from the beginning."); | ||
| process.exit(1); | ||
|
Comment on lines
+479
to
+483
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 A fenced pre-stop refusal leaves the Windows tray stopped The updater stops a running Windows tray before acquiring and validating the ownership lease. If ownership changes, this branch exits without restoring the tray, leaving its UI unavailable until manually restarted. Learn moreThe Windows tray handoff occurs before the ownership lease is acquired. A running tray is stopped and recorded in Example: The tray is running when Recommended fix: Acquire and validate the ownership lease before tray handoff, or wrap every post-handoff pre-replacement failure—including lease acquisition and this refusal—in shared best-effort tray restoration. Add a behavioral regression test that simulates an ownership change after handoff and verifies the tray restart callback runs. Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
| runtimePlan = lockedPlan; | ||
| const stopEnvironment = ownershipMutationLeaseChildEnvironment(process.env, replacementLease.token); | ||
| let replacementRefusal: string | null = null; | ||
| let stopAttempted = false; | ||
| const installStdio = updateChildStdio(); | ||
| let postUpdateLauncher = installer === "pnpm" && owner | ||
| ? join(owner.packagePath, "bin", "ocx.mjs") | ||
| : join(packageRoot(), "bin", "ocx.mjs"); | ||
| let postUpdateLauncherUsable = true; | ||
| let r: { | ||
| status: number | null; | ||
| signal?: NodeJS.Signals | null; | ||
| stdout?: string | Buffer | null; | ||
| stderr?: string | Buffer | null; | ||
| } | null = null; | ||
| try { | ||
|
|
||
| // Never replace package files under a live proxy: the running server dynamic-imports | ||
| // modules after startup, so an in-place update leaves it executing mixed old/new code. | ||
| // Gate on the service and the runtime-port record too, not just the pid file — a | ||
|
|
@@ -476,7 +509,6 @@ export async function runUpdate(): Promise<void> { | |
| // shared client config still points at a proxy that is gone; installing over that | ||
| // silently skips the recovery the receipt was written to trigger (#3008). | ||
| // Full `ocx stop` semantics (drain, service stop, restore). | ||
| let stopAttempted = false; | ||
| if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())) { | ||
| stopAttempted = true; | ||
| console.log("⏹ Stopping the running proxy before updating..."); | ||
|
|
@@ -485,6 +517,7 @@ export async function runUpdate(): Promise<void> { | |
| stdio: stopStdio, | ||
| encoding: stopStdio === "pipe" ? "utf8" : undefined, | ||
| windowsHide: true, | ||
| env: stopEnvironment, | ||
| }); | ||
| if (stopStdio === "pipe") logSpawnOutput("", stop); | ||
| // One decision, shared with the package launcher (#3008). The two lanes disagreeing about | ||
|
|
@@ -517,6 +550,7 @@ export async function runUpdate(): Promise<void> { | |
| ? `⚠️ Could not confirm the proxy on ${capturedListen.hostname}:${capturedListen.port} is stopped; aborting the update. Run 'ocx stop' and retry.` | ||
| : "⚠️ Could not stop the running proxy; aborting the update. Run 'ocx stop' and retry."); | ||
| } | ||
| replacementLease.release(); | ||
| process.exit(1); | ||
| } | ||
| if (historyOnlyStop || historyRestoreIncomplete()) { | ||
|
|
@@ -538,24 +572,8 @@ export async function runUpdate(): Promise<void> { | |
| } | ||
| } | ||
|
|
||
| const installStdio = updateChildStdio(); | ||
| let postUpdateLauncher = installer === "pnpm" && owner | ||
| ? join(owner.packagePath, "bin", "ocx.mjs") | ||
| : join(packageRoot(), "bin", "ocx.mjs"); | ||
| let postUpdateLauncherUsable = true; | ||
| let r: { | ||
| status: number | null; | ||
| signal?: NodeJS.Signals | null; | ||
| stdout?: string | Buffer | null; | ||
| stderr?: string | Buffer | null; | ||
| } | null = null; | ||
| const { serviceStatePaths } = await import("../service"); | ||
| const replacementLease = acquireOwnershipMutationLease(serviceStatePaths()); | ||
| let replacementRefusal: string | null = null; | ||
| try { | ||
| // Ownership can change while registry and stop work is in flight. Unknown at this exact | ||
| // boundary blocks replacement; a confirmed desktop claim still permits updating the idle | ||
| // npm installation while leaving the bundled sidecar alone. | ||
| // Re-read even though cooperating ownership changes are fenced by the lease: an unreadable | ||
| // or externally replaced record still blocks replacement at the final package boundary. | ||
| const replacementOwnership = await resolvedRuntimeOwnership(); | ||
| const replacementPlan = planUpdateRuntimeHandling({ | ||
| ...replacementOwnership, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -176,6 +176,20 @@ describe("both updaters consult the shared rule", () => { | |
| expect(bunPath).toContain("if (postInstallPlan.mayRestoreService) {"); | ||
| }); | ||
|
|
||
| test("the Bun updater fences and delegates its final stop authorization", () => { | ||
| const leaseAt = bunPath.indexOf("const replacementLease = acquireOwnershipMutationLease"); | ||
| const lockedReadAt = bunPath.indexOf("const lockedOwnership = await resolvedRuntimeOwnership()", leaseAt); | ||
| const stopAt = bunPath.indexOf('selfLaunchArgv(["stop"])', lockedReadAt); | ||
| const releaseAt = bunPath.indexOf("replacementLease.release()", stopAt); | ||
| expect(leaseAt).toBeGreaterThan(-1); | ||
| expect(lockedReadAt).toBeGreaterThan(leaseAt); | ||
| expect(stopAt).toBeGreaterThan(lockedReadAt); | ||
| expect(releaseAt).toBeGreaterThan(stopAt); | ||
| expect(bunPath.slice(lockedReadAt, stopAt)).toContain("lockedOwnership.subjectToken !== initialOwnership.subjectToken"); | ||
| expect(bunPath.slice(lockedReadAt, stopAt)).toContain("ownershipMutationLeaseChildEnvironment"); | ||
| expect(bunPath.slice(stopAt, releaseAt)).toContain("env: stopEnvironment"); | ||
| }); | ||
|
Comment on lines
+179
to
+191
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| test("the npm launcher gates its stop, its refresh and its failure recovery", () => { | ||
| expect(launcher).toContain("from \"../src/update/runtime-ownership.mjs\""); | ||
| expect(launcher).toContain("if (stopNeeded && !runtimePlan.mayStopRuntime)"); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On Windows, if ownership changes after the initial observation but before this locked recheck, the updater has already stopped a running tray via
handoffWindowsTrayForUpdate, yet this new early-exit path releases the lease and terminates without restarting it. This leaves the user's tray unexpectedly down even though no runtime stop or package replacement occurred; restore it whentrayWasRunningis true, as the later replacement-refusal path does, or acquire and validate the lease before the tray handoff.Useful? React with 👍 / 👎.