-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix(cli): probe endpoint liveness in-process so the standalone resolve does not fork execPath #5418
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
318ecd1
e4d4d9d
ad66056
5416388
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,8 @@ export interface HealthzIdentity { | |
| guiPairCapability?: unknown; | ||
| } | ||
|
|
||
| export type EndpointLiveness = "live" | "dead" | "unknown"; | ||
|
|
||
| export interface LivenessIo { | ||
| fetchFn?: typeof fetch; | ||
| readPidFn?: () => number | null; | ||
|
|
@@ -85,6 +87,11 @@ export const START_OWNERSHIP_LIVENESS: Pick<LivenessIo, "timeoutMs" | "attempts" | |
| attempts: 3, | ||
| }; | ||
|
|
||
| type LivenessFetch = ( | ||
| input: string | URL | Request, | ||
| init?: RequestInit, | ||
| ) => Promise<Response>; | ||
|
|
||
| export interface LiveProxy { | ||
| pid: number | null; | ||
| port: number; | ||
|
|
@@ -148,6 +155,74 @@ export function isOpencodexHealthz(body: HealthzIdentity | null): boolean { | |
| return body.status === "ok" && typeof body.version === "string" && typeof body.uptime === "number"; | ||
| } | ||
|
|
||
| /** | ||
| * "Nothing is listening" is narrower than "the probe failed". Only a connect-phase refusal | ||
| * proves the endpoint is free; a timeout, reset, or other transport failure leaves the | ||
| * question open. | ||
| */ | ||
| export function isConnectionRefused(error: unknown): boolean { | ||
| const visit = (current: unknown, depth: number): boolean => { | ||
| if (depth >= 4) return false; | ||
| if (current === null || (typeof current !== "object" && typeof current !== "function")) return false; | ||
| const record = current as { code?: unknown; cause?: unknown; errors?: unknown }; | ||
| if (record.code === "ECONNREFUSED" || record.code === "ConnectionRefused") return true; | ||
| if (typeof record.code === "string" && record.code.endsWith("ECONNREFUSED")) return true; | ||
| if (Array.isArray(record.errors) && record.errors.length > 0) { | ||
|
Comment on lines
+168
to
+170
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a configured hostname resolves to multiple addresses, Useful? React with 👍 / 👎. |
||
| // One connect attempt fanned out over several addresses reports a single AggregateError. | ||
| // Only a unanimous refusal proves the endpoint is free: a bundle that mixes ECONNREFUSED | ||
| // with a timeout means one address answered nothing at all, and an address whose state is | ||
| // unreadable is unknown, not absence. Collapsing it to "refused" is how a second runtime | ||
| // gets started on a port that already has one. | ||
| return record.errors.every(error => visit(error, depth + 1)); | ||
| } | ||
| return visit(record.cause, depth + 1); | ||
| }; | ||
| return visit(error, 0); | ||
| } | ||
|
|
||
| async function classifyHealthz( | ||
| url: string, | ||
| fetchFn: LivenessFetch, | ||
| timeoutMs: number, | ||
| ): Promise<EndpointLiveness> { | ||
| try { | ||
| const response = await fetchFn(url, { signal: AbortSignal.timeout(timeoutMs) }); | ||
| if (response.status !== 200) return "unknown"; | ||
| const body = (await response.json().catch(() => undefined)) as HealthzIdentity | null | undefined; | ||
| if (body === undefined) return "unknown"; | ||
| return isOpencodexHealthz(body) ? "live" : "dead"; | ||
| } catch (error) { | ||
| return isConnectionRefused(error) ? "dead" : "unknown"; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Tri-state probe of one endpoint, the in-process counterpart of | ||
| * `src/update/proxy-liveness-probe.mjs`. Only a connect-phase refusal or a clean 200 that is | ||
| * not ours proves "dead"; a timeout, reset, non-200 or unreadable body leaves the question | ||
| * open. Loopback endpoints are checked on both IPv4 and IPv6 because a listener may bind only | ||
| * one family. Runs in-process because a compiled standalone binary cannot fork `execPath -e`. | ||
| */ | ||
| export async function probeEndpointLiveness( | ||
| endpoint: { port: number; hostname?: string }, | ||
| io: Pick<LivenessIo, "fetchFn" | "timeoutMs"> = {}, | ||
| ): Promise<EndpointLiveness> { | ||
| if (!Number.isFinite(endpoint.port) || endpoint.port <= 0 || endpoint.port > 65535) return "dead"; | ||
| const fetchFn = io.fetchFn ?? directLocalHttpFetch; | ||
| const timeoutMs = io.timeoutMs ?? 1500; | ||
| let sawUnknown = false; | ||
| for (const hostname of loopbackProbeHosts(endpoint.hostname)) { | ||
| const result = await classifyHealthz( | ||
| `http://${hostname}:${endpoint.port}/healthz`, | ||
| fetchFn, | ||
| timeoutMs, | ||
| ); | ||
| if (result === "live") return "live"; | ||
| if (result === "unknown") sawUnknown = true; | ||
| } | ||
| return sawUnknown ? "unknown" : "dead"; | ||
| } | ||
|
|
||
| /** Identity-checked /healthz probe; null when unreachable, non-OK, or not our proxy. */ | ||
| export async function proxyIdentityAt( | ||
| port: number, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This switches
ocx resolvetoprobeEndpointLivenessandeveryEndpointProvenDownAsync, butstructure/runtime.md:55still states that the contract uses the updater'sprobeProxyLivenessand synchronouseveryEndpointProvenDown. Update that owned structure document in this change so maintainers do not rely on an obsolete description of this launch-safety path.AGENTS.md reference: src/AGENTS.md:L11-L11
Useful? React with 👍 / 👎.