Skip to content

Commit 7586a22

Browse files
committed
fix(liveness): bound stop probes and harden WinSW secret tests
Stop-path discovery now shares a wall-clock deadline so multi-candidate SERVICE_STOP_LIVENESS retries cannot overrun the verification window, and tests assert resolved OPENCODEX_HOME plus absent API/admin token values.
1 parent 4ef32b0 commit 7586a22

5 files changed

Lines changed: 82 additions & 8 deletions

File tree

src/server/proxy-liveness.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ export interface LivenessIo {
3737
*/
3838
attempts?: number;
3939
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;
4048
}
4149

4250
/** Default probe options for service stop / orphan cleanup — a just-bound proxy can miss a single 750ms probe. */
@@ -86,10 +94,17 @@ export async function proxyIdentityAt(
8694
): Promise<{ pid: number | null } | null> {
8795
const fetchFn = io.fetchFn ?? fetch;
8896
const sleepFn = io.sleepFn ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
89-
const timeoutMs = io.timeoutMs ?? 750;
90-
const attempts = Math.max(1, Math.min(Math.trunc(io.attempts ?? 1), 5));
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));
91103

92104
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);
93108
try {
94109
const res = await fetchFn(`http://${probeHostname(opts.hostname)}:${port}/healthz`, {
95110
signal: AbortSignal.timeout(timeoutMs),
@@ -104,6 +119,7 @@ export async function proxyIdentityAt(
104119
// Transport failure (timeout / refused) — retry while budget remains; a proxy that
105120
// has only just begun listening can miss a single short probe (#764).
106121
if (attempt >= attempts) return null;
122+
if (io.deadlineAt !== undefined && io.deadlineAt - nowFn() <= 0) return null;
107123
await sleepFn(100);
108124
}
109125
}

src/service.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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(SERVICE_STOP_LIVENESS));
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(SERVICE_STOP_LIVENESS);
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/proxy-liveness.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,39 @@ describe("proxyIdentityAt", () => {
8282
expect(identity).toBeNull();
8383
expect(calls).toBe(1);
8484
});
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+
});
85118
});
86119

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

tests/service.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -664,8 +664,8 @@ describe("service lifecycle cleanup ordering", () => {
664664
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(SERVICE_STOP_LIVENESS)");
668-
expect(service).toContain("(() => findLiveProxy(SERVICE_STOP_LIVENESS))");
667+
expect(service).toContain("...SERVICE_STOP_LIVENESS");
668+
expect(service).toContain("deadlineAt:");
669669
expect(service).toContain("SERVICE_STOP_LIVENESS");
670670
expect(service).toContain("await stopProxy(trackedKillPid);");
671671
expect(service).toContain("await stopProxy(liveKillPid);");

tests/winsw.test.ts

Lines changed: 16 additions & 2 deletions
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,7 +41,7 @@ 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-
expect(xml).toContain('<env name="OPENCODEX_HOME"');
44+
expect(winswEnvValue(xml, "OPENCODEX_HOME")).toBe(getConfigDir());
3445
// Token VALUES never land in the XML — only file pointers / non-secret budgets.
3546
expect(xml).not.toContain("OPENCODEX_API_AUTH_TOKEN");
3647
expect(xml).not.toContain("OPENCODEX_ADMIN_AUTH_TOKEN");
@@ -39,14 +50,17 @@ describe("winsw xml", () => {
3950
test("bakes install-time ACL timeout and never embeds the admin token (#764)", () => {
4051
const xml = buildWinswXml(entry, {
4152
...env,
53+
OPENCODEX_API_AUTH_TOKEN: "api-secret-value",
4254
OPENCODEX_ADMIN_AUTH_TOKEN: "admin-secret & more",
4355
OPENCODEX_ACL_TIMEOUT_MS: "10000",
4456
});
4557

4658
expect(xml).toContain('<env name="OPENCODEX_ACL_TIMEOUT_MS" value="10000"/>');
47-
expect(xml).toContain('<env name="OPENCODEX_HOME"');
59+
expect(winswEnvValue(xml, "OPENCODEX_HOME")).toBe(getConfigDir());
4860
expect(xml).not.toContain("OPENCODEX_ADMIN_AUTH_TOKEN");
61+
expect(xml).not.toContain("OPENCODEX_API_AUTH_TOKEN");
4962
expect(xml).not.toContain("admin-secret");
63+
expect(xml).not.toContain("api-secret-value");
5064
});
5165

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

0 commit comments

Comments
 (0)