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
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ runs helper features around provider requests.
| `visionSidecar?` | `OcxVisionSidecarConfig` | on when usable | Image-description sidecar options. |
| `images?` | `OcxImagesConfig` | automatic OpenAI selection | Standalone Images relay options for Codex `image_gen`. |

On Linux x64/arm64 with pidfd support, service health recovery binds termination to the proxy's
kernel process identity, so PID reuse cannot redirect it. The sentinel's watching log ends in
`(pidfd)`. Unsupported kernels or architectures retain the existing live-parent PID check and log
that fallback; other binding failures disable recovery for that proxy. macOS retains the live-parent
check, and Windows retains its process handle binding.

While the canonical ChatGPT upstream WebSocket waits for the first Responses event after
sending the create frame, it watches for liveness rather than a fixed deadline. When the socket
supports protocol pings, the proxy pings it every 15 seconds. Any inbound frame — quota,
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1638,7 +1638,7 @@
"server-xai-responses-streaming.test.ts": "server",
"service-auth-qualified-localhost.test.ts": "service",
"service-child-ownership.test.ts": "service",
"service-handled-signal-exit.test.ts": "service", "service-health-sentinel-launch.test.ts": "service", "service-health-sentinel-policy.test.ts": "service", "service-health-sentinel-process.test.ts": "service", "service-health-sentinel-runner.test.ts": "service",
"service-handled-signal-exit.test.ts": "service", "service-health-sentinel-linux.test.ts": "service", "service-health-sentinel-launch.test.ts": "service", "service-health-sentinel-policy.test.ts": "service", "service-health-sentinel-process.test.ts": "service", "service-health-sentinel-runner.test.ts": "service",
"service-claim.test.ts": "service",
"service-ownership-compatibility.test.ts": "service",
"service-ownership-handover.test.ts": "service",
Expand Down
112 changes: 112 additions & 0 deletions src/service/health-sentinel-linux.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/** Linux pidfd ownership for the opt-in sentinel. Loaded only on Linux. */
import { dlopen, FFIType, read } from "bun:ffi";
import type { TerminateResult } from "./health-sentinel-main";

const ESRCH = 3;
const ENOSYS = 38;
// Syscalls from 424 onward share one number on every Linux architecture. Binding is limited to
// x64/arm64 because the FFI signature below assumes their LP64 integer-register calling convention.
const SYS_PIDFD_OPEN = 434;
const SYS_PIDFD_SEND_SIGNAL = 424;

type CallResult = { value: number; errno: number };
export type PidfdSyscalls = {
open(pid: number): CallResult;
signal(fd: number, signal: number): CallResult;
close(fd: number): void;
dispose(): void;
};
export type ParentPidfd = { terminate(): TerminateResult; close(): void };
export type ParentPidfdResult =
| { kind: "bound"; handle: ParentPidfd }
| { kind: "unsupported"; reason: string }
| { kind: "refused"; reason: string };

function nativeSyscalls(): PidfdSyscalls {
const symbols = {
// syscall is variadic, but these syscalls use integer/pointer arguments only. All supplied
// slots are machine-word integers (including zero for siginfo and flags), on these LP64 ABIs.
syscall: { args: [FFIType.i64, FFIType.i64, FFIType.i64, FFIType.i64, FFIType.i64], returns: FFIType.i64 },
__errno_location: { args: [], returns: FFIType.ptr },
close: { args: [FFIType.i32], returns: FFIType.i32 },
} as const;
const load = (path: string) => dlopen(path, symbols);
let libc: ReturnType<typeof load>;
try {
libc = load("libc.so.6");
} catch {
// musl exports libc symbols through its loader; it has no glibc soname or pidfd wrappers.
libc = load(`/lib/ld-musl-${process.arch === "x64" ? "x86_64" : "aarch64"}.so.1`);
}
const call = (nr: number, a: number, b: number, c = 0, d = 0): CallResult => {
const value = Number(libc.symbols.syscall(nr, a, b, c, d));
return { value, errno: value === -1 ? read.i32(libc.symbols.__errno_location()!) : 0 };
};
return {
open: pid => call(SYS_PIDFD_OPEN, pid, 0),
signal: (fd, signal) => call(SYS_PIDFD_SEND_SIGNAL, fd, signal),
// Never retry close on Linux: even an EINTR has released the descriptor.
close: fd => { libc.symbols.close(fd); },
dispose: () => libc.close(),
};
}

