diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index 04dbd509307..64660c965b2 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -134,6 +134,24 @@ export async function proxyIdentityAt( const version = typeof body?.version === "string" ? body.version : undefined; return version === undefined ? { pid } : { pid, version }; } catch { + // TLS fallback: HTTP probe against an HTTPS-only listener (0.0.0.0 + tls) times out + // via directLocalHttpFetch (HTTP-only). One HTTPS try with native fetch per attempt. + try { + const httpsUrl = `https://${probeHostname(opts.hostname)}:${port}/healthz`; + const res = await fetch(httpsUrl, { + signal: AbortSignal.timeout(timeoutMs), + ...( { tls: { rejectUnauthorized: false } } as unknown as Record), + } as RequestInit & { tls?: unknown }); + if (!res.ok) return null; + const body = (await res.json().catch(() => null)) as HealthzIdentity | null; + if (!isOpencodexHealthz(body)) return null; + const pid = typeof body?.pid === "number" ? body.pid : null; + if (opts.expectedPid !== undefined && pid !== null && pid !== opts.expectedPid) return null; + const version = typeof body?.version === "string" ? body.version : undefined; + return version === undefined ? { pid } : { pid, version }; + } catch { + // HTTPS also unreachable — fall through to transport retry + } // Transport failure (timeout / refused) — retry while budget remains; a proxy that // has only just begun listening can miss a single short probe (#764). if (attempt >= attempts) return null; @@ -347,6 +365,21 @@ export async function probeReadiness( if (parsed.status !== "ready" && res.status !== 503) return null; return parsed; } catch { - return null; + // TLS fallback — same rationale as proxyIdentityAt + try { + const httpsUrl = `https://${probeHostname(opts.hostname)}:${port}/readyz`; + const res = await fetch(httpsUrl, { + signal: AbortSignal.timeout(io.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS), + ...( { tls: { rejectUnauthorized: false } } as unknown as Record), + } as RequestInit & { tls?: unknown }); + const body = (await res.json().catch(() => null)) as unknown; + const parsed = validateReadyzBody(body, port, opts); + if (!parsed) return null; + if (parsed.status === "ready" && res.status !== 200) return null; + if (parsed.status !== "ready" && res.status !== 503) return null; + return parsed; + } catch { + return null; + } } } diff --git a/tests/proxy-liveness.test.ts b/tests/proxy-liveness.test.ts index 7fd806fd626..7d07752f90b 100644 --- a/tests/proxy-liveness.test.ts +++ b/tests/proxy-liveness.test.ts @@ -736,3 +736,34 @@ describe("probeReadiness adversarial contract (never counts ready)", () => { expect(probe).toBeNull(); }); }); + +describe("TLS fallback (https:// on TLS-only listener)", () => { + test("proxyIdentityAt falls back to https when http throws", async () => { + const orig = globalThis.fetch; + try { + globalThis.fetch = (async (url: string | URL | Request) => { + if (String(url).startsWith("https:")) return healthz(OURS); + throw new Error("unreachable"); + }) as typeof fetch; + const identity = await proxyIdentityAt(10100, {}, { fetchFn: (async () => { throw new Error("http-only probe timed out"); }) as typeof fetch }); + expect(identity).toEqual({ pid: 4242, version: "2.6.17" }); + } finally { + globalThis.fetch = orig; + } + }); + + test("probeReadiness falls back to https when http throws", async () => { + const orig = globalThis.fetch; + try { + globalThis.fetch = (async (url: string | URL | Request) => { + if (String(url).startsWith("https:")) return readyz(READY_BODY, 200); + throw new Error("unreachable"); + }) as typeof fetch; + const probe = await probeReadiness(10100, {}, { fetchFn: (async () => { throw new Error("http-only probe timed out"); }) as typeof fetch }); + expect(probe).toEqual({ ready: true, status: "ready", pid: 4242, port: 10100 }); + } finally { + globalThis.fetch = orig; + } + }); +}); +