Files
hyperframes/packages/producer/src/services/healthWorker.ts
T
James RussoandJerrai 01a10cdc53 fix(producer): serve /health from a worker_thread so probes survive main-thread stalls (#1733)
* fix(producer): serve /health from a worker_thread so probes survive main-thread stalls

Adds an off-main-thread /health endpoint that listens on its own port
(default 9848, env PRODUCER_HEALTH_PORT). The endpoint binds inside a
Node worker_thread with a minimal node:http server — separate event loop,
separate isolate — so probe responses don't depend on whatever the
producer's main thread is doing.

Why now
-------

Today's hyperframes-producer crashloop traced to an infinite GSAP
timeline -> distributed planner trying to enumerate ~300,000,000,000
frames -> sidecar /health stops landing within k8s's 5s window ->
otherwise-healthy pods killed.

Miguel is shipping the root-cause fix at plan() time (impossible /
non-finite / sentinel durations get rejected before chunk planning).
That removes today's wedge.

This change is defense-in-depth for the kill mechanism. Even with the
plan() guard, future wedge classes can stall the main event loop for
seconds at a time: large synchronous file I/O (see the companion
fileServer streaming PR), GC pauses on long-running renders, tight
loops in user-authored GSAP / Three.js / canvas code, future
activity / pool changes whose runtime cost we haven't yet characterized.

Probe responsiveness should reflect process liveness, not main-thread
event-loop responsiveness. If the entire Node process is dead the OS
tears down both threads' sockets simultaneously and k8s correctly kills
the pod. Anything short of that and the worker thread's listener keeps
answering.

Backwards-compatible: the main-thread /health on PRODUCER_PORT (9847)
keeps working exactly as before. The k8s sidecar probe config in
heygen-com/app can migrate to the worker port at its own pace. A
companion heygen-com/app PR in this batch raises the probe timeout
from 5s -> 30s as a last-resort backstop.

TODO: link Miguel's upstream plan() duration guard PR once known.

Test: healthWorker.test.ts (vitest) — 3 tests pass locally, including
the load-bearing one: stays responsive while the main thread is blocked
on a 500ms sync busy-spin.

— Jerrai

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(producer): tighten healthWorker startup race + shutdown semantics

Addresses Miga's review on #1733.

- server.ts: store the worker as a Promise<HealthWorkerHandle | null>
  instead of mutating a `let` from inside `.then`. A SIGTERM landing
  before the `.then` callback fired would previously see `healthWorker
  === null` and skip cleanup. shutdown() now `await`s the promise with
  a bounded 1.5s timeout so a hung-startup worker can't keep SIGTERM
  waiting (worker.terminate() from process exit still kills it).
- healthWorkerThread.ts: replace `process.exit()` inside the worker
  with `parentPort.close()` + natural event-loop drain. Node-version
  semantics for `process.exit()` from a worker have been historically
  inconsistent; the documented clean path is to close the channel and
  let the worker exit naturally. Also drops the redundant 2s force-exit
  on shutdown — the parent already owns the authoritative deadline via
  Promise.race + worker.terminate(), so the worker-side timer was
  belt-and-suspenders noise.

Co-Authored-By: Jerrai <noreply@anthropic.com>

— Jerrai

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 08:47:49 -07:00

187 lines
6.9 KiB
TypeScript

/**
* Worker-thread health endpoint.
*
* Runs an HTTP server (`/health`, returns 200 OK with uptime+timestamp JSON)
* on a *separate* Node worker_thread so probe responses don't depend on the
* main event loop. The main thread can be deep in a long-running synchronous
* task (Chrome teardown, large file I/O, GC pause, the post-Miguel guard
* "impossible duration" math, etc.) and this endpoint still answers within
* milliseconds because it lives in a different V8 isolate with its own
* event loop.
*
* Why this matters
* ----------------
*
* The producer sidecar's k8s `livenessProbe` / `readinessProbe` hit `/health`
* on the Hono server in the main thread. Any synchronous stall longer than
* the probe `timeoutSeconds` (5s in prod prior to the companion change)
* triggers a SIGKILL even when the process is still alive — just busy.
*
* Today's incident (2026-06-26): an infinite GSAP timeline caused the
* distributed planner to try to enumerate ~300_000_000_000 frames, and
* the sidecar got killed mid-arithmetic. Miguel's upstream `plan()`
* duration guard kills that input class at the source. This module is
* defense-in-depth: future wedge classes (sync I/O on video-heavy comps,
* runaway loops, GC pauses) shouldn't kill an otherwise-alive pod either.
*
* Contract
* --------
*
* - The worker thread binds an HTTP listener on
* `PRODUCER_HEALTH_PORT` (default 9848) for `/health` only.
* - Liveness in the worker thread = "the worker_thread itself is responsive",
* which is a strict subset of process liveness. If the entire Node process
* is dead the OS tears down both threads' sockets simultaneously, so the
* worker_thread's listener stops answering and k8s correctly kills the pod.
* - Listening on `0.0.0.0` is intentional: this is the probe entry point and
* the sidecar already exposes other ports for in-pod traffic only. The
* endpoint returns the same shape the main-thread `/health` always returned
* so existing observability keeps working.
*
* The main thread still serves `/health` on the main port (9847) for
* backwards compatibility; k8s probe config in `heygen-com/app` can migrate
* to the worker port at its own pace.
*/
import { Worker } from "node:worker_threads";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { existsSync } from "node:fs";
export interface HealthWorkerOptions {
/** Port the worker_thread health endpoint listens on. Default 9848 / env. */
port?: number;
/**
* Optional logger; falls back to console. Note: the worker thread itself
* cannot use the parent's logger directly (separate isolate), so it logs
* via `console`. The handle returned here uses the provided logger for
* lifecycle events on the *main* thread.
*/
logger?: {
info?: (msg: string, meta?: Record<string, unknown>) => void;
warn?: (msg: string, meta?: Record<string, unknown>) => void;
error?: (msg: string, meta?: Record<string, unknown>) => void;
};
/**
* Override worker entry module. Falls back to a co-located
* `healthWorkerThread.js` (post-build) or `.ts` (dev/test).
*/
workerEntry?: string;
}
export interface HealthWorkerHandle {
/** Port the listener is bound to (resolved). */
port: number;
/** Stop the listener + terminate the worker thread. Idempotent. */
shutdown: () => Promise<void>;
}
const DEFAULT_HEALTH_PORT = 9848;
/**
* Spawn the health worker_thread. Returns once the worker reports its
* listener is up (or rejects if the worker fails to start).
*/
export async function startHealthWorker(
options: HealthWorkerOptions = {},
): Promise<HealthWorkerHandle> {
const log = options.logger ?? defaultLogger();
const port =
options.port ?? parseInt(process.env.PRODUCER_HEALTH_PORT ?? String(DEFAULT_HEALTH_PORT), 10);
const entry = options.workerEntry ?? resolveWorkerEntry();
if (!entry) {
throw new Error(
"[healthWorker] could not resolve worker entry. " +
"Pass options.workerEntry or ensure healthWorkerThread.{js,ts} is co-located.",
);
}
const worker = new Worker(entry, {
workerData: { port },
// Keep stdio inherited so the worker's console logs land in the pod logs
// alongside the main thread's.
stdout: false,
stderr: false,
});
// Wait for the worker to report "listening" before resolving, so callers
// can be sure the probe endpoint is actually up.
await new Promise<void>((resolve, reject) => {
const onMessage = (msg: { type: string; error?: string }) => {
if (msg?.type === "listening") {
worker.off("error", onError);
worker.off("message", onMessage);
resolve();
} else if (msg?.type === "listen-error") {
worker.off("error", onError);
worker.off("message", onMessage);
reject(new Error(`[healthWorker] failed to bind port ${port}: ${msg.error}`));
}
};
const onError = (err: Error) => {
worker.off("error", onError);
worker.off("message", onMessage);
reject(err);
};
worker.on("message", onMessage);
worker.on("error", onError);
});
log.info?.(`[healthWorker] /health listening on worker thread, port ${port}`);
let shutdownPromise: Promise<void> | null = null;
const shutdown = (): Promise<void> => {
if (shutdownPromise) return shutdownPromise;
shutdownPromise = (async () => {
try {
worker.postMessage({ type: "shutdown" });
// Give the worker a beat to close its server cleanly. terminate()
// is the hard backstop.
await Promise.race([
new Promise<void>((res) => worker.once("exit", () => res())),
new Promise<void>((res) => setTimeout(res, 2_000)),
]);
} finally {
await worker.terminate().catch(() => {});
}
})();
return shutdownPromise;
};
// If the worker crashes unexpectedly, log loudly. We don't auto-respawn
// here — the k8s probe will catch a dead listener and the pod will be
// restarted, which is the right behavior for a truly-broken process.
worker.on("error", (err: Error) => {
log.error?.(`[healthWorker] worker thread error: ${err.message}`);
});
worker.on("exit", (code) => {
if (code !== 0) {
log.warn?.(`[healthWorker] worker thread exited with code ${code}`);
}
});
return { port, shutdown };
}
function defaultLogger() {
return {
info: (msg: string) => console.log(msg),
warn: (msg: string) => console.warn(msg),
error: (msg: string) => console.error(msg),
};
}
/**
* Try to find the co-located worker entry file. Prefer the compiled `.js`
* (production) and fall back to the `.ts` source (dev / vitest via tsx).
*/
function resolveWorkerEntry(): string | undefined {
const here = dirname(fileURLToPath(import.meta.url));
const candidates = [join(here, "healthWorkerThread.js"), join(here, "healthWorkerThread.ts")];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
return undefined;
}