Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion src/server/proxy-liveness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>),
} 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;
Expand Down Expand Up @@ -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<string, unknown>),
} 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;
}
}
}
31 changes: 31 additions & 0 deletions tests/proxy-liveness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
});

Loading