Skip to content

Commit b1f33ab

Browse files
authored
Merge pull request lidge-jun#805 from lidge-jun/fix/764-winsw-env-and-liveness-probe
fix(service): bake WinSW admin env and retry stop-path probes
2 parents cd9ad73 + 3e95ba3 commit b1f33ab

7 files changed

Lines changed: 176 additions & 20 deletions

File tree

src/lib/winsw.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,19 @@ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = proces
8787
})();
8888
// Services never bake `--port 0` (parsePortOption rejects it); treat as default.
8989
const safeListenPort = listenPort > 0 && listenPort <= 65535 ? listenPort : 10100;
90+
// SCM services do not inherit the interactive user environment (#764). Bake:
91+
// - OPENCODEX_HOME so file-backed admin auth (`admin-api-token`) resolves
92+
// - OPENCODEX_ACL_TIMEOUT_MS when set (not a secret)
93+
// Never embed OPENCODEX_ADMIN_AUTH_TOKEN or OPENCODEX_API_AUTH_TOKEN values in XML —
94+
// those stay file-pointer / generated-file only (uninstall retains the XML).
95+
const aclTimeout = env.OPENCODEX_ACL_TIMEOUT_MS?.trim();
9096
const envLines = [
9197
` <env name="OCX_SERVICE" value="1"/>`,
9298
` <env name="OCX_API_TOKEN_FILE" value="${xmlEscape(serviceApiTokenFilePath())}"/>`,
9399
` <env name="PATH" value="${xmlEscape(env.PATH ?? "")}"/>`,
94100
env.CODEX_HOME?.trim() ? ` <env name="CODEX_HOME" value="${xmlEscape(currentCodexHomeAbsolute())}"/>` : null,
95-
env.OPENCODEX_HOME?.trim() ? ` <env name="OPENCODEX_HOME" value="${xmlEscape(getConfigDir())}"/>` : null,
101+
` <env name="OPENCODEX_HOME" value="${xmlEscape(getConfigDir())}"/>`,
102+
aclTimeout ? ` <env name="OPENCODEX_ACL_TIMEOUT_MS" value="${xmlEscape(aclTimeout)}"/>` : null,
96103
].filter((line): line is string => Boolean(line));
97104
return `<?xml version="1.0" encoding="UTF-8"?>
98105
<service>

src/server/proxy-liveness.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,29 @@ export interface LivenessIo {
3030
readRuntimeFn?: (pid?: number) => { pid?: number; port: number; hostname?: string } | null;
3131
configFn?: () => { port?: number; hostname?: string };
3232
timeoutMs?: number;
33+
/**
34+
* How many times to retry a probe that failed with a transport error (timeout /
35+
* connection refused). Definitive answers (non-OK HTTP, foreign /healthz body, pid
36+
* mismatch) do not retry. Default 1 = no retry. Stop paths should pass 2–3 (#764).
37+
*/
38+
attempts?: number;
39+
sleepFn?: (ms: number) => Promise<void>;
40+
/**
41+
* Absolute wall-clock deadline for discovery. When set, each probe attempt aborts
42+
* once the remaining budget cannot cover another fetch — so multi-candidate
43+
* `findLiveProxy` under `SERVICE_STOP_LIVENESS` cannot overrun the stop-path
44+
* verification window (#764 / CodeRabbit).
45+
*/
46+
deadlineAt?: number;
47+
nowFn?: () => number;
3348
}
3449

50+
/** Default probe options for service stop / orphan cleanup — a just-bound proxy can miss a single 750ms probe. */
51+
export const SERVICE_STOP_LIVENESS: Pick<LivenessIo, "timeoutMs" | "attempts"> = {
52+
timeoutMs: 1500,
53+
attempts: 3,
54+
};
55+
3556
export interface LiveProxy {
3657
pid: number | null;
3758
port: number;
@@ -72,19 +93,37 @@ export async function proxyIdentityAt(
7293
io: LivenessIo = {},
7394
): Promise<{ pid: number | null } | null> {
7495
const fetchFn = io.fetchFn ?? fetch;
75-
try {
76-
const res = await fetchFn(`http://${probeHostname(opts.hostname)}:${port}/healthz`, {
77-
signal: AbortSignal.timeout(io.timeoutMs ?? 750),
78-
});
79-
if (!res.ok) return null;
80-
const body = (await res.json().catch(() => null)) as HealthzIdentity | null;
81-
if (!isOpencodexHealthz(body)) return null;
82-
const pid = typeof body?.pid === "number" ? body.pid : null;
83-
if (opts.expectedPid !== undefined && pid !== null && pid !== opts.expectedPid) return null;
84-
return { pid };
85-
} catch {
86-
return null;
96+
const sleepFn = io.sleepFn ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
97+
const nowFn = io.nowFn ?? Date.now;
98+
const baseTimeoutMs = io.timeoutMs ?? 750;
99+
const requestedAttempts = Math.trunc(io.attempts ?? 1);
100+
const attempts = Number.isNaN(requestedAttempts)
101+
? 1
102+
: Math.max(1, Math.min(requestedAttempts, 5));
103+
104+
for (let attempt = 1; attempt <= attempts; attempt++) {
105+
const remainingMs = io.deadlineAt === undefined ? baseTimeoutMs : io.deadlineAt - nowFn();
106+
if (remainingMs <= 0) return null;
107+
const timeoutMs = Math.min(baseTimeoutMs, remainingMs);
108+
try {
109+
const res = await fetchFn(`http://${probeHostname(opts.hostname)}:${port}/healthz`, {
110+
signal: AbortSignal.timeout(timeoutMs),
111+
});
112+
if (!res.ok) return null;
113+
const body = (await res.json().catch(() => null)) as HealthzIdentity | null;
114+
if (!isOpencodexHealthz(body)) return null;
115+
const pid = typeof body?.pid === "number" ? body.pid : null;
116+
if (opts.expectedPid !== undefined && pid !== null && pid !== opts.expectedPid) return null;
117+
return { pid };
118+
} catch {
119+
// Transport failure (timeout / refused) — retry while budget remains; a proxy that
120+
// has only just begun listening can miss a single short probe (#764).
121+
if (attempt >= attempts) return null;
122+
if (io.deadlineAt !== undefined && io.deadlineAt - nowFn() <= 0) return null;
123+
await sleepFn(100);
124+
}
87125
}
126+
return null;
88127
}
89128

