Skip to content
Merged
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
271 changes: 209 additions & 62 deletions bin/ocx.mjs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ and, afterwards, a folder next to the package that cannot be deleted because `bu
The updater stops the proxy, installs the new version into a separate staging folder, checks it,
and only then swaps it in. When the install step fails, the swap never happens: the previous version
stays installed and runnable, and the updater restarts the previous background service (or the
previous proxy) before it exits. You do not need to reinstall to get back to a working proxy.
previous proxy) before it exits, when ownership and service-home checks authorize recovery.

If the initial service-home check is inconclusive, for example because Bun is missing or a service
query fails, the updater can replace an idle package without stopping or restarting the proxy.
A successful package-only update prints a manual restart hint and explains that service ownership
could not be verified. Check `ocx service status` and the registered installation before restarting.
Changes in authority, unknown durable runtime ownership, Desktop supervision, a live package
runtime or an unresolved teardown receipt still block package replacement; an inconclusive check is not permission to control them.

Check that with:

Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"cli-capability-provider-workflows.test.ts": "cli", "cli-capability-access-workflows.test.ts": "cli", "cli-capability-lab-workflows.test.ts": "cli", "skill-ocx-workflows.test.ts": "ci-workflows",
"cli-capability-data.test.ts": "cli", "skill-ocx-generated.test.ts": "ci-workflows",
"cli-update-restart.test.ts": "cli", "cli-update-restart-child.test.ts": "cli", "cli-update-restart-transport.test.ts": "cli", "cli-update-restart-home.test.ts": "cli",
"cli-update-restart-service-record.test.ts": "cli", "cli-update-restart-supervision.test.ts": "cli", "cli-update-restart-admission.test.ts": "cli", "cli-update-restart-desktop-service.test.ts": "cli",
"cli-update-restart-service-record.test.ts": "cli", "cli-update-restart-supervision.test.ts": "cli", "cli-update-restart-admission.test.ts": "cli", "cli-update-restart-desktop-service.test.ts": "cli", "update-reclaim-authorizer.test.ts": "update", "update-windows-wrapper-authority.test.ts": "update", "update-mutation-scanner.test.ts": "update", "update-mutation-boundaries.test.ts": "update", "update-mutation-guard.test.ts": "update", "update-home-launcher.test.ts": "update", "update-home-recovery.test.ts": "update", "update-recovery-authority.test.ts": "update", "update-recovery-boundaries.test.ts": "update", "update-home-worker.test.ts": "update", "update-service-home-policy.test.ts": "update", "update-service-home.test.ts": "update", "update-guarded-ops.test.ts": "update", "update-effect-authority.test.ts": "update", "update-capability-policy.test.ts": "update",
"codex-shim-overlay.test.ts": "codex-integration", "codex-shim-migration.test.ts": "codex-integration",
"codex-shim-overlay-transaction.test.ts": "codex-integration", "codex-shim-overlay-readiness.test.ts": "codex-integration",
"codex-shim-overlay-candidates.test.ts": "codex-integration",
Expand Down
9 changes: 5 additions & 4 deletions src/config/owner-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,11 @@ export function readOwnerRegistry(): OwnerRegistryRead {
}

/** Retire 'home' from the shared registry. Best-effort like registration. */
export function unregisterOwnerRegistryHome(home: string): void {
try {
unlinkSync(registryEntryPath(ownerRegistryDir(), home));
} catch { /* a missing pointer needs no removal */ }
export function unregisterOwnerRegistryHome(home: string, beforeMutation?: () => void): void {
let path: string;
try { path = registryEntryPath(ownerRegistryDir(), home); } catch { return; }
beforeMutation?.();
try { unlinkSync(path); } catch { /* a missing pointer needs no removal */ }
}

/** Register this process's own home after its runtime record is published. */
Expand Down
14 changes: 9 additions & 5 deletions src/config/process-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,21 @@ export function readRuntimePort(expectedPid?: number): RuntimePortState | null {
}
}

export function removePid(expectedPid?: number): void {
export function removePid(expectedPid?: number, beforeMutation?: () => void): void {
if (expectedPid !== undefined && readPidFileValue() !== expectedPid) return;
try { unlinkSync(getPidPath()); } catch { /* ignore */ }
const path = getPidPath();
beforeMutation?.();
try { unlinkSync(path); } catch { /* ignore */ }
}

export function removeRuntimePort(expectedPid?: number): void {
export function removeRuntimePort(expectedPid?: number, beforeMutation?: () => void): void {
if (expectedPid !== undefined && readRuntimePort(expectedPid) === null) return;
try { unlinkSync(getRuntimePortPath()); } catch { /* ignore */ }
const path = getRuntimePortPath();
beforeMutation?.();
try { unlinkSync(path); } catch { /* ignore */ }
// The record is gone, so the registry pointer names a dead home. Retire it
// beside the record or stale pointers accumulate toward the reader's cap.
unregisterOwnerRegistryHome(getConfigDir());
unregisterOwnerRegistryHome(getConfigDir(), beforeMutation);
}

/**
Expand Down
15 changes: 12 additions & 3 deletions src/lib/process-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,22 +367,31 @@ async function waitForStoppedPort(
}
}

export function killProxy(pid: number): void {
export function killProxy(pid: number, mayTerminate?: () => boolean): void {
if (!isProcessAlive(pid)) return;
const authorize = () => {
if (mayTerminate && mayTerminate() !== true) throw Object.assign(new Error("Process termination refused"), { code: "EUPDATEAUTHORITY" });
};
if (process.platform === "win32") {
// Windows process.kill(SIGTERM/SIGINT) is TerminateProcess — not a graceful signal.
// Graceful drain happens only via stopProxyGracefully() (POST /api/stop). This path
// is the hard fallback: taskkill /T /F so the process tree exits (ghost LISTEN /
// CLOSE_WAIT are then cleared by reclaimListenPort / SetTcpEntry).
const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
const args = ["/PID", String(pid), "/T", "/F"];
authorize();
try {
execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe", windowsHide: true });
execFileSync(taskkill, args, { stdio: "pipe", windowsHide: true });
} catch (err) {
if (isProcessAlive(pid)) throw err;
}
} else {
authorize();
process.kill(pid, "SIGTERM");
if (!waitForExit(pid, 5000)) process.kill(pid, "SIGKILL");
if (!waitForExit(pid, 5000)) {
authorize();
process.kill(pid, "SIGKILL");
}
}
if (!waitForExit(pid, 5000)) throw new Error(`process ${pid} did not exit`);
}
17 changes: 10 additions & 7 deletions src/lib/windows-service-wrappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,17 @@ export function windowsWrapperKillScript(paths: readonly string[]): string {
export function killWindowsSchedulerWrappers(paths: {
scriptPath: string;
launcherPath: string;
}): void {
}, beforeMutation?: () => void): void {
if (process.platform !== "win32") return;
let file: string, args: string[];
try {
spawnSync(resolveTrustedWindowsPowerShellExe(), [
// No `-WindowStyle Hidden` here: Bun 1.3.14 can fail that direct CLI pair
// before the command runs (#1589). `windowsHide` below is sufficient.
"-NoProfile", "-NoLogo", "-NonInteractive",
"-Command", windowsWrapperKillScript([paths.scriptPath, paths.launcherPath]),
], { stdio: "ignore", timeout: 5000, windowsHide: true });
file = resolveTrustedWindowsPowerShellExe();
args = ["-NoProfile", "-NoLogo", "-NonInteractive", "-Command",
windowsWrapperKillScript([paths.scriptPath, paths.launcherPath])];
} catch { return; }
beforeMutation?.();
try {
// windowsHide avoids Bun's direct -WindowStyle argv failure (#1589).
spawnSync(file, args, { stdio: "ignore", timeout: 5000, windowsHide: true });
} catch { /* best-effort */ }
}
12 changes: 8 additions & 4 deletions src/lib/winsw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,11 @@ const SERVICE_COMMAND_TIMEOUT_MS = 15_000;
/** `stopwait` outlasts the SCM `<stoptimeout>` (20s in the generated service definition) plus overhead; still bounded so a wedged SCM cannot hang the CLI. */
const SERVICE_STOPWAIT_TIMEOUT_MS = 40_000;

function runWinsw(args: string[]): string {
function runWinsw(args: string[], beforeMutation?: () => void): string {
const timeoutMs = args[0] === "stopwait" ? SERVICE_STOPWAIT_TIMEOUT_MS : SERVICE_COMMAND_TIMEOUT_MS;
return execFileSync(winswExePath(), args, {
const file = winswExePath();
beforeMutation?.();
return execFileSync(file, args, {
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, timeout: timeoutMs,
}).trim();
}
Expand Down Expand Up @@ -352,8 +354,10 @@ export function startWinswService(): void { runWinsw(["start"]); }
* catch cannot decide whether it is safe for lifecycle callers to continue. Re-read
* SCM state and only accept the two states that cannot still own the proxy listener.
*/
export function stopWinswService(): void {
try { runWinsw(["stopwait"]); } catch { /* classify by verified state below */ }
export function stopWinswService(beforeMutation?: () => void): void {
let refused = false;
const gate = beforeMutation && (() => { try { beforeMutation(); } catch (error) { refused = true; throw error; } });
try { runWinsw(["stopwait"], gate); } catch (error) { if (refused) throw error; /* classify by verified state below */ }
const status = statusWinswRaw();
if (status === "stopped" || status === "nonexistent") return;
if (status === "unknown") {
Expand Down
34 changes: 29 additions & 5 deletions src/server/port-reclaim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,16 @@ export type ReclaimListenPortOptions = WaitForPortOptions & {
listListenPidsFn?: (port: number) => ListenPidScan | number[];
isAliveFn?: (pid: number) => boolean;
verifyOcxFn?: (pid: number) => number | null;
killFn?: (pid: number) => void;
dropTcpFn?: (port: number) => number | { dropped: number; skippedIpv6: number };
killFn?: (pid: number, mayMutate?: () => boolean) => void;
dropTcpFn?: (port: number, mayMutate?: () => boolean) => number | { dropped: number; skippedIpv6: number };
isAvailableFn?: (port: number, hostname?: string) => Promise<boolean>;
/**
* Synchronous authority gate re-checked after each availability await and
* immediately before every destructive action (killFn / dropTcpFn). Returning
* false denies the mutation and ends the reclaim: the port is reported
* not-freed without touching processes or TCP rows. A throwing gate denies too.
*/
mayMutateFn?: () => boolean;
sleepMs?: (ms: number) => Promise<void>;
};

Expand Down Expand Up @@ -516,6 +523,8 @@ export async function ownsIpv4LoopbackListener(
* revalidates immediately before each kill.
* Never overrides a rejected ocx verifier result. Never drops TCP rows while a
* rejected live or protected ocx listener owns the port, or when the scan failed.
* A denied `mayMutateFn` ends the wait: no kill or TCP-row drop may run under
* an authority that no longer holds.
*/
export async function reclaimListenPort(
port: number,
Expand All @@ -539,13 +548,21 @@ export async function reclaimListenPort(
const dropTcpFn = opts.dropTcpFn ?? dropWindowsTcpRowsForLocalPort;
const isAvailableFn = opts.isAvailableFn ?? isPortAvailable;
const sleep = opts.sleepMs ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)));
let denied = false;
const mayMutate = (): boolean => {
try { denied ||= opts.mayMutateFn !== undefined && opts.mayMutateFn() !== true; } catch { denied = true; }
return !denied;
};

const deadline = Date.now() + timeoutMs;
let lastScan = 0;
const killed = new Set<number>();

for (;;) {
if (await isAvailableFn(port, hostname)) return true;
const available = await isAvailableFn(port, hostname);
// The authority may have changed while the availability probe was in flight.
if (!mayMutate()) return false;
if (available) return true;
if (Date.now() >= deadline) return false;

if (Date.now() - lastScan >= scanIntervalMs) {
Expand Down Expand Up @@ -586,10 +603,13 @@ export async function reclaimListenPort(
if (!killed.has(pid)) {
// Revalidate immediately before termination.
if (isAliveFn(pid) && verifyOcxFn(pid) === pid && mayKillThis) {
if (!mayMutate()) return false;
try {
killFn(pid);
if (opts.mayMutateFn) killFn(pid, mayMutate);
else killFn(pid);
killed.add(pid);
} catch {
if (denied) return false;
// Kill failed: keep waiting and never reset this listener's TCP rows.
protectedOcxListener = true;
}
Expand Down Expand Up @@ -619,9 +639,13 @@ export async function reclaimListenPort(
// Reset those IPv4 TCBs (and ghost LISTEN rows) so the configured port can bind again —
// without killing the browser process. Only safe when no live foreign/protected listener remains.
if (dropTcpRows) {
// Same gate as the kill path: denied authority never runs SetTcpEntry.
if (!mayMutate()) return false;
try {
dropTcpFn(port);
if (opts.mayMutateFn) dropTcpFn(port, mayMutate);
else dropTcpFn(port);
} catch {
if (denied) return false;
/* access denied / unsupported — keep waiting */
}
}
Expand Down
6 changes: 4 additions & 2 deletions src/server/windows-tcp-drop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ function readNetstatAno(): string {
* Bare IPv6 rows (`::1`, `::`, etc.) are skipped (not coerced into IPv4 wildcards);
* IPv6 TCB reclamation is unsupported on this path.
*/
export function dropWindowsTcpRowsForLocalPort(port: number): WindowsTcpDropResult {
export function dropWindowsTcpRowsForLocalPort(port: number, mayMutate?: () => boolean): WindowsTcpDropResult {
if (process.platform !== "win32" || !Number.isFinite(port) || port <= 0) {
return { dropped: 0, skippedIpv6: 0, accessDenied: 0 };
}
Expand Down Expand Up @@ -171,8 +171,10 @@ export function dropWindowsTcpRowsForLocalPort(port: number): WindowsTcpDropResu
view.setUint32(8, htons(row.localPort), true);
view.setUint32(12, remoteDw, true);
view.setUint32(16, htons(row.remotePort), true);
const pointer = ptr(buf);
if (mayMutate && mayMutate() !== true) throw Object.assign(new Error("TCP deletion refused"), { code: "EUPDATEAUTHORITY" });
try {
const rc = setTcpEntry(ptr(buf));
const rc = setTcpEntry(pointer);
if (rc === 0) dropped += 1;
// 5 = ERROR_ACCESS_DENIED, 317 = non-elevated SetTcpEntry (MSDN).
else if (rc === 5 || rc === 317) accessDenied += 1;
Expand Down
7 changes: 5 additions & 2 deletions src/service/windows-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -840,10 +840,13 @@ export function isWindowsSchedulerEndBenign(error: unknown): boolean {
* that *succeeds* while the wrapper survives and respawns. That verification lives
* on the stop-verification path (poll across the restart window), not here.
*/
export function stopWindows(): void {
export function stopWindows(beforeMutation?: () => void): void {
let refused = false;
const gate = beforeMutation && (() => { try { beforeMutation(); } catch (error) { refused = true; throw error; } });
try {
schtasks(["/end", "/tn", TASK]);
schtasks(["/end", "/tn", TASK], gate);
} catch (error) {
if (refused) throw error;
if (isWindowsSchedulerEndBenign(error)) return;
}
}
Expand Down
17 changes: 12 additions & 5 deletions src/service/windows-scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ function windowsSchtasks(): string {

let querySchtasksForTests: ((args: string[]) => string) | null = null;

export function querySchtasks(args: string[]): string {
export function querySchtasks(args: string[], beforeMutation?: () => void): string {
// The repository preload isolates HOME and OPENCODEX_HOME, but Task Scheduler is
// machine-global. A partially-faked service test once fell through here and replaced the
// user's real `opencodex-proxy` task with a launcher inside its temporary test home; the
Expand All @@ -86,22 +86,29 @@ export function querySchtasks(args: string[]): string {
+ "inject the scheduler operation instead of calling the live manager.",
);
}
if (querySchtasksForTests) return querySchtasksForTests(args);
return runFile(windowsSchtasks(), args);
if (querySchtasksForTests) { beforeMutation?.(); return querySchtasksForTests(args); }
const file = windowsSchtasks();
beforeMutation?.();
return runFile(file, args);
}

/** Test-only seam for Task Scheduler query used by presence probes. */
export function setQuerySchtasksForTests(next: ((args: string[]) => string) | null): void {
querySchtasksForTests = next;
}

export function schtasks(args: string[], io: {
export function schtasks(args: string[], control: (() => void) | {
run?: (args: string[]) => string;
probeTask?: () => WindowsSchedulerTaskProbe;
} = {}): string {
const beforeMutation = typeof control === "function" ? control : undefined;
const io = typeof control === "function" ? {} : control;
let refused = false;
const gate = beforeMutation && (() => { try { beforeMutation(); } catch (error) { refused = true; throw error; } });
try {
return (io.run ?? querySchtasks)(args);
return io.run ? io.run(args) : querySchtasks(args, gate);
} catch (error) {
if (refused) throw error;
let taskPresent = false;
const normalized = args.map(arg => arg.toLowerCase());
const taskIndex = normalized.indexOf("/tn");
Expand Down
10 changes: 6 additions & 4 deletions src/tray/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,11 +622,13 @@ export async function runWindowsTrayHost(): Promise<void> {
});
}

function signalTrayStop(): ReturnType<typeof readHeartbeat> {
function signalTrayStop(beforeMutation?: () => void): ReturnType<typeof readHeartbeat> {
const previous = readHeartbeat();
// The stop event name depends only on the current home. Never execute paths
// recovered from tray-state.json while attempting cleanup or repair.
execFileSync(windowsPowerShellPath(), windowsTrayProcessArgs(currentEntry(), "Stop"), {
const file = windowsPowerShellPath(), args = windowsTrayProcessArgs(currentEntry(), "Stop");
beforeMutation?.();
execFileSync(file, args, {
stdio: "ignore",
windowsHide: true,
timeout: 15_000,
Expand Down Expand Up @@ -743,11 +745,11 @@ export function startWindowsTray(): WindowsTrayStatus {
return getWindowsTrayStatus();
}

export function stopWindowsTray(): WindowsTrayStatus {
export function stopWindowsTray(beforeMutation?: () => void): WindowsTrayStatus {
assertWindows();
let previous = readHeartbeat();
if (previous) {
previous = signalTrayStop();
previous = signalTrayStop(beforeMutation);
}
if (!waitForTrayExit(previous)) throw new Error("The tray did not exit after the stop signal. Its login registration was preserved.");
return getWindowsTrayStatus();
Expand Down
7 changes: 4 additions & 3 deletions src/update/async-check.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { probeSpawn } from "./mutation-guard.mjs";
import { unprivilegedOwnershipMutationEnvironment } from "../service/ownership-mutation-lease.mjs";
import { PKG, registrySpawnTarget, type Channel, type Installer } from "./index";
import type { PnpmGlobalOwner } from "./pnpm-global-install.mjs";
Expand Down Expand Up @@ -40,11 +41,11 @@ export async function pnpmOwner(deps: PnpmOwnerDeps = {}): Promise<PnpmGlobalOwn

export interface AsyncLookupDeps {
ownerFn: () => Promise<PnpmGlobalOwner | null>;
spawnFn: typeof spawn;
spawnFn: typeof probeSpawn;
deadlineMs?: number;
}

const defaultDeps: AsyncLookupDeps = { ownerFn: pnpmOwner, spawnFn: spawn };
const defaultDeps: AsyncLookupDeps = { ownerFn: pnpmOwner, spawnFn: probeSpawn };

export async function latestVersionAsync(
channel: Channel,
Expand Down
4 changes: 2 additions & 2 deletions src/update/bind-probe.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { spawnSync } from "node:child_process";
import { probeSpawnSync } from "./mutation-guard.mjs";

/**
* Strict bind script: exit 0 only after listen+close. Any listen error (including
Expand All @@ -17,7 +17,7 @@ export function strictBindProbeScript(port: number, hostname: string): string {

export function spawnBindProbe(bin: string, script: string): boolean {
try {
const r = spawnSync(bin, ["-e", script], {
const r = probeSpawnSync(bin, ["-e", script], {
windowsHide: true,
timeout: 4000,
stdio: "ignore",
Expand Down
Loading
Loading