diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 48bc8a8fd0..a9e64fc1af 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -354,8 +354,17 @@ export function readConnectedClaudeContextWindows(path = DEFAULT_CATALOG_PATH): } } -async function ensureProxyForClaude(): Promise { - const live = await findLiveProxy(); +export type ClaudeProxyEnsureDeps = { + findLiveProxy?: typeof findLiveProxy; +}; + +export async function ensureProxyForClaude(deps: ClaudeProxyEnsureDeps = {}): Promise { + // A proxy that has only just bound can miss a single probe while its event loop + // is still settling startup work — the same just-started race the stop paths + // already retry for (#764, SERVICE_STOP_LIVENESS). Only the attempts budget is + // borrowed here; the probe timeout remains DEFAULT_PROBE_TIMEOUT_MS (750 ms). + // Without this, `ocx claude` can spawn a second proxy while the first is serving. + const live = await (deps.findLiveProxy ?? findLiveProxy)({ attempts: 3 }); if (live) return live.port; const cfgPort = loadConfig().port; const pinPort = typeof cfgPort === "number" && cfgPort > 0 ? cfgPort : 10100; diff --git a/tests/claude-cli.test.ts b/tests/claude-cli.test.ts index e33fb38035..8381b99e84 100644 --- a/tests/claude-cli.test.ts +++ b/tests/claude-cli.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { buildClaudeEnv, claudeNotFoundHint, rootSkipPermissionsNotice, shouldAllowRootSkipPermissions } from "../src/cli/claude"; +import { buildClaudeEnv, claudeNotFoundHint, ensureProxyForClaude, rootSkipPermissionsNotice, shouldAllowRootSkipPermissions } from "../src/cli/claude"; import { commandInvocation } from "../src/lib/win-exec"; +import type { LivenessIo, LiveProxy } from "../src/server/proxy-liveness"; import type { OcxConfig } from "../src/types"; function cfg(extra?: Partial): OcxConfig { @@ -25,6 +26,20 @@ const AUTH_PRESENT = { }, }; +describe("ocx claude proxy liveness", () => { + test("retries the initial liveness probe before spawning a proxy", async () => { + const seen: (number | undefined)[] = []; + const findLiveProxy = async (io?: LivenessIo): Promise => { + seen.push(io?.attempts); + // retry semantics are covered by tests/proxy-liveness.test.ts:102-119; this pins that the launcher hands the stop-path budget down. + return { pid: 4242, port: 10100, source: "runtime" }; + }; + + expect(await ensureProxyForClaude({ findLiveProxy })).toBe(10100); + expect(seen).toEqual([3]); + }); +}); + describe("ocx claude env assembly", () => { test("connected target injects only the hub base and client admission token", () => { const env = buildClaudeEnv(cfg(), {