90129
/**

src/service.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* restore it via the command.
77
*/
88
import { execFileSync, execSync } from "node:child_process";
9-
import { findLiveProxy } from "./server/proxy-liveness";
9+
import { findLiveProxy, SERVICE_STOP_LIVENESS } from "./server/proxy-liveness";
1010
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
1111
import { homedir } from "node:os";
1212
import { dirname, join, resolve } from "node:path";
@@ -1723,11 +1723,18 @@ export async function proxyStillLiveAfterStop(deps: {
17231723
/** Whether the stopped supervisor can respawn its child; only then is polling worth the wait. */
17241724
canRespawn?: boolean;
17251725
} = {}): Promise<{ port: number } | null> {
1726-
const findProxy = deps.findProxy ?? findLiveProxy;
17271726
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
17281727
const now = deps.now ?? Date.now;
17291728
const canRespawn = deps.canRespawn ?? process.platform === "win32";
17301729
const deadline = now() + (canRespawn ? 7000 : 0);
1730+
// Single-shot (non-respawn) still needs one full SERVICE_STOP_LIVENESS budget; respawn
1731+
// polling shares the outer deadline so multi-candidate discovery cannot overrun it.
1732+
const findProxy = deps.findProxy ?? (() => {
1733+
const probeDeadline = canRespawn
1734+
? deadline
1735+
: now() + (SERVICE_STOP_LIVENESS.timeoutMs! * SERVICE_STOP_LIVENESS.attempts! + 250);
1736+
return findLiveProxy({ ...SERVICE_STOP_LIVENESS, deadlineAt: probeDeadline, nowFn: now });
1737+
});
17311738
for (;;) {
17321739
try {
17331740
const live = await findProxy();
@@ -1755,7 +1762,11 @@ async function stopTrackedProxyIfRunning(): Promise<TrackedProxyCleanupResult> {
17551762
}
17561763
// Orphan recovery: the pid file can be missing/stale while the service wrapper keeps
17571764
// a live proxy running — mirror `ocx stop`'s identity-checked findLiveProxy fallback.
1758-
const live = await findLiveProxy({ timeoutMs: 1500 });
1765+
// Cap multi-candidate discovery so stop cleanup cannot hang for three full retry budgets.
1766+
const live = await findLiveProxy({
1767+
...SERVICE_STOP_LIVENESS,
1768+
deadlineAt: Date.now() + 7000,
1769+
});
17591770
const liveKillPid = verifiedKillTarget(live?.pid);
17601771
if (liveKillPid !== null) {
17611772
await stopProxy(liveKillPid);

tests/api-storage-cleanup.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
1+
import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test";
22
import { managementFetch as fetch } from "./helpers/management-auth";
33
import { Database } from "bun:sqlite";
44
import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
@@ -9,6 +9,10 @@ import { startServer } from "../src/server";
99
import type { OcxConfig } from "../src/types";
1010
import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home";
1111

12+
// Windows CI under load can spend >5s just binding the proxy + previewing cleanup;
13+
// Bun's default test budget then fails the suite before the assertion runs.
14+
setDefaultTimeout(20_000);
15+
1216
let testDir = "";
1317
let previousHome: string | undefined;
1418
let isolatedCodexHome: IsolatedCodexHome | null = null;

tests/proxy-liveness.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,70 @@ describe("proxyIdentityAt", () => {
5151
expect(await proxyIdentityAt(10100, { expectedPid: 1 }, { fetchFn: (async () => healthz(OURS)) as typeof fetch })).toBeNull();
5252
expect(await proxyIdentityAt(10100, {}, { fetchFn: (async () => { throw new Error("refused"); }) as typeof fetch })).toBeNull();
5353
});
54+
55+
test("retries transport failures and succeeds on a later attempt (#764)", async () => {
56+
let calls = 0;
57+
const sleeps: number[] = [];
58+
const identity = await proxyIdentityAt(10100, {}, {
59+
attempts: 3,
60+
timeoutMs: 50,
61+
sleepFn: async (ms) => { sleeps.push(ms); },
62+
fetchFn: (async () => {
63+
calls += 1;
64+
if (calls < 3) throw new Error("timeout");
65+
return healthz(OURS);
66+
}) as typeof fetch,
67+
});
68+
expect(identity).toEqual({ pid: 4242 });
69+
expect(calls).toBe(3);
70+
expect(sleeps).toEqual([100, 100]);
71+
});
72+
73+
test("does not retry a definitive foreign /healthz body", async () => {
74+
let calls = 0;
75+
const identity = await proxyIdentityAt(10100, {}, {
76+
attempts: 3,
77+
fetchFn: (async () => {
78+
calls += 1;
79+
return healthz({ ok: true });
80+
}) as typeof fetch,
81+
});
82+
expect(identity).toBeNull();
83+
expect(calls).toBe(1);
84+
});
85+
86+
test("NaN attempts fall back to a single probe", async () => {
87+
let calls = 0;
88+
const identity = await proxyIdentityAt(10100, {}, {
89+
attempts: Number.NaN,
90+
fetchFn: (async () => {
91+
calls += 1;
92+
return healthz(OURS);
93+
}) as typeof fetch,
94+
});
95+
expect(identity).toEqual({ pid: 4242 });
96+
expect(calls).toBe(1);
97+
});
98+
99+
test("honors an aggregate deadline across transport retries", async () => {
100+
let calls = 0;
101+
let clock = 1_000;
102+
const identity = await proxyIdentityAt(10100, {}, {
103+
attempts: 3,
104+
timeoutMs: 1_500,
105+
deadlineAt: 1_000 + 1_200,
106+
nowFn: () => clock,
107+
sleepFn: async () => { clock += 100; },
108+
fetchFn: (async () => {
109+
calls += 1;
110+
clock += 1_500;
111+
throw new Error("timeout");
112+
}) as typeof fetch,
113+
});
114+
expect(identity).toBeNull();
115+
// First attempt spends the budget; remaining retries must not fire.
116+
expect(calls).toBe(1);
117+
});
54118
});
55119

56120
describe("findLiveProxy", () => {

tests/service.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -661,10 +661,12 @@ describe("service lifecycle cleanup ordering", () => {
661661
expect(service).toContain('verifyPidIdentity');
662662
expect(service).toContain("removeRuntimePort(pid);");
663663
expect(service).toContain('import { isProcessAlive, stopProxy } from "./lib/process-control";');
664-
expect(service).toContain('import { findLiveProxy } from "./server/proxy-liveness";');
664+
expect(service).toContain('import { findLiveProxy, SERVICE_STOP_LIVENESS } from "./server/proxy-liveness";');
665665
expect(service).toContain('type TrackedProxyCleanupResult = "none" | "stale" | "stopped";');
666666
expect(service).toContain("async function stopTrackedProxyIfRunning(): Promise<TrackedProxyCleanupResult>");
667-
expect(service).toContain("await findLiveProxy({ timeoutMs: 1500 })");
667+
expect(service).toContain("...SERVICE_STOP_LIVENESS");
668+
expect(service).toContain("deadlineAt:");
669+
expect(service).toContain("SERVICE_STOP_LIVENESS");
668670
expect(service).toContain("await stopProxy(trackedKillPid);");
669671
expect(service).toContain("await stopProxy(liveKillPid);");
670672
expect(service).toContain("removePid(pid);");

tests/winsw.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,23 @@ import { describe, expect, test } from "bun:test";
22
import { buildWinswXml, ensureWinswBinary, parseWinswStatus, probeScmRegistration, sha256Hex, installWinswService, statusWinswRaw, WINSW_SHA256, WINSW_SERVICE_ID } from "../src/lib/winsw";
33
import { parseServiceArgs, serviceReinstallArgs } from "../src/service";
44
import { loadServiceTokenFromFile } from "../src/lib/service-secrets";
5+
import { getConfigDir } from "../src/config";
56
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
67
import { tmpdir } from "node:os";
78
import { join } from "node:path";
89

910
const entry = { bun: "C:\\OpenCodex\\bun.exe", cli: "C:\\Open Codex\\cli & co\\index.ts" };
1011

12+
function winswEnvValue(xml: string, name: string): string | null {
13+
const match = xml.match(new RegExp(`<env name="${name}" value="([^"]*)"/>`));
14+
if (!match) return null;
15+
return match[1]!
16+
.replace(/&amp;/g, "&")
17+
.replace(/&lt;/g, "<")
18+
.replace(/&gt;/g, ">")
19+
.replace(/&quot;/g, '"');
20+
}
21+
1122
describe("winsw xml", () => {
1223
const env = { USERDOMAIN: "WORKGROUP", USERNAME: "jun", PATH: "C:\\bin;C:\\tools & more" } as NodeJS.ProcessEnv;
1324

@@ -30,8 +41,26 @@ describe("winsw xml", () => {
3041
expect(xml).toContain('<env name="OCX_SERVICE" value="1"/>');
3142
expect(xml).toContain('<env name="OCX_API_TOKEN_FILE"');
3243
expect(xml).toContain('<env name="PATH" value="C:\\bin;C:\\tools &amp; more"/>');
33-
// The token VALUE never lands in the XML — only the file pointer.
44+
expect(winswEnvValue(xml, "OPENCODEX_HOME")).toBe(getConfigDir());
45+
// Token VALUES never land in the XML — only file pointers / non-secret budgets.
46+
expect(xml).not.toContain("OPENCODEX_API_AUTH_TOKEN");
47+
expect(xml).not.toContain("OPENCODEX_ADMIN_AUTH_TOKEN");
48+
});
49+
50+
test("bakes install-time ACL timeout and never embeds the admin token (#764)", () => {
51+
const xml = buildWinswXml(entry, {
52+
...env,
53+
OPENCODEX_API_AUTH_TOKEN: "api-secret-value",
54+
OPENCODEX_ADMIN_AUTH_TOKEN: "admin-secret & more",
55+
OPENCODEX_ACL_TIMEOUT_MS: "10000",
56+
});
57+
58+
expect(xml).toContain('<env name="OPENCODEX_ACL_TIMEOUT_MS" value="10000"/>');
59+
expect(winswEnvValue(xml, "OPENCODEX_HOME")).toBe(getConfigDir());
60+
expect(xml).not.toContain("OPENCODEX_ADMIN_AUTH_TOKEN");
3461
expect(xml).not.toContain("OPENCODEX_API_AUTH_TOKEN");
62+
expect(xml).not.toContain("admin-secret");
63+
expect(xml).not.toContain("api-secret-value");
3564
});
3665

3766
test("escapes executable/arguments and configures restart + graceful stop", () => {

0 commit comments

Comments
 (0)