From ec1eace1269d610fa875134889c20bf009efd736 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:38:02 +0200 Subject: [PATCH 1/4] fix: keep configured port after update restart and stop Windows console flashes Pin post-update proxy restart to config.port, wait briefly for the listen socket to free, and avoid spawning visible PowerShell/icacls windows on the start hot path. --- src/cli/index.ts | 9 +++++-- src/config.ts | 46 +++++++++++++++++++++++++++++++++-- src/lib/process-control.ts | 2 +- src/lib/windows-secret-acl.ts | 4 +++ src/server/ports.ts | 46 +++++++++++++++++++++++++++++++++-- src/server/proxy-liveness.ts | 6 +++-- src/update/job.ts | 35 ++++++++++++++++++-------- tests/ports.test.ts | 37 +++++++++++++++++++++++++++- tests/update-job.test.ts | 11 +++++++++ 9 files changed, 176 insertions(+), 20 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 85d43f7f..1086d737 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -87,7 +87,12 @@ async function waitForProxy(timeoutMs = 8_000): Promise { async function chooseListenPort(requestedPort?: number): Promise { const config = loadConfig(); const preferred = requestedPort ?? config.port ?? 10100; - const selected = await findAvailablePort(preferred, config.hostname ?? "127.0.0.1"); + // Brief prefer-retry covers stop→start races (update restart, `ocx restart`) where the + // old process has exited but the listen socket is still draining. + const selected = await findAvailablePort(preferred, config.hostname ?? "127.0.0.1", { + preferRetryMs: 750, + preferRetryIntervalMs: 50, + }); if (selected !== preferred) { console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`); } @@ -587,7 +592,7 @@ switch (command) { const jobId = args[1]; if (!jobId) process.exit(1); const channel = normalizeUpdateChannel(args[2]); - runGuiUpdateWorker(jobId, channel, args[3] === "restart"); + await runGuiUpdateWorker(jobId, channel, args[3] === "restart"); break; } case "restart": { diff --git a/src/config.ts b/src/config.ts index 41cce6d6..7afbf818 100644 --- a/src/config.ts +++ b/src/config.ts @@ -825,26 +825,68 @@ export function isOcxStartCommandLine(commandLine: string): boolean { return hasOcxEntrypoint && /(?:^|[\s"'])start(?:$|[\s"'])/.test(normalized); } +/** Per-process memo: waitForProxy/findLiveProxy used to spawn powershell on every 150ms poll. */ +const ocxStartProcessCache = new Map(); + function isLikelyOcxStartProcess(pid: number): boolean { + const cached = ocxStartProcessCache.get(pid); + if (cached !== undefined) return cached; const commandLine = readProcessCommandLine(pid); if (commandLine === undefined) return false; - return isOcxStartCommandLine(commandLine); + const ok = isOcxStartCommandLine(commandLine); + ocxStartProcessCache.set(pid, ok); + return ok; +} + +/** + * Alive pid from the pid file without the expensive Windows command-line probe. + * Safe for liveness polls: callers still identity-check /healthz before trusting the proxy. + * Destructive stop/kill paths should keep using {@link readPid}, which verifies the cmdline. + */ +export function readAlivePid(): number | null { + const pid = readPidFileValue(); + if (pid === null) return null; + try { + process.kill(pid, 0); + return pid; + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code === "EPERM") return pid; + return null; + } } function readProcessCommandLine(pid: number): string | undefined { try { if (process.platform === "win32") { + // Prefer WMIC over PowerShell: much faster cold start, and windowsHide avoids console flash. + // Fall back to PowerShell when WMIC is absent (newer Windows images). + const wmic = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\wbem\\WMIC.exe`; + try { + const output = execFileSync(wmic, [ + "process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/VALUE", + ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true }); + const match = /^CommandLine=(.*)$/m.exec(output.replace(/\r/g, "")); + const value = match?.[1]?.trim(); + if (value) return value; + } catch { + /* WMIC missing or failed — fall through */ + } const output = execFileSync("powershell.exe", [ "-NoProfile", + "-NoLogo", + "-NonInteractive", + "-WindowStyle", + "Hidden", "-Command", `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`, - ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000 }); + ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true }); return output.trim() || undefined; } const output = execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000, + windowsHide: true, }); return output.trim() || undefined; } catch { diff --git a/src/lib/process-control.ts b/src/lib/process-control.ts index 81f03044..329a1fdb 100644 --- a/src/lib/process-control.ts +++ b/src/lib/process-control.ts @@ -100,7 +100,7 @@ export function killProxy(pid: number): void { if (process.platform === "win32") { const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`; try { - execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe" }); + execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe", windowsHide: true }); } catch (err) { if (isProcessAlive(pid)) throw err; } diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index 8a481d70..3ecd8e14 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -64,11 +64,13 @@ function runIcacls(targetPath: string, directory: boolean): void { throw new Error("Cannot determine current Windows user for ACL hardening"); } + // windowsHide: console-subsystem tools (icacls) otherwise flash a visible window per call. // Step 1: disable inheritance and remove inherited ACEs execFileSync("icacls.exe", [targetPath, "/inheritance:r"], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000, shell: false, + windowsHide: true, }); // Step 2: remove broad explicit grants using stable SIDs (not localized names). @@ -82,6 +84,7 @@ function runIcacls(targetPath: string, directory: boolean): void { stdio: ["ignore", "pipe", "ignore"], timeout: 5000, shell: false, + windowsHide: true, }); // Step 3: grant current user full control. @@ -90,6 +93,7 @@ function runIcacls(targetPath: string, directory: boolean): void { stdio: ["ignore", "pipe", "ignore"], timeout: 5000, shell: false, + windowsHide: true, }); } diff --git a/src/server/ports.ts b/src/server/ports.ts index 8f2dfd3b..e4f1d47f 100644 --- a/src/server/ports.ts +++ b/src/server/ports.ts @@ -24,8 +24,50 @@ export async function isPortAvailable(port: number, hostname = "127.0.0.1"): Pro }); } -export async function findAvailablePort(preferredPort: number, hostname = "127.0.0.1"): Promise { - if (await isPortAvailable(preferredPort, hostname)) return preferredPort; +export type WaitForPortOptions = { + timeoutMs?: number; + intervalMs?: number; +}; + +/** Poll until `port` accepts a bind, or until the timeout elapses. */ +export async function waitForPortAvailable( + port: number, + hostname = "127.0.0.1", + opts: WaitForPortOptions = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? 5000; + const intervalMs = opts.intervalMs ?? 50; + const deadline = Date.now() + timeoutMs; + for (;;) { + if (await isPortAvailable(port, hostname)) return true; + if (Date.now() >= deadline) return false; + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } +} + +export type FindAvailablePortOptions = { + /** How long to keep retrying the preferred port before falling back to an ephemeral port. */ + preferRetryMs?: number; + preferRetryIntervalMs?: number; +}; + +export async function findAvailablePort( + preferredPort: number, + hostname = "127.0.0.1", + opts: FindAvailablePortOptions = {}, +): Promise { + const preferRetryMs = opts.preferRetryMs ?? 0; + if (preferRetryMs > 0) { + if (await waitForPortAvailable(preferredPort, hostname, { + timeoutMs: preferRetryMs, + intervalMs: opts.preferRetryIntervalMs ?? 50, + })) { + return preferredPort; + } + } else if (await isPortAvailable(preferredPort, hostname)) { + return preferredPort; + } + return await new Promise((resolve, reject) => { const server = createServer(); server.once("error", reject); diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index 002bca2d..10095763 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -9,7 +9,7 @@ * * Lives outside cli.ts (which dispatches argv at module top level) so tests can import it. */ -import { loadConfig, readPid, readRuntimePort } from "../config"; +import { loadConfig, readAlivePid, readRuntimePort } from "../config"; export interface HealthzIdentity { service?: unknown; @@ -86,7 +86,9 @@ export async function proxyIdentityAt( * found and a foreign listener on the configured port is rejected. */ export async function findLiveProxy(io: LivenessIo = {}): Promise { - const readPidFn = io.readPidFn ?? readPid; + // Prefer the cheap alive-pid check: the Windows cmdline probe (WMIC/PowerShell) is too + // expensive for waitForProxy's 150ms poll loop, and /healthz identity is the real trust gate. + const readPidFn = io.readPidFn ?? readAlivePid; const readRuntimeFn = io.readRuntimeFn ?? readRuntimePort; const configFn = io.configFn ?? loadConfig; diff --git a/src/update/job.ts b/src/update/job.ts index 441bb2ed..d875783a 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -2,8 +2,9 @@ import { spawn, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { atomicWriteFile, getConfigDir, readPid } from "../config"; +import { atomicWriteFile, getConfigDir, loadConfig, readPid } from "../config"; import { killProxy } from "../lib/process-control"; +import { waitForPortAvailable } from "../server/ports"; import { isServiceInstalled } from "../service"; import { type Channel, @@ -158,18 +159,23 @@ export function restartCommand( serviceInstalled: boolean, installer: Installer, launcher = packageLauncherPath(), + port?: number, ): { mode: "service" | "proxy"; bin: string; args: string[]; display: string } { const mode = serviceInstalled ? "service" : "proxy"; + const pinPort = !serviceInstalled && typeof port === "number" && Number.isFinite(port) && port > 0; + const startArgs = pinPort + ? [launcher, "start", "--port", String(Math.trunc(port))] + : [launcher, "start"]; if (installer === "npm") { const bin = nodeBin(); - const args = serviceInstalled ? [launcher, "service", "install"] : [launcher, "start"]; + const args = serviceInstalled ? [launcher, "service", "install"] : startArgs; return { mode, bin, args, display: formatCommand(bin, args) }; } // bun/source installs: restart via the current runtime executable + package launcher (both real // .exe files), NOT the `ocx.cmd` shim. Spawning a `.cmd` shell-less throws EINVAL on Windows // Node/Bun ≥18.20/20.12 (CVE-2024-27980 hardening) — the same class the npm path (nodeBin) avoids. const bin = process.execPath; - const args = serviceInstalled ? [launcher, "service", "install"] : [launcher, "start"]; + const args = serviceInstalled ? [launcher, "service", "install"] : startArgs; return { mode, bin, args, display: formatCommand(bin, args) }; } @@ -264,8 +270,8 @@ function runLoggedCommand(job: UpdateJobState, bin: string, args: string[], time return { status: result.status, signal: result.signal }; } -function spawnDetachedStart(job: UpdateJobState, installer: Installer): void { - const cmd = restartCommand(false, installer); +function spawnDetachedStart(job: UpdateJobState, installer: Installer, port?: number): void { + const cmd = restartCommand(false, installer, packageLauncherPath(), port); const env = { ...process.env }; delete env.OCX_SERVICE; updateJob(job, {}, `$ ${cmd.display}`); @@ -278,9 +284,12 @@ function spawnDetachedStart(job: UpdateJobState, installer: Installer): void { child.unref(); } -function restartAfterUpdate(job: UpdateJobState): void { +async function restartAfterUpdate(job: UpdateJobState): Promise { const serviceInstalled = isServiceInstalled(); - const cmd = restartCommand(serviceInstalled, job.installer); + const config = loadConfig(); + const port = config.port ?? 10100; + const hostname = config.hostname ?? "127.0.0.1"; + const cmd = restartCommand(serviceInstalled, job.installer, packageLauncherPath(), port); if (serviceInstalled) { const result = runLoggedCommand(job, cmd.bin, cmd.args, RESTART_TIMEOUT_MS); if (result.status !== 0) { @@ -293,11 +302,17 @@ function restartAfterUpdate(job: UpdateJobState): void { if (pid) { updateJob(job, {}, `Stopping current proxy PID ${pid}.`); killProxy(pid); + // Windows taskkill can leave the listen socket busy briefly; wait only after a real + // kill so cold restarts are not charged a multi-second prefer-wait. + const freed = await waitForPortAvailable(port, hostname, { timeoutMs: 2000, intervalMs: 25 }); + if (!freed) { + updateJob(job, {}, `Port ${port} still busy after stop; starting with --port ${port} anyway.`); + } } - spawnDetachedStart(job, job.installer); + spawnDetachedStart(job, job.installer, port); } -export function runGuiUpdateWorker(jobId: string, channel: Channel, restart: boolean): void { +export async function runGuiUpdateWorker(jobId: string, channel: Channel, restart: boolean): Promise { let job = readUpdateJob(jobId); const check = checkForUpdate(channel); const now = new Date().toISOString(); @@ -362,7 +377,7 @@ export function runGuiUpdateWorker(jobId: string, channel: Channel, restart: boo if (restart) { job = updateJob(job, { status: "restarting" }, "Update installed. Restarting proxy..."); - restartAfterUpdate(job); + await restartAfterUpdate(job); updateJob(job, { status: "succeeded", restarted: true }, "Restart requested."); return; } diff --git a/tests/ports.test.ts b/tests/ports.test.ts index dcc61965..f3dd01ef 100644 --- a/tests/ports.test.ts +++ b/tests/ports.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createServer, type Server } from "node:net"; -import { findAvailablePort, isAddrInUse, isPortAvailable, shouldPersistSelectedPort } from "../src/server/ports"; +import { findAvailablePort, isAddrInUse, isPortAvailable, shouldPersistSelectedPort, waitForPortAvailable } from "../src/server/ports"; const servers: Server[] = []; @@ -54,6 +54,41 @@ describe("port selection", () => { expect(shouldPersistSelectedPort(10100, 10100, 10100)).toBe(false); }); + test("waitForPortAvailable resolves once a busy port is released", async () => { + const { server, port } = await listen(); + expect(await isPortAvailable(port)).toBe(false); + + const waiting = waitForPortAvailable(port, "127.0.0.1", { timeoutMs: 2000, intervalMs: 25 }); + await close(server); + const idx = servers.indexOf(server); + if (idx >= 0) servers.splice(idx, 1); + + await expect(waiting).resolves.toBe(true); + expect(await isPortAvailable(port)).toBe(true); + }); + + test("waitForPortAvailable returns false when the port stays busy past the timeout", async () => { + const { port } = await listen(); + await expect(waitForPortAvailable(port, "127.0.0.1", { timeoutMs: 80, intervalMs: 20 })).resolves.toBe(false); + expect(await isPortAvailable(port)).toBe(false); + }); + + test("findAvailablePort retries the preferred port briefly before falling back", async () => { + const { server, port } = await listen(); + expect(await isPortAvailable(port)).toBe(false); + + const pending = findAvailablePort(port, "127.0.0.1", { preferRetryMs: 500, preferRetryIntervalMs: 25 }); + // Free the preferred port during the retry window. + setTimeout(() => { + void close(server).then(() => { + const idx = servers.indexOf(server); + if (idx >= 0) servers.splice(idx, 1); + }); + }, 60); + + expect(await pending).toBe(port); + }); + test("isAddrInUse recognizes bind conflicts by code or message and rejects everything else", () => { expect(isAddrInUse(Object.assign(new Error("listen failed"), { code: "EADDRINUSE" }))).toBe(true); expect(isAddrInUse(new Error("listen EADDRINUSE: address already in use ::1:8123"))).toBe(true); diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index a0cb00c2..54546604 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -98,6 +98,17 @@ describe("GUI update execution decisions", () => { }); }); + test("proxy restart pins --port so post-update start does not hop to an ephemeral port", () => { + const proxy = restartCommand(false, "npm", "/pkg/bin/ocx.mjs", 10100); + expect(proxy.mode).toBe("proxy"); + expect(proxy.args).toEqual(["/pkg/bin/ocx.mjs", "start", "--port", "10100"]); + expect(proxy.display).toContain("start --port 10100"); + // Service reinstall keeps its own port contract — do not append --port. + expect(restartCommand(true, "npm", "/pkg/bin/ocx.mjs", 10100).args).toEqual([ + "/pkg/bin/ocx.mjs", "service", "install", + ]); + }); + test("a running job prevents a second update job", () => { const now = new Date().toISOString(); const job: UpdateJobState = { From 94adfd1f294026ba9acb17495e3cae845138a340 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:43:19 +0200 Subject: [PATCH 2/4] test: update Windows restart source contract for pinned --port startArgs --- tests/windows-deploy-close-regressions.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/windows-deploy-close-regressions.test.ts b/tests/windows-deploy-close-regressions.test.ts index 40c31b2b..5c60805c 100644 --- a/tests/windows-deploy-close-regressions.test.ts +++ b/tests/windows-deploy-close-regressions.test.ts @@ -17,7 +17,10 @@ describe("update-job restart avoids the shell-less .cmd EINVAL (Windows, bun/sou }); test("bun/source restart uses the runtime executable + launcher (a real .exe, no shell)", () => { // restartCommand's non-npm branch resolves to process.execPath + the package launcher. - expect(src).toMatch(/const bin = process\.execPath;\s*\n\s*const args = serviceInstalled \? \[launcher, "service", "install"\] : \[launcher, "start"\];/); + // Proxy mode may pin --port via startArgs; service mode stays install-only. + expect(src).toMatch(/const bin = process\.execPath;\s*\n\s*const args = serviceInstalled \? \[launcher, "service", "install"\] : startArgs;/); + expect(src).toContain('? [launcher, "start", "--port", String(Math.trunc(port))]'); + expect(src).toContain(': [launcher, "start"]'); }); }); From 8865dc2c53b90f8d344acf3aa1c39141acdd61bd Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:52:54 +0200 Subject: [PATCH 3/4] test: shrink usage-debug long-run loop so pre-push does not time out under load --- tests/usage-debug.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/usage-debug.test.ts b/tests/usage-debug.test.ts index 3fbce29f..00152e12 100644 --- a/tests/usage-debug.test.ts +++ b/tests/usage-debug.test.ts @@ -150,7 +150,10 @@ describe("appendUsageDebug", () => { }); test("keeps file size bounded by MAX_LINES across long runs", () => { - for (let i = 0; i < USAGE_DEBUG_MAX_LINES * 3; i++) { + // Cross the rotate threshold twice — enough to prove the bound holds across + // multiple rewrites without MAX*3 appends (that path times out under full-suite load). + const total = USAGE_DEBUG_MAX_LINES + USAGE_DEBUG_KEEP_LINES + 25; + for (let i = 0; i < total; i++) { appendUsageDebug(sample({ requestId: `ocx-${i}`, ts: i })); } const path = usageDebugPath(); From e91da0817361af872247b0b46e7904b4a0b9ee71 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:15:21 +0200 Subject: [PATCH 4/4] chore: leave Windows ACL harden for a separate PR Port stickiness stays scoped to restart/port behavior; icacls ACL soft-fail lives elsewhere. --- src/lib/windows-secret-acl.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index 3ecd8e14..8a481d70 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -64,13 +64,11 @@ function runIcacls(targetPath: string, directory: boolean): void { throw new Error("Cannot determine current Windows user for ACL hardening"); } - // windowsHide: console-subsystem tools (icacls) otherwise flash a visible window per call. // Step 1: disable inheritance and remove inherited ACEs execFileSync("icacls.exe", [targetPath, "/inheritance:r"], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000, shell: false, - windowsHide: true, }); // Step 2: remove broad explicit grants using stable SIDs (not localized names). @@ -84,7 +82,6 @@ function runIcacls(targetPath: string, directory: boolean): void { stdio: ["ignore", "pipe", "ignore"], timeout: 5000, shell: false, - windowsHide: true, }); // Step 3: grant current user full control. @@ -93,7 +90,6 @@ function runIcacls(targetPath: string, directory: boolean): void { stdio: ["ignore", "pipe", "ignore"], timeout: 5000, shell: false, - windowsHide: true, }); }