/** Acquire before arming; reparenting during acquisition invalidates even a successfully opened fd. */
export function openParentPidfd(
pid: number,
isParent: () => boolean,
options: { arch?: string; load?: () => PidfdSyscalls } = {},
): ParentPidfdResult {
if (!isParent()) return { kind: "refused", reason: "the sentinel is not the watched process's child" };
const arch = options.arch ?? process.arch;
if (arch !== "x64" && arch !== "arm64") return { kind: "unsupported", reason: `pidfd syscall ABI is unsupported on ${arch}` };
let api: PidfdSyscalls;
try { api = (options.load ?? nativeSyscalls)(); }
catch { return { kind: "refused", reason: "Linux pidfd libc binding is unavailable" }; }
let fd: number | null = null;
let closed = false;
let signalled = false;
const close = () => {
if (closed) return;
closed = true;
try { if (fd !== null) api.close(fd); } finally { api.dispose(); }
};
const refuse = (reason: string): ParentPidfdResult => { close(); return { kind: "refused", reason }; };
try {
const opened = api.open(pid);
if (opened.value < 0) {
close();
return opened.errno === ENOSYS
? { kind: "unsupported", reason: "pidfd_open is unsupported by this kernel" }
: { kind: "refused", reason: `pidfd_open failed (errno ${opened.errno})` };
}
fd = opened.value;
if (!isParent()) return refuse("the watched parent exited during pidfd acquisition");
// Check both syscall support and permission before arming, without sending a signal.
const probe = api.signal(fd, 0);
if (probe.value < 0) {
close();
return probe.errno === ENOSYS
? { kind: "unsupported", reason: "pidfd_send_signal is unsupported by this kernel" }
: { kind: "refused", reason: `pidfd signal check failed (errno ${probe.errno})` };
}
if (!isParent()) return refuse("the watched parent exited during pidfd verification");
return {
kind: "bound",
handle: {
close,
terminate: () => {
if (closed || signalled) return "not_target";
// A throwing binding must settle the recorded intent as "failed", like process.kill does.
let sent: CallResult;
try { sent = api.signal(fd!, 9); } catch { return "failed"; }
if (sent.value < 0) return sent.errno === ESRCH ? "not_target" : "failed";
signalled = true;
return "terminated";
},
},
};
} catch {
return refuse("Linux pidfd binding failed");
}
}
133 changes: 76 additions & 57 deletions src/service/health-sentinel-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@
* - Windows reuses ids quickly, so the sentinel opens a handle on its parent at startup, accepts it
* only if that process was created before the sentinel, and terminates through the handle
* (health-sentinel-windows.ts). While the handle is open the id cannot be reused.
* - POSIX allocates ids sequentially, and the sentinel's live `process.ppid` changes the moment
* its parent exits, before the exited parent can be reaped and its id freed. It is the last check
* before `kill`. Reuse inside that window needs the parent to exit, be reaped, and the id space to
* wrap completely (99,999 ids on macOS, `pid_max` on Linux) between two adjacent syscalls; that
* residual is accepted and documented in structure/ops/service-health-recovery.md.
* - Linux holds a pidfd after rechecking the live parent relationship, and signals that fd.
* Only unsupported kernels/architectures retain the live-ppid fallback used by macOS.
* - macOS checks the live parent id immediately before SIGKILL; PID reuse between those two
* syscalls remains a documented residual (structure/ops/service-health-recovery.md).
*
* SIGKILL rather than SIGTERM: a proxy whose event loop is stuck cannot run its own SIGTERM
* handler, and systemd counts a SIGTERM death as a clean exit that `Restart=on-failure` does not
Expand Down Expand Up @@ -80,6 +79,8 @@ export type HealthSentinelDeps = {
isTargetGone(): boolean;
/** Bind to the target once at startup; a failure stands the sentinel down. */
bindTarget(): { ok: true } | { ok: false; reason: string };
/** Release the process owner on every exit, refusal, cancellation or exception. */
closeTarget(): void;
/** Terminate the bound target, re-proving it is still the target at the last moment. */
terminateTarget(): TerminateResult;
/** Run under the ownership mutation lease, released on return; "busy" when it could not be taken promptly. */
Expand Down Expand Up @@ -149,6 +150,11 @@ function matchesProbeTarget(runtime: SentinelRuntimeRecord | null, pid: number,
}

export async function runHealthSentinel(deps: HealthSentinelDeps): Promise<HealthSentinelResult> {
try { return await observeHealthSentinel(deps); }
finally { deps.closeTarget(); }
}

async function observeHealthSentinel(deps: HealthSentinelDeps): Promise<HealthSentinelResult> {
const timing: HealthSentinelTiming = { ...DEFAULT_HEALTH_SENTINEL_TIMING, ...deps.timing };
const standDown = (reason: string): HealthSentinelResult => {
deps.log(`standing down: ${reason} (pid ${deps.targetPid})`);
Expand Down Expand Up @@ -407,59 +413,72 @@ export async function runHealthSentinelProcess(env: NodeJS.ProcessEnv = process.
if (opened.ok) windowsHandle = opened.handle;
else windowsBindFailure = opened.reason;
}
let linuxHandle: import("./health-sentinel-linux").ParentPidfd | null = null;
let linuxBindFailure: string | null = null;
if (process.platform === "linux") {
const { openParentPidfd } = await import("./health-sentinel-linux");
const opened = openParentPidfd(targetPid, () => !gone && process.ppid === targetPid);
if (opened.kind === "bound") linuxHandle = opened.handle;
else if (opened.kind === "refused") linuxBindFailure = opened.reason;
else appendBoundedLog(logPath, `pidfd unavailable: ${opened.reason}; using live parent PID check`);
}
const lag = startLagMonitor();
const result = await runHealthSentinel({
targetPid,
now: Date.now,
sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
probe: async (target, timeoutMs) => {
lag.reset();
const outcome = await probeHealth(target, timeoutMs);
return outcome.kind === "timeout" ? { ...outcome, sentinelLagMs: lag.read() } : outcome;
},
readRuntime: () => readSentinelRuntime(runtimePath),
targetExited,
isTargetGone: () => gone || (windowsHandle?.exited() ?? false),
bindTarget: () => {
// POSIX: Bun reads the parent id live, and it stops naming the proxy once the proxy exits.
// Windows: it is the creating parent's id, so the handle below carries the identity.
if (process.ppid !== targetPid) return { ok: false, reason: "the sentinel is not the watched process's child" };
if (process.platform !== "win32") return { ok: true };
return windowsHandle ? { ok: true } : { ok: false, reason: windowsBindFailure ?? "the parent process cannot be opened" };
},
terminateTarget: () => {
if (process.platform === "win32") {
if (!windowsHandle) return "failed";
return windowsHandle.terminate(1) ? "terminated" : "failed";
}
if (gone || process.ppid !== targetPid) return "not_target";
try {
process.kill(targetPid, "SIGKILL");
return "terminated";
} catch {
return "failed";
}
},
withOwnershipLease: run => {
let lease: { release(): void };
try {
lease = acquireOwnershipMutationLease(statePaths, { waitMs: LEASE_WAIT_MS, processImage: () => null });
} catch {
return "busy";
}
try { return run(); } finally { lease.release(); }
},
readHistory: () => {
const entries = readRecoveryEntries(historyPath);
return entries === null ? null : countedRecoveryTimes(entries);
},
writeHistory: entry => writeRecoveryEntry(historyPath, entry),
log: line => appendBoundedLog(logPath, line),
timing: parseTimingOverride(env[HEALTH_SENTINEL_ENV.timing]),
});
lag.stop();
windowsHandle?.close();
void result;
try {
await runHealthSentinel({
targetPid,
now: Date.now,
sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
probe: async (target, timeoutMs) => {
lag.reset();
const outcome = await probeHealth(target, timeoutMs);
return outcome.kind === "timeout" ? { ...outcome, sentinelLagMs: lag.read() } : outcome;
},
readRuntime: () => readSentinelRuntime(runtimePath),
targetExited,
closeTarget: () => { linuxHandle?.close(); windowsHandle?.close(); },
isTargetGone: () => gone || (windowsHandle?.exited() ?? false),
bindTarget: () => {
// POSIX: Bun reads the parent id live, and it stops naming the proxy once the proxy exits.
// Windows: it is the creating parent's id, so the handle below carries the identity.
if (process.ppid !== targetPid) return { ok: false, reason: "the sentinel is not the watched process's child" };
if (linuxBindFailure) return { ok: false, reason: linuxBindFailure };
if (process.platform !== "win32") return { ok: true };
return windowsHandle ? { ok: true } : { ok: false, reason: windowsBindFailure ?? "the parent process cannot be opened" };
},
terminateTarget: () => {
if (process.platform === "win32") {
if (!windowsHandle) return "failed";
return windowsHandle.terminate(1) ? "terminated" : "failed";
}
if (gone || process.ppid !== targetPid) return "not_target";
if (linuxHandle) return linuxHandle.terminate();
try {
process.kill(targetPid, "SIGKILL");
return "terminated";
} catch {
return "failed";
}
},
withOwnershipLease: run => {
let lease: { release(): void };
try {
lease = acquireOwnershipMutationLease(statePaths, { waitMs: LEASE_WAIT_MS, processImage: () => null });
} catch {
return "busy";
}
try { return run(); } finally { lease.release(); }
},
readHistory: () => {
const entries = readRecoveryEntries(historyPath);
return entries === null ? null : countedRecoveryTimes(entries);
},
writeHistory: entry => writeRecoveryEntry(historyPath, entry),
log: line => appendBoundedLog(logPath, linuxHandle && line.startsWith("watching pid ") ? `${line} (pidfd)` : line),
timing: parseTimingOverride(env[HEALTH_SENTINEL_ENV.timing]),
});
} finally {
lag.stop();
}
return 0;
}

Expand Down
1 change: 1 addition & 0 deletions structure/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ A source area can be described by more than one doc, because these docs are orga
| `src/service/` | [`runtime.md`](runtime.md) |
| `src/service/desktop-startup.ts` | [`desktop-start-at-login.md`](desktop-start-at-login.md) |
| `src/service/health-sentinel-launch.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) |
| `src/service/health-sentinel-linux.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) |
| `src/service/health-sentinel-main.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) |
| `src/service/health-sentinel-policy.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) |
| `src/service/health-sentinel-probe.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) |
Expand Down
1 change: 1 addition & 0 deletions structure/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,7 @@
"documents": [
"src/service/health-sentinel-launch.ts",
"src/service/health-sentinel-main.ts",
"src/service/health-sentinel-linux.ts",
"src/service/health-sentinel-policy.ts",
"src/service/health-sentinel-probe.ts",
"src/service/health-sentinel-windows.ts"
Expand Down
27 changes: 22 additions & 5 deletions structure/ops/service-health-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Probes accept only numeric IPv4/IPv6 destinations. Default and localhost binds u

## Binding the kill to the proxy

`src/service/health-sentinel-main.ts` never kills a bare number:
`src/service/health-sentinel-main.ts` selects the process binding before arming recovery:

- Windows opens a kernel32 handle on the parent at startup through `bun:ffi`
(`src/service/health-sentinel-windows.ts`), accepts it only if that process was created before the
Expand All @@ -66,10 +66,24 @@ Probes accept only numeric IPv4/IPv6 destinations. Default and localhost binds u
far enough that the new process appears to be older than the sentinel, the handle binds to that
process and a later recovery would terminate it. A handle created by the proxy and inherited by
the sentinel would close this gap; Bun's spawn does not offer one today.
- POSIX checks the live `process.ppid` immediately before SIGKILL. SIGTERM cannot run on a stuck
loop and is a clean exit to systemd. This binding is probabilistic: if the sentinel is descheduled
between that check and `kill` while the parent exits, is reaped, and a new process receives the
same id, the signal reaches that process. Bun exposes no pidfd or process handle on POSIX.
- Linux opens a pidfd through libc `syscall` using `bun:ffi`
(`src/service/health-sentinel-linux.ts`) on x64 and arm64, then checks that the watched PID is
still its live parent while holding the descriptor. A signal-zero call verifies signal support
and permissions before arming; the parent relationship is checked again afterward. Recovery
uses `pidfd_send_signal(SIGKILL)` on that descriptor, so PID reuse cannot redirect it. An exited
target (`ESRCH`) is skipped. A bound signal failure never falls back to a numeric PID.
`ENOSYS` from either startup syscall, or an unsupported syscall architecture, selects the existing
live-parent fallback and logs the limitation. Permission/resource/identity errors and unavailable
libc bindings refuse recovery. Both glibc and musl libc bindings are supported without new deps.
The watching line ends with `(pidfd)` when the binding succeeds.
- macOS, and Linux without pidfd support, check the live `process.ppid` immediately before
SIGKILL. This fallback is probabilistic: if the sentinel is descheduled between the check and
`kill` while the parent exits, is reaped, and its PID is reused, the signal can reach that process.
SIGTERM cannot run on a stuck loop and is a clean exit to systemd.

The runner releases its process owner in `finally`, including binding refusal, EOF, failed
recovery and unexpected exceptions. Pidfd close is idempotent and prevents further signals;
Linux close is never retried because a reused descriptor could name an unrelated object.

The lease-held runtime-record re-read and the durable `intent` write run under the service ownership
mutation lease (`src/service/ownership-mutation-lease.mjs`, 500 ms wait, no subprocess); a busy lease
Expand All @@ -90,3 +104,6 @@ replayed: requests the hung process held are lost as in a crash.
phase-aligned recurring stalls), `-runner` (ordering, fail-closed paths, lease), `-launch` (gate,
environment, drain hook, config admission) and `-process` (the real launcher and sentinel against a
fixture that stops answering, on every CI OS).
`tests/service/service-health-sentinel-linux.test.ts` covers injected syscall errors, reparenting
during acquisition, PID reuse, already-exited targets, repeated recovery, teardown and descriptor
cleanup. Linux additionally exercises the real FFI syscalls against a disposable process.
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"codex-history-pending-wal.test.ts": "codex-integration",
"compaction-history-framing.test.ts": "responses",
"service-log.test.ts": "service",
"service-health-sentinel-linux.test.ts": "service",
"service-state-isolation.test.ts": "service",
"owner-registry-isolation.test.ts": "config",
"service-health-sentinel-policy.test.ts": "service",
Expand Down
Loading
Loading