Skip to content

feat(process-daemon): run commands without a PTY on Unix - #60697

Merged
jlongster merged 3 commits into
devfrom
daemon-pipe-mode
Nov 2, 2026
Merged

jlongster merged 3 commits into
devfrom
daemon-pipe-mode

Conversation

@jlongster

@jlongster jlongster commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Issue for this PR

Closes #

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Lets the PTY daemon run any command without a terminal ("pipe" processes), so OpenCode can later run its shell commands in the daemon and keep them alive across a server restart handoff. Unix only; Windows comes in a follow-up.

Protocol (additive, stays 7, feature "pipe" on Unix):

  • create takes mode: "pty" | "pipe" (default "pty"); title, cols and rows become optional and are checked for PTYs only. Older daemons ignore mode and would start a PTY, so clients send "pipe" only when features lists it.
  • list takes an optional mode. Without it, list returns PTYs only, so today's servers never see pipe processes as terminal tabs. mode is left out of the JSON when unset, because older daemons reject a list with extra keys.
  • TerminalInfo.mode; Exited (lifecycle and subscription response) gets signal (for example "SIGTERM"), left out when absent so PTY exits serialize as before.
  • write, resize, control, input frames, snapshot, read_rows and rename fail for pipe processes; replay, subscribe and terminate work.

How a pipe process runs (src/service/pipe.rs):

  • std::process::Command with its own process group, stdin on /dev/null, and stdout and stderr sharing one pipe, so their bytes stay in order. No TERM/COLORTERM, no emulator.
  • A pre_exec hook clears the signal mask and resets SIGCHLD, SIGHUP, SIGINT, SIGQUIT, SIGTERM and SIGALRM to their defaults (like portable-pty does for terminals). The daemon blocks SIGTERM and SIGINT (since fix(process-daemon): stop cleanly on SIGTERM and SIGINT #60691), and std keeps the mask across exec, so without this a pipe child could never be stopped by those signals.
  • The waiter uses waitid(WNOWAIT) and leaves the child as a zombie until terminate. That keeps its PID and process group ID reserved, so terminate can always kill leftover descendants in the group (and the child itself, in case it moved to another group) without ever signalling a reused PID.
  • The reader polls the output pipe plus two control pipes: one closes when the child exits (output is still read for 1 s, so cmd & can't hold the exit forever), and one closes at shutdown (so a descendant that left the group can't block terminate). Once every writer has closed the output pipe, the rest is read to the end regardless of the 1 s deadline, so a slow subscriber can't truncate the output.
  • Pipes are created under a read lock and children spawned under a write lock, because macOS has no pipe2 and a fork between pipe() and close-on-exec would leak a pipe into another child.
  • Startup failures kill the child before joining any thread and reap it last.
  • Output uses the existing replay buffer and subscriptions unchanged.

In service.rs: the actor's ghostty terminal is now optional (none for pipe processes); actor startup moved into start_actor so both spawn paths share it; list_mode filters before refreshing foreground processes.

How did you verify your code works?

cargo fmt --check, cargo clippy --all-targets --all-features -- -D warnings and the macOS test suite in packages/process-daemon; clippy for x86_64-pc-windows-msvc and x86_64-unknown-linux-gnu. New tests/pipe.rs (Linux CI runs it; #61277 adds it to the macOS CI job):

  • stdout and stderr interleaved in order, not a TTY, stdin closed, exit code
  • SIGPIPE is not inherited as ignored (a shell that sends itself SIGPIPE dies from it)
  • exit by signal reports SIGTERM
  • fast exit with no output
  • sleep 30 & delays the exit by at most the drain, and terminate then kills it
  • a descendant that called setsid and holds the pipe doesn't block terminate
  • an exited process stays a zombie until terminate, which reaps it
  • terminal-only requests are rejected; PTY and pipe lists are separate; no size needed

Daemon-level tests (real binary): a pipe child started by the daemon still dies from SIGTERM and SIGINT (this test fails without the pre_exec reset); a pipe process keeps running across a restart handoff and delivers its full output and exit code to the new owner; default list stays PTY-only; features includes pipe. Protocol unit tests pin the wire format for older peers ({"op":"list"}, PTY create unchanged, Exited without signal).

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #36348, #57977

@jlongster
jlongster requested a review from a team as a code owner October 30, 2026 20:01
Base automatically changed from daemon-stop-signals to dev November 2, 2026 16:59

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found no blocking problems. On Linux the lib and ownership tests all pass, including the handoff, signal-mask and feature-list tests, and 8 of 10 in tests/pipe.rs pass. cargo fmt --check and clippy -D warnings are clean.

The 2 pipe failures come from my container: its PID 1 never reaps orphans, so the killed sleep processes stay zombies and kill(pid, 0) still says they're alive. terminate did kill them. I left a small inline note on this, since it will also bite anyone running the suite where PID 1 doesn't reap (for example Docker without --init).

Public API: unchanged. The daemon protocol between process-daemon and packages/core only gains optional fields, and the changes are backwards compatible both ways:

  • create.mode: new, defaults to pty
  • title, cols and rows: now optional
  • list.mode: new, and left out of the JSON when unset
  • TerminalInfo.mode: new
  • Exited.signal: new, and left out when absent

Today's TypeScript client ignores the extra mode field, and since list without mode returns only PTYs, it never sees pipe processes.

Related: #36348, #57977

}
}

fn alive(pid: i32) -> bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kill(pid, 0) succeeds for zombies too. The sleep/perl grandchildren get reparented to PID 1, so wherever PID 1 doesn't reap (containers without --init, like the one I ran in), wait_dead times out even though terminate killed them. Here, terminate_kills_the_process_group and a_descendant_holding_the_pipe_delays_the_exit_by_at_most_the_drain both failed this way, with ps showing the PIDs in state Z. Treating a zombie as dead would make these tests independent of the host's init, e.g. by checking the state in /proc/<pid>/stat on Linux or ps -o stat=, or by making the test process a subreaper with PR_SET_CHILD_SUBREAPER.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This push only rebases the branch onto dev. The daemon files are byte-for-byte the same as in the commit I reviewed, so my earlier review still holds. The zombie check in tests/pipe.rs is still open.

Public API: unchanged. The internal daemon protocol only gains optional fields and stays backwards compatible.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This push only takes --test pipe out of the macOS CI job; the description now says #61277 adds it there instead. Linux CI still runs tests/pipe.rs through the full cargo test --locked. No daemon code or test changed, so my earlier review still holds, and the zombie check in tests/pipe.rs is still open.

Public API: unchanged.

@jlongster
jlongster merged commit 394d49d into dev Nov 2, 2026
20 checks passed
@jlongster
jlongster deleted the daemon-pipe-mode branch November 2, 2026 18:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant