Summary
On a host that denies process enumeration, every hypit build that renders through hyperframes.local is reported as failed after the frames have already been rendered and the video encoded. The only thing that fails is the post-render cleanup probe.
A finished render is discarded, and --follow never prints Build complete.
Where
packages/provider-hyperframes-local/src/capture-process.ts
- L28 —
await exec("ps", ["-A", "-o", "pid=,ppid="], …) — descendant discovery
- L43 —
await exec("ps", ["-p", String(child), "-o", "stat="], …) — liveness probe
- L78–81 —
kill() turns any rejection from killRenderTree into failure = "Render cleanup failed; …", which close then rejects with
- L115 —
void kill() runs on the completed path, not only on error paths
Because of L115, the failure is not an edge case on this host: it is every Build.
Why ps fails here
macOS sandboxes — for example an agent/IDE host that applies a sandbox-exec profile — commonly deny process enumeration while still allowing libproc-backed queries:
$ ps -A
zsh: operation not permitted: /bin/ps
$ node -e 'require("node:child_process").execFile("ps",["-A"],()=>{})'
Error: spawn EPERM # thrown from child_process.spawn
$ pgrep -P $$
62917 # works
So execFile("ps", …) rejects before ps ever runs, and killRenderTree cannot distinguish "no descendants" from "cannot enumerate".
Evidence
Host: macOS 15.7 (24G222), arm64 · Node v22.22.2 · @hypit/hypit 0.1.9 · @hyperframes/engine 0.7.101
node bin/hypit.mjs build examples/semantic-composition/chat.svrun \
--workspace examples/semantic-composition \
--runtime examples/semantic-composition/hypit.runtime.json --follow
Execution log (hypit logs bld_20260915T160124951Z_58CF657E92), tail:
rendering frames
diagnostic: Capture worker 1: 120 frames; startup 730 ms; seek 101 ms; prepare 55 ms; PNG 2431 ms
diagnostic: Capture worker 0: 120 frames; startup 729 ms; seek 102 ms; prepare 58 ms; PNG 2449 ms
diagnostic: capturing frames: 2706 ms
encoding video
failed: Render cleanup failed; Error: spawn EPERM
hypit inspect:
Reason Command need:…:request-visual-render ended without a stored result:
Endpoint hyperframes.local failed render-visual: Render cleanup failed; Error: spawn EPERM
240/240 frames captured, encode started, Build discarded. The browser launch itself is healthy — [BrowserManager] Browser launched (HeadlessChrome/151.0.7922.71, screenshot, gl=--use-gl=angle --use-angle=metal, headlessShell=true, platform=darwin).
Two independent problems
-
Descendant discovery does not need ps. Asking the kernel about one parent at a time is enough, and the queries that work on the restricted host are the cheaper ones. ps -A also reads every process on the machine to answer a question about one subtree.
-
A cleanup probe should not discard a finished render. L114 already states the intent — "Terminate any leftover descendants before releasing capacity" — which is resource hygiene, not a correctness condition on the output. The worker reported completed and the media pipeline produced a file; failing the Build here loses work that exists.
Direction (verified locally)
Both ps uses in killRenderTree replaced:
- descendants — breadth-first over
exec("pgrep", ["-P", String(pid)]), treating exit code 1 as "no children"
- liveness —
process.kill(pid, 0); ESRCH means the process is gone
- the wait loop degrades to a timeout instead of raising, since the next point cannot be expressed with
kill(pid, 0)
One caveat worth knowing: process.kill(pid, 0) cannot distinguish a zombie from a running process, which is exactly what the stat column was read for. In practice this is benign here — the worker is this process's own child and is reaped on close, and Chrome grandchildren are reparented — so the loop exits promptly. But it is the one behaviour the ps -o stat= check got that the replacement does not.
With only that change, the same command on the same host:
✓ Build complete
Build bld_20260915T160325985Z_F0C3B7BCC5
hypit get bld_20260915T160325985Z_F0C3B7BCC5 --output final.video … --to <path> then exports an 8s, 540x960, 30fps H.264 + AAC file with the expected picture.
Happy to open a PR with the change if that is useful — otherwise it stays a local patch.
Summary
On a host that denies process enumeration, every
hypit buildthat renders throughhyperframes.localis reported as failed after the frames have already been rendered and the video encoded. The only thing that fails is the post-render cleanup probe.A finished render is discarded, and
--follownever printsBuild complete.Where
packages/provider-hyperframes-local/src/capture-process.tsawait exec("ps", ["-A", "-o", "pid=,ppid="], …)— descendant discoveryawait exec("ps", ["-p", String(child), "-o", "stat="], …)— liveness probekill()turns any rejection fromkillRenderTreeintofailure = "Render cleanup failed; …", whichclosethen rejects withvoid kill()runs on the completed path, not only on error pathsBecause of L115, the failure is not an edge case on this host: it is every Build.
Why
psfails heremacOS sandboxes — for example an agent/IDE host that applies a
sandbox-execprofile — commonly deny process enumeration while still allowing libproc-backed queries:So
execFile("ps", …)rejects beforepsever runs, andkillRenderTreecannot distinguish "no descendants" from "cannot enumerate".Evidence
Host: macOS 15.7 (24G222), arm64 · Node v22.22.2 ·
@hypit/hypit0.1.9 ·@hyperframes/engine0.7.101Execution log (
hypit logs bld_20260915T160124951Z_58CF657E92), tail:hypit inspect:240/240 frames captured, encode started, Build discarded. The browser launch itself is healthy —
[BrowserManager] Browser launched (HeadlessChrome/151.0.7922.71, screenshot, gl=--use-gl=angle --use-angle=metal, headlessShell=true, platform=darwin).Two independent problems
Descendant discovery does not need
ps. Asking the kernel about one parent at a time is enough, and the queries that work on the restricted host are the cheaper ones.ps -Aalso reads every process on the machine to answer a question about one subtree.A cleanup probe should not discard a finished render. L114 already states the intent — "Terminate any leftover descendants before releasing capacity" — which is resource hygiene, not a correctness condition on the output. The worker reported
completedand the media pipeline produced a file; failing the Build here loses work that exists.Direction (verified locally)
Both
psuses inkillRenderTreereplaced:exec("pgrep", ["-P", String(pid)]), treating exit code 1 as "no children"process.kill(pid, 0);ESRCHmeans the process is gonekill(pid, 0)One caveat worth knowing:
process.kill(pid, 0)cannot distinguish a zombie from a running process, which is exactly what thestatcolumn was read for. In practice this is benign here — the worker is this process's own child and is reaped onclose, and Chrome grandchildren are reparented — so the loop exits promptly. But it is the one behaviour theps -o stat=check got that the replacement does not.With only that change, the same command on the same host:
hypit get bld_20260915T160325985Z_F0C3B7BCC5 --output final.video … --to <path>then exports an 8s, 540x960, 30fps H.264 + AAC file with the expected picture.Happy to open a PR with the change if that is useful — otherwise it stays a local patch.