Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 39 additions & 21 deletions src/update/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore the tray when locked ownership aborts

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 when trayWasRunning is true, as the later replacement-refusal path does, or acquire and validate the lease before the tray handoff.

Useful? React with 👍 / 👎.

Comment on lines +479 to +483

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 more

The Windows tray handoff occurs before the ownership lease is acquired. A running tray is stopped and recorded in trayWasRunning. The newly added locked-ownership refusal releases the lease and exits directly, unlike the later replacement refusal, which calls startWindowsTray() first. Lease acquisition itself can also throw after the tray has stopped, producing the same stranded-tray outcome.

Example: The tray is running when ocx update begins. The desktop app changes runtime ownership before line 474. The locked subject differs from the initial subject, so the updater exits here; the package and proxy remain untouched, but the tray remains stopped.

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.

Devin Review


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
Expand All @@ -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...");
Expand All @@ -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
Expand Down Expand Up @@ -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()) {
Expand All @@ -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,
Expand Down
13 changes: 12 additions & 1 deletion tests/cli/cli-status-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as statusFacade from "../../src/cli/status";
import * as statusProbes from "../../src/cli/status-probes";
import { packageVersion } from "../../src/cli/help";
import { getDefaultConfig } from "../../src/config";
import { isProcessAlive } from "../../src/lib/process-control";
import { findDeadPid } from "../helpers/dead-pid";
import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmColdSpawn } from "../helpers/cold-spawn-warmup";
import { removeTreeWithRetry } from "../helpers/remove-tree";
Expand Down Expand Up @@ -1061,22 +1062,32 @@ describe("status reports stale process records end to end", () => {
await new Promise<void>(resolve => { occupied.listen(0, "127.0.0.1", () => resolve()); });
const occupiedPort = (occupied.address() as AddressInfo).port;
try {
const pid = findDeadPid();
let pid = findDeadPid();
writeFileSync(join(home, "config.json"), JSON.stringify({ port: occupiedPort, codexAutoStart: false }), "utf8");
writeFileSync(join(home, "ocx.pid"), String(pid), "utf8");

// The recorded port has to refuse for this to discriminate, and `allocateFreePort`
// hands back a port it has already released. Confirm refusal immediately before and
// immediately after the probe, and re-allocate when something took it in between, so
// a stolen port retries instead of failing an assertion it never exercised.
//
// The seeded dead pid carries the same hazard: `findDeadPid` proves it free once,
// and a pid reclaimed later makes the probe correctly judge the records as owned by
// a live process on every remaining attempt — the same misreading as a stolen port.
// Re-verify liveness around the probe and re-seed the records when it is taken.
let parsed: { proxy?: { staleProcessState?: unknown } } | undefined;
for (let attempt = 0; attempt < 5 && parsed === undefined; attempt++) {
if (isProcessAlive(pid)) {
pid = findDeadPid();
writeFileSync(join(home, "ocx.pid"), String(pid), "utf8");
}
const recordedPort = await allocateFreePort();
if (recordedPort === occupiedPort) continue;
if (!await refusesConnection(recordedPort)) continue;
writeFileSync(join(home, "runtime-port.json"), JSON.stringify({ pid, port: recordedPort, hostname: "127.0.0.1" }), "utf8");
const observed = JSON.parse(runStatusJson(home).stdout) as { proxy?: { staleProcessState?: unknown } };
if (!await refusesConnection(recordedPort)) continue;
if (isProcessAlive(pid)) continue;
// The TCP check can refuse while the HTTP /healthz probe aborts at 800ms
// without an ECONNREFUSED code. That leaves staleProcessState false even
// though the recorded port is still empty; retry instead of treating a
Expand Down
14 changes: 14 additions & 0 deletions tests/update/update-desktop-owner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 The race fix lacks behavioral regression coverage

The new test only searches source text for ordering and environment delegation. It cannot prove an ownership transition blocks stop or that the child joins the lease.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.


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)");
Expand Down
Loading