Skip to content

Commit 6a09cb7

Browse files
authored
Merge pull request #838 from Wibias/fix/update-proxy-recovery
fix(update): recover Windows dashboard proxy after self-update
2 parents a1ed691 + daf9c59 commit 6a09cb7

20 files changed

Lines changed: 1256 additions & 162 deletions

bin/ocx.mjs

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -250,12 +250,40 @@ function runNpmSelfUpdate() {
250250
try {
251251
const svcArgs = serviceReinstallArgs();
252252
const svc = spawnSync(process.execPath, svcArgs, { stdio: "inherit", windowsHide: true });
253-
if (svc.status !== 0) {
253+
let needDirectStart = svc.status !== 0;
254+
if (!needDirectStart) {
255+
// Exit 0 can still leave stale/missing assets that never bring the proxy
256+
// back — match the GUI/CLI fallthrough so /healthz is not left dead.
257+
try {
258+
const st = spawnSync(process.execPath, [launcher, "status", "--json"], {
259+
encoding: "utf8",
260+
timeout: 20_000,
261+
windowsHide: true,
262+
});
263+
if (st.status === 0 && typeof st.stdout === "string" && st.stdout.trim()) {
264+
const parsed = JSON.parse(st.stdout);
265+
const proxyUp = parsed?.proxy?.running === true || parsed?.proxy?.health?.ok === true;
266+
const viable = parsed?.startup?.serviceViable === true;
267+
if (!proxyUp && !viable) needDirectStart = true;
268+
} else {
269+
// status failed or empty — fail closed to direct start (match CLI).
270+
needDirectStart = true;
271+
}
272+
} catch {
273+
needDirectStart = true;
274+
}
275+
}
276+
if (needDirectStart) {
254277
// On Windows, schtasks /create requires elevation. The launcher inherits the
255278
// user's (non-admin) token, so the service reinstall can fail with access
256-
// denied. Fall back to a direct detached proxy start so the update never
257-
// leaves the user without a running proxy.
258-
console.warn("opencodex: service refresh failed — starting the proxy directly instead.");
279+
// denied — or exit 0 while leaving a non-viable manager. Fall back to a
280+
// direct detached proxy start so the update never leaves the user without
281+
// a running proxy.
282+
console.warn(
283+
svc.status === 0
284+
? "opencodex: service refresh left a non-viable manager — starting the proxy directly instead."
285+
: "opencodex: service refresh failed — starting the proxy directly instead.",
286+
);
259287
console.warn(" Run 'ocx service install' as administrator to refresh the background service.");
260288
const env = { ...process.env };
261289
delete env.OCX_SERVICE;

src/adapters/cursor/native-exec-shell.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -335,17 +335,17 @@ function waitForBackgroundShellClose(entry: BackgroundShellEntry): Promise<boole
335335
if (backgroundShells.get(entry.shellId) !== entry) return Promise.resolve(true);
336336
return new Promise(resolveWait => {
337337
let settled = false;
338-
const timer = backgroundShellRuntime.setTimer(() => {
339-
if (settled) return;
340-
settled = true;
341-
resolveWait(false);
342-
}, CURSOR_BACKGROUND_SHELL_TERM_GRACE_MS);
343338
// Deliberately REF'D: this is the bounded kill-grace wait that shutdown
344339
// drain awaits. Bun on Windows can starve unref'd timers when a pending
345340
// promise is the only other work, which would leave drainAndShutdown
346341
// waiting on this resolution forever. The timer self-clears within the
347342
// 2-second grace window (or earlier on close), so a ref cannot keep the
348343
// process alive beyond that bound.
344+
const timer = backgroundShellRuntime.setTimer(() => {
345+
if (settled) return;
346+
settled = true;
347+
resolveWait(false);
348+
}, CURSOR_BACKGROUND_SHELL_TERM_GRACE_MS);
349349
void entry.closePromise.then(() => {
350350
if (settled) return;
351351
settled = true;

src/cli/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,16 +129,21 @@ async function chooseListenPort(requestedPort?: number): Promise<number> {
129129
if (hardPin && preferred > 0) {
130130
const { reclaimListenPort } = await import("../server/port-reclaim");
131131
await reclaimListenPort(preferred, config.hostname ?? "127.0.0.1", {
132-
timeoutMs: 30_000,
132+
// Ghost LISTEN rows with a dead PID can outlive the process for a while.
133+
// SetTcpEntry(DELETE_TCB) needs elevation (often returns 317), so the only
134+
// reliable non-admin recovery is to wait for the OS to release the TCB.
135+
timeoutMs: 60_000,
133136
intervalMs: 100,
134137
scanIntervalMs: 500,
135138
killOcxHolders: false,
136-
dropTcpRows: false,
139+
dropTcpRows: true,
137140
});
138141
}
139142
try {
140143
const selected = await findAvailablePort(preferred, config.hostname ?? "127.0.0.1", {
141-
preferRetryMs: hardPin ? 0 : 750,
144+
// After reclaim, keep probing briefly — ghost rows sometimes clear between
145+
// the reclaim deadline and the final listen. Still never hop off `--port`.
146+
preferRetryMs: hardPin ? 5_000 : 750,
142147
preferRetryIntervalMs: 50,
143148
allowEphemeralFallback: !hardPin,
144149
});

src/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2173,9 +2173,13 @@ export function parsePidFile(raw: string): number | null {
21732173
export function isOcxStartCommandLine(commandLine: string): boolean {
21742174
const normalized = commandLine.toLowerCase().replace(/\\/g, "/");
21752175
// "src/cli.ts" matches pre-restructure installs still running; "src/cli/index.ts" is current.
2176+
// `@bitkyc08/.opencodex-*` is npm's in-place rename of the global package during
2177+
// `npm install -g` — a Windows service wrapper can respawn from that temp tree
2178+
// mid-update, and must still count as ocx for port reclaim.
21762179
const hasOcxEntrypoint = normalized.includes("src/cli.ts")
21772180
|| normalized.includes("src/cli/index.ts")
21782181
|| normalized.includes("@bitkyc08/opencodex")
2182+
|| /@bitkyc08\/\.opencodex-/.test(normalized)
21792183
|| /(?:^|[\s/"'])(?:ocx|opencodex)(?:\.cmd)?(?:$|[\s"'])/.test(normalized);
21802184
return hasOcxEntrypoint && /(?:^|[\s"'])start(?:$|[\s"'])/.test(normalized);
21812185
}

src/server/management/system-restart.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
* recycle to reclaim RSS, not a teardown.
77
*
88
* Respawn policy (matches real supervisor configs in src/service.ts):
9-
* - Supervised child (`OCX_SERVICE=1` + service installed): exit(1) so
9+
* - Supervised child (`OCX_SERVICE=1` + viable service): exit(1) so
1010
* failure-only supervisors (systemd Restart=on-failure, WinSW onfailure,
1111
* Task Scheduler ERRORLEVEL loop) bring the proxy back.
1212
* - Otherwise: detached `ocx start --port <live>` (bypasses ensure's
1313
* codexAutoStart gate), mark recycle so exit cleanup keeps injection, exit(0).
14+
* Installed-but-stale/missing service assets are NOT treated as supervised —
15+
* exit(1) would leave the proxy dead with `Service: installed, stale or missing
16+
* service assets` and a /healthz timeout.
1417
* - If detached spawn fails (sync throw or pre-start `error`): exit(1) without
1518
* markRecycling — after drain the listen socket is already closed, so a latch
1619
* reset cannot recover serving. Clear inherited `OCX_SERVICE` so exit cleanup
@@ -27,15 +30,16 @@ import {
2730
markRecyclingForExit,
2831
setDraining,
2932
} from "../lifecycle";
30-
import { isServiceInstalled } from "../../service";
33+
import { isServiceViable } from "../../service";
3134
import { readRuntimePort } from "../../config";
3235

3336
/** Fixed v1 drain window for the memory-card action (not config-driven). */
3437
export const MEMORY_DRAIN_RESTART_MS = 60_000;
3538

3639
export interface SystemRestartIo {
3740
drainAndShutdown?: typeof drainAndShutdown;
38-
isServiceInstalled?: () => boolean;
41+
/** True when a background service can actually respawn this process after exit(1). */
42+
isServiceViable?: () => boolean;
3943
isSupervisedServiceChild?: () => boolean;
4044
/** Must resolve only after the replacement process has actually started. */
4145
spawnStart?: (port?: number) => void | Promise<void>;
@@ -66,8 +70,11 @@ function resolveListenPort(): number | undefined {
6670
return undefined;
6771
}
6872

69-
function isSupervisedServiceChild(): boolean {
70-
return process.env.OCX_SERVICE === "1" && isServiceInstalled();
73+
function isSupervisedServiceChild(io: SystemRestartIo = {}): boolean {
74+
if (process.env.OCX_SERVICE !== "1") return false;
75+
// Presence is not enough: stale/missing service assets report installed but will not
76+
// respawn after exit(1). Dashboard status/recovery must fall through to detached start.
77+
return (io.isServiceViable ?? isServiceViable)();
7178
}
7279

7380
/** Stable, path-free spawn failure label for logs (never interpolate err.message). */
@@ -137,7 +144,7 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
137144
schedule(async () => {
138145
const drain = io.drainAndShutdown ?? drainAndShutdown;
139146
await drain(undefined, MEMORY_DRAIN_RESTART_MS);
140-
const supervised = (io.isSupervisedServiceChild ?? isSupervisedServiceChild)();
147+
const supervised = (io.isSupervisedServiceChild ?? (() => isSupervisedServiceChild(io)))();
141148
if (supervised) {
142149
// Failure-only supervisors ignore exit(0); intentional non-zero triggers respawn.
143150
(io.exitProcess ?? ((code: number) => { process.exit(code); }))(1);

src/server/port-reclaim.ts

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
* Reclaim a listen port after stop/update so restart can stay on the configured
33
* port instead of hopping to an ephemeral one (Windows CLOSE_WAIT / leftover ocx).
44
*
5-
* Killing is never the default: a process may be killed only when the caller
6-
* supplies a non-empty explicit PID allowlist for a process it just stopped.
5+
* Killing is never the default. A process may be killed only when the caller
6+
* sets `killOcxHolders` and either supplies a non-empty `onlyKillPids` allowlist
7+
* (trusted teardown PIDs, including allowlisted holders that fail ocx revalidate)
8+
* or enables `killAllOcxOnPort` for revalidated ocx listeners. Unknown foreign
9+
* (non-ocx, non-allowlisted) processes are never killed.
710
*/
811
import { execFileSync } from "node:child_process";
912
import { verifyPidIdentity } from "../config";
@@ -19,13 +22,24 @@ export type ReclaimListenPortOptions = WaitForPortOptions & {
1922
/**
2023
* When true AND `onlyKillPids` is a non-empty allowlist, those PIDs may be
2124
* killed after revalidation. Default false — never kill without an allowlist.
25+
* When {@link killAllOcxOnPort} is also true, any ocx listener on this port
26+
* may be killed even if it is not in `onlyKillPids`.
2227
*/
2328
killOcxHolders?: boolean;
2429
/**
2530
* Explicit PIDs the caller just stopped / hard-killed. An omitted or empty
26-
* list means no process may be killed.
31+
* list means no process may be killed — unless {@link killAllOcxOnPort} is set.
2732
*/
2833
onlyKillPids?: number[];
34+
/**
35+
* When true with `killOcxHolders`, every live ocx listener on this port may be
36+
* killed (re-checked each scan). Used by post-update restart so a Windows
37+
* service wrapper that respawns a *new* bun PID mid-reclaim cannot stay
38+
* protected just because it was absent from the pre-wait allowlist snapshot.
39+
* Never kills foreign (non-ocx) processes — only allowlisted teardown PIDs
40+
* and revalidated ocx listeners.
41+
*/
42+
killAllOcxOnPort?: boolean;
2943
/**
3044
* On Windows, force-delete IPv4 TCP rows for this local port via SetTcpEntry.
3145
* Default true on win32. Never kills foreign processes, never runs while a
@@ -157,9 +171,9 @@ export function listListenPids(port: number): number[] {
157171

158172
/**
159173
* Wait until `port` can bind.
160-
* Never kills a process unless `killOcxHolders === true` and `onlyKillPids` is a
161-
* non-empty allowlist of PIDs the caller itself just stopped — then revalidates
162-
* immediately before each kill.
174+
* Never kills a process unless `killOcxHolders === true` and either
175+
* `onlyKillPids` is a non-empty allowlist or `killAllOcxOnPort` is set — then
176+
* revalidates immediately before each kill.
163177
* Never kills foreign processes. Never drops TCP rows while a live foreign or
164178
* protected ocx listener owns the port, or when the listener scan failed.
165179
*/
@@ -174,7 +188,9 @@ export async function reclaimListenPort(
174188
const allowedKillPids = new Set(
175189
(opts.onlyKillPids ?? []).filter(pid => Number.isSafeInteger(pid) && pid > 0),
176190
);
177-
const mayKill = opts.killOcxHolders === true && allowedKillPids.size > 0;
191+
const killAllOcx = opts.killAllOcxOnPort === true;
192+
const mayKill = opts.killOcxHolders === true
193+
&& (allowedKillPids.size > 0 || killAllOcx);
178194
const dropTcpRows = opts.dropTcpRows ?? process.platform === "win32";
179195
const listFn = opts.listListenPidsFn ?? scanListenPids;
180196
const isAliveFn = opts.isAliveFn ?? isProcessAlive;
@@ -207,20 +223,44 @@ export async function reclaimListenPort(
207223

208224
for (const pid of scan.pids) {
209225
if (pid === process.pid) continue;
210-
if (!isAliveFn(pid)) continue; // Windows may still list a dead owner briefly
226+
if (!isAliveFn(pid)) {
227+
// Clear "already tried" so a later respawn that reuses this PID slot
228+
// is not skipped (Windows service :loop / npm rename respawns).
229+
killed.delete(pid);
230+
continue; // Windows may still list a dead owner briefly
231+
}
211232
const isOcx = verifyOcxFn(pid) === pid;
233+
const allowlisted = allowedKillPids.has(pid);
234+
// Pre-update PIDs can fail verify while still LISTENing (dead owner still
235+
// listed, or cmdline probe raced). Allowlisted teardown PIDs may be killed;
236+
// unknown foreign claimants must remain fail-closed.
212237
if (!isOcx) {
238+
if (mayKill && allowlisted) {
239+
if (!killed.has(pid)) {
240+
try {
241+
killFn(pid);
242+
killed.add(pid);
243+
} catch {
244+
// Kill failed: never SetTcpEntry while the process may still own the port.
245+
protectedOcxListener = true;
246+
}
247+
}
248+
if (!isAliveFn(pid)) killed.delete(pid);
249+
else protectedOcxListener = true;
250+
continue;
251+
}
213252
foreignLive = true;
214253
continue;
215254
}
216-
if (!mayKill || !allowedKillPids.has(pid)) {
255+
const mayKillThis = allowlisted || killAllOcx;
256+
if (!mayKill || !mayKillThis) {
217257
// Healthy / intentional ocx proxy — never steal its port.
218258
protectedOcxListener = true;
219259
continue;
220260
}
221261
if (!killed.has(pid)) {
222262
// Revalidate immediately before termination.
223-
if (isAliveFn(pid) && verifyOcxFn(pid) === pid && allowedKillPids.has(pid)) {
263+
if (isAliveFn(pid) && verifyOcxFn(pid) === pid && mayKillThis) {
224264
try {
225265
killFn(pid);
226266
killed.add(pid);
@@ -233,8 +273,14 @@ export async function reclaimListenPort(
233273
protectedOcxListener = true;
234274
}
235275
}
236-
// Only reset TCP rows after confirmed process death.
237-
if (isAliveFn(pid)) protectedOcxListener = true;
276+
// Respawning supervisors (Windows service :loop) mint a new PID after each
277+
// kill — clear the per-PID "already tried" bit once the process is gone so a
278+
// later child with a reused slot is not skipped, and keep reclaiming while live.
279+
if (!isAliveFn(pid)) {
280+
killed.delete(pid);
281+
} else {
282+
protectedOcxListener = true;
283+
}
238284
}
239285

240286
if (foreignLive || protectedOcxListener) {

src/server/ports.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export function isAddrInUse(err: unknown): boolean {
1616
export async function isPortAvailable(port: number, hostname = "127.0.0.1"): Promise<boolean> {
1717
return await new Promise(resolve => {
1818
const server = createServer();
19+
// Fail closed: EACCES / EADDRNOTAVAIL / EPERM / unknown listen errors mean the
20+
// requested bind is not available. Only the listening event reports free.
1921
server.once("error", () => resolve(false));
2022
server.once("listening", () => {
2123
server.close(() => resolve(true));

src/server/windows-tcp-drop.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ export type WindowsTcpDropResult = {
7979
dropped: number;
8080
/** IPv6 (or unparseable) rows skipped — never coerced into IPv4 wildcards. */
8181
skippedIpv6: number;
82+
/**
83+
* SetTcpEntry returned ERROR_ACCESS_DENIED (5) or the UAC-non-elevated code (317).
84+
* On a normal (non-admin) update worker this is expected — ghost LISTEN rows must
85+
* clear by waiting for the OS, not by SetTcpEntry.
86+
*/
87+
accessDenied: number;
8288
};
8389

8490
function htons(port: number): number {
@@ -133,21 +139,22 @@ function readNetstatAno(): string {
133139
*/
134140
export function dropWindowsTcpRowsForLocalPort(port: number): WindowsTcpDropResult {
135141
if (process.platform !== "win32" || !Number.isFinite(port) || port <= 0) {
136-
return { dropped: 0, skippedIpv6: 0 };
142+
return { dropped: 0, skippedIpv6: 0, accessDenied: 0 };
137143
}
138144
const setTcpEntry = loadSetTcpEntry();
139-
if (!setTcpEntry) return { dropped: 0, skippedIpv6: 0 };
145+
if (!setTcpEntry) return { dropped: 0, skippedIpv6: 0, accessDenied: 0 };
140146

141147
let output = "";
142148
try {
143149
output = readNetstatAno();
144150
} catch {
145-
return { dropped: 0, skippedIpv6: 0 };
151+
return { dropped: 0, skippedIpv6: 0, accessDenied: 0 };
146152
}
147153

148154
const rows = parseTcpQuadsForLocalPort(output, Math.trunc(port));
149155
let dropped = 0;
150156
let skippedIpv6 = 0;
157+
let accessDenied = 0;
151158
for (const row of rows) {
152159
const localDw = ipv4ToWinUint32(row.localAddr);
153160
const remoteDw = ipv4ToWinUint32(row.remoteAddr);
@@ -165,10 +172,13 @@ export function dropWindowsTcpRowsForLocalPort(port: number): WindowsTcpDropResu
165172
view.setUint32(12, remoteDw, true);
166173
view.setUint32(16, htons(row.remotePort), true);
167174
try {
168-
if (setTcpEntry(ptr(buf)) === 0) dropped += 1;
175+
const rc = setTcpEntry(ptr(buf));
176+
if (rc === 0) dropped += 1;
177+
// 5 = ERROR_ACCESS_DENIED, 317 = non-elevated SetTcpEntry (MSDN).
178+
else if (rc === 5 || rc === 317) accessDenied += 1;
169179
} catch {
170180
/* keep going */
171181
}
172182
}
173-
return { dropped, skippedIpv6 };
183+
return { dropped, skippedIpv6, accessDenied };
174184
}

src/service.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1862,6 +1862,15 @@ export function isServiceInstalled(): boolean {
18621862
return diagnoseService().installed;
18631863
}
18641864

1865+
/**
1866+
* True when an installed background service can actually supervise the proxy.
1867+
* Presence alone is not enough: stale/missing assets, conflicts, and disabled
1868+
* registrations report `installed` but will not bring the proxy back after exit.
1869+
*/
1870+
export function isServiceViable(): boolean {
1871+
return diagnoseService().viable;
1872+
}
1873+
18651874
export interface ServiceDiagnostic {
18661875
supported: boolean;
18671876
installed: boolean;

0 commit comments

Comments
 (0)