fix(tools): stop unsandboxed shell hang after output cap - #96
Conversation
When sandbox is off, filling the result buffer left the drain loop and blocked forever in waitpid, freezing the agent. Truncated copies also skipped the NUL terminator. Reap leftover children like sandbox_exec, kill the process group, and always NUL-terminate the capture buffer. Refs: #69
There was a problem hiding this comment.
Summary
Focused rewrite of #69 against current development. Unsandboxed run_unsandboxed used to leave the drain loop once the result buffer filled, then block forever in waitpid(..., 0) if the child stayed alive without writing. That freezes agent_run (CLI, WebChat, inbound ASAP). The same fill path copied add + 1 bytes from a chunk whose NUL sat at buf[n], so a truncated last chunk wrote a data byte at result_buf[max_len-1] and left no terminator. agent.c then strdups a 4 KiB TOOL_RESULT_SIZE slot, so that was a real heap over-read, not only a test-buffer issue.
The new path matches sandbox_exec's WNOHANG + SIGKILL reap, adds a process group so sequential printf; sleep grandchildren die, always NUL-terminates, and retries poll on EINTR. I built test_shell with CI=true -Werror (linked -lm; this image has no libcurl) and reran it under GCC ASan+UBSan: 9/0. Sequential leftover sleep 9999 was gone. GitHub CI was still pending at review time.
Must Fix
None.
Should Fix
reap_running_childonly callskill_command_treewhen the firstwaitpid(WNOHANG)is 0. If the shell already exited, or dies from SIGPIPE afterclose(pipefd[0]), the group is not signalled. I ransleep 9998 & printf '%080d' 0against this build:executereturned immediately andsleep 9998was left with PPid 1. Sequentialprintf; sleep(the unit-test command) was reaped. Kill the process group first, then wait for the tracked pid.kill(-pid)still reaches leftover members after the shell is gone, as long as PGID equals the child pid.test_shell_caps_output_without_hangingonly checks return + a NUL + a 5s alarm. It does not scan/procfor leftoversleep, so a kill-the-shell-only implementation would pass. Same gap astest_timeout_kills_processon #89.- CHANGELOG Unreleased does not mention the hang or the missing NUL.
Nice to Have
- The final blocking
waitpid(..., 0)after 2s of WNOHANG (copied fromsandbox_exec) can hang again if the child is stuck in D-state. - Output-cap kills set
timed_out. The[Command timed out]suffix is skipped when the buffer is full (total == max_len - 1), so it does not fire on the cap path, but the flag name is misleading if that check is ever loosened. - Unsandboxed child still inherits stdin and the pipe has no
FD_CLOEXEC.sandbox_execcloses stdin and sets CLOEXEC on the read end. - Assert truncated content (
buf[0] == '0'), not only that some NUL exists.
Positive Highlights
- Right root cause: blocking
waitpidafter the cap, plus theadd + 1NUL skip.append_unsandboxed_outputcopiesaddbytes and writes the terminator itself. - Process group via
setpgidin both parent and child is the correct extra for the no-NEWPID path.kill(-pid)falls back tokill(pid)on ESRCH, so a failedsetpgiddoes not SIGKILL the agent's own group. - Tight scope: two files, no Landlock / allowlist mix. The new test hits the exact #69 trigger (
printfthensleep, so SIGPIPE will not reap the child).
Sent by Cursor Automation: Adrianno鈥檚 personal code review
waitpid(WNOHANG) skipping kill_command_tree leaked background grandchildren after the shell exited. Signal the group first, assert no leftover sleep argv, and record the hang/NUL fix in CHANGELOG. Refs: #96
Linux glibc + -Werror hid popen/pclose/nanosleep without a feature test macro, so CI failed to compile the leftover-sleep scanner. Refs: #96
|
Review follow-up in 76a763a: Should fix
Nice to have (not in this PR)
|


Summary
development(not a cherry-pick of themain-based Cursor draft).total >= max_len - 1, leftover children are reaped withWNOHANG+ SIGKILL (same idea assandbox_exec) instead of blockingwaitpid(..., 0).sleep/grandchildren die with the shell, and the capture buffer is always NUL-terminated.Supersedes #69.
Does not mix #79/#89 (allowlist / Landlock).
Test plan
make test_shell/./build/test_shell(test_shell_caps_output_without_hanging)CI=true make testmake staticMade with Cursor