Visitar URL original
refactor(process-daemon): simplify Windows pipe mode by jlongster · Pull Request #63346 · anomalyco/opencode · GitHub
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
50 changes: 25 additions & 25 deletions packages/process-daemon/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,13 @@ starts a pipe process: any command without a terminal. Both return
- On Unix the child gets its own process group and an empty signal mask with
default actions (the daemon blocks SIGTERM and SIGINT, and std keeps the
mask across exec). On Windows it is created with `CREATE_NO_WINDOW` (no
console window and no console) and is placed in a kill-on-close job from
creation. The program is resolved the way `CreateProcessW` resolves a bare
executable name (the application directory, the current directory, the
system directories and `PATH`); the daemon does not run a shell unless the
request itself asks for one, and passing `["/c", command]` to `cmd.exe` is
the caller's job.
console) inside a kill-on-close job. For a bare executable name,
`CreateProcessW` searches the directory containing the daemon executable,
the daemon's current directory, the 32-bit and then 16-bit Windows system
directories, the Windows directory, and finally the directories in the
daemon's `PATH`. This search is independent of the request's `cwd` and `env`,
which set the child's working directory and environment. To invoke a shell,
the caller must request one explicitly, e.g. `cmd.exe` with `["/c", command]`.
- `output_path` must be absolute. The daemon opens it before spawning,
truncating an existing file or creating one (mode `0666` minus the daemon's
umask on Unix), and refuses a symlink or other reparse point as the final
Expand All @@ -177,17 +178,17 @@ starts a pipe process: any command without a terminal. Both return
nothing reads what comes later. The file is closed before the exit is
published.
- `Exited` and the lifecycle carry `signal`, the signal's name such as
`"SIGTERM"`, when the daemon terminated the process; `exit_code` is then
null. `signal` is left out when there is none.
`"SIGTERM"`, when a signal ended the process (on Windows, when the daemon
signalled it); `exit_code` is then null. `signal` is left out when there is
none.
- On Unix an exited pipe process stays listed, as a zombie, until `terminate`.
The zombie keeps its PID and process group ID reserved, so killing the group
can never reach an unrelated process. On Windows the process object and job
handle stay open until `terminate`, so the PID is not reused either.
`terminate` kills the whole tree (SIGKILL on Unix; `TerminateJobObject`, or
the main process only when no job could be used, on Windows), reaps it and
removes the process; it never waits on a descendant that left the process
group or job and still holds the pipe. The daemon never deletes output
files.
`terminate` kills the whole tree (SIGKILL on Unix; the job on Windows),
reaps it and removes the process; it never waits on a descendant that left
the process group or job and still holds the pipe. The daemon never deletes
output files.

### Line count

Expand Down Expand Up @@ -221,18 +222,17 @@ only; left out for PTYs), `output_error` (left out when there is none) and
`signal { id, signal }` (pipe only; `"SIGTERM"`, `"SIGINT"` or `"SIGKILL"`)
signals the process tree and returns `Ok`. On Unix the signal goes to the
process group and the child, as a real signal. **On Windows every signal is a
hard kill** of the job (or of the main process when no job could be used) with
`TerminateJobObject`/`TerminateProcess`: there is no console and no console
control event, so SIGTERM and SIGINT cannot be caught or trapped, and the exit
reports the signal that was sent, not a platform exit code. Unlike
`terminate`, `signal` keeps the process: `wait` then gets a normal `Exited`
with `signal`. Signalling an exited process is allowed and reaches whatever is
left of its group or job (an empty group is not an error); after `terminate`
the process is gone and `signal` fails. If an outer job refuses the child
(`ERROR_ACCESS_DENIED`, for example a job with UI restrictions), the daemon
runs the command anyway without a job, logs a warning once, and kills only the
main process with `TerminateProcess`; descendants then survive a signal or
terminate.
hard kill** of the job: there is no console, so SIGTERM and SIGINT cannot be
caught, and the exit reports the signal that was sent, not a platform exit
code. Unlike `terminate`, `signal` keeps the process: `wait` then gets a normal
`Exited` with `signal`. Signalling an exited process is allowed and reaches
whatever is left of its group or job (an empty group is not an error); after
`terminate` the process is gone and `signal` fails.

If an outer job refuses the child (`ERROR_ACCESS_DENIED`, for example a job
with UI restrictions), the daemon runs the command anyway without a job, logs
a warning once, and kills only the main process; descendants then survive a
signal or terminate.

`metadata` on `create_pipe` is an opaque JSON object the daemon stores and
returns as `pipe.metadata` without interpreting it. `set_metadata { id,
Expand Down
17 changes: 4 additions & 13 deletions packages/process-daemon/src/daemon/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ use windows_sys::Win32::System::WindowsProgramming::{
use super::{LOCK_FILE, REGISTRATION_FILE, Registration, registration_path, sweep};
use crate::protocol::{PROTOCOL_VERSION, features};
use crate::transport::Listener;
use crate::transport::windows::{owned_handle, security::PrivateSecurity};
use crate::transport::windows::{owned_handle, security::PrivateSecurity, wide};

pub(super) fn read_registration(directory: &Path) -> Result<Registration> {
let security = PrivateSecurity::new()?;
Expand Down Expand Up @@ -74,9 +74,9 @@ impl Runtime {
if let Some(parent) = directory.parent() {
fs::create_dir_all(parent)?;
}
let wide = wide_path(directory)?;
let directory_wide = wide(directory.as_os_str())?;
// SAFETY: wide path and private descriptor are valid through creation.
if unsafe { CreateDirectoryW(wide.as_ptr(), &security.attributes()) } == 0 {
if unsafe { CreateDirectoryW(directory_wide.as_ptr(), &security.attributes()) } == 0 {
let error = std::io::Error::last_os_error();
if error.raw_os_error() != Some(ERROR_ALREADY_EXISTS as i32) {
return Err(error.into());
Expand Down Expand Up @@ -300,7 +300,7 @@ fn open(
sharing: u32,
security: &PrivateSecurity,
) -> Result<File> {
let path = wide_path(path)?;
let path = wide(path.as_os_str())?;
// SAFETY: all pointers are valid; OPEN_REPARSE_POINT lets check_kind reject
// links rather than inspecting a different target after following them.
let handle = unsafe {
Expand Down Expand Up @@ -332,15 +332,6 @@ fn check_kind(file: &File, directory: bool) -> Result<()> {
Ok(())
}

fn wide_path(path: &Path) -> Result<Vec<u16>> {
let mut wide = path.as_os_str().encode_wide().collect::<Vec<_>>();
if wide.contains(&0) {
bail!("PTY storage path contains NUL");
}
wide.push(0);
Ok(wide)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
35 changes: 24 additions & 11 deletions packages/process-daemon/src/service/pipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
use std::fs::File;
use std::io::{self, Write};
use std::sync::{Arc, Mutex, RwLock};
use std::thread::JoinHandle;
use std::thread::{self, JoinHandle};
use std::time::Duration;

use anyhow::{Context, Result};
Expand Down Expand Up @@ -149,10 +149,11 @@ fn new_pipe_info(id: TerminalId, pid: u32, request: CreatePipe) -> ProcessInfo {
}
}

/// The platform reader and waiter, each spawning its named worker thread.
struct Threads {
reader: Box<dyn FnOnce(OutputSink) -> io::Result<JoinHandle<()>>>,
waiter: Box<dyn FnOnce(Sender<ActorMessage>) -> io::Result<JoinHandle<()>>>,
/// The waiter's result, and something dropped only after the exit message is
/// sent (Unix starts the reader's drain by dropping a pipe writer).
struct Waiter {
exit: io::Result<Exit>,
after: Box<dyn Send>,
}

/// Registers a spawned pipe process with its actor, then runs the platform's
Expand All @@ -163,9 +164,9 @@ fn start_pipe(
order: u64,
request: CreatePipe,
process: Arc<Process>,
sink: File,
cancel: Box<dyn Send>,
threads: Threads,
reader: impl FnOnce(Sender<ActorMessage>) + Send + 'static,
waiter: impl FnOnce() -> Waiter + Send + 'static,
) -> Result<TerminalHandle> {
let info = Arc::new(RwLock::new(new_pipe_info(id, process.pid(), request)));
// Nothing is ever written to a pipe process; a disconnected queue
Expand Down Expand Up @@ -196,21 +197,33 @@ fn start_pipe(
thread,
} = actor;
let mut joins = vec![thread];
let reader = (threads.reader)(OutputSink::new(sink, actor_tx.clone()));
let reader_actor_tx = actor_tx.clone();
let reader = thread::Builder::new()
.name(format!("opencode-pty-reader-{id}"))
.spawn(move || reader(reader_actor_tx))
.context("failed to spawn terminal reader");
let reader = match reader {
Ok(reader) => reader,
Err(error) => {
stop(shutdown_tx, cancel, &process, joins);
return Err(error).context("failed to spawn terminal reader");
return Err(error);
}
};
joins.push(reader);
let waiter = (threads.waiter)(actor_tx.clone());
let wait_actor_tx = actor_tx.clone();
let waiter = thread::Builder::new()
.name(format!("opencode-pty-wait-{id}"))
.spawn(move || {
let Waiter { exit, after } = waiter();
let _ = wait_actor_tx.send(exit_message(exit));
drop(after);
})
.context("failed to spawn terminal child waiter");
let waiter = match waiter {
Ok(waiter) => waiter,
Err(error) => {
stop(shutdown_tx, cancel, &process, joins);
return Err(error).context("failed to spawn terminal child waiter");
return Err(error);
}
};
joins.push(waiter);
Expand Down
29 changes: 7 additions & 22 deletions packages/process-daemon/src/service/pipe/unix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,13 @@ use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex, PoisonError, RwLock};
use std::thread;
use std::time::Instant;

use anyhow::{Context, Result, bail};
use nix::errno::Errno;
use nix::poll::{PollFd, PollFlags, PollTimeout, poll};

use super::{DRAIN_AFTER_EXIT, Exit, OutputSink, exit_message, start_pipe};
use super::{DRAIN_AFTER_EXIT, Exit, OutputSink, start_pipe};
use crate::service::{CreatePipe, ProcessSignal, TerminalHandle, TerminalId};

/// Serializes pipe creation against `spawn`. macOS has no `pipe2`, so std
Expand Down Expand Up @@ -127,32 +126,18 @@ impl TerminalHandle {
// Dropping `Child` neither waits nor kills; `Process` reaps it.
drop(child);

let reader_id = id;
let waiter_id = id;
let pid = process.pid;
start_pipe(
id,
order,
request,
Arc::clone(&process),
sink,
Box::new(cancel_writer),
super::Threads {
reader: Box::new(move |sink| {
thread::Builder::new()
.name(format!("opencode-pty-reader-{reader_id}"))
.spawn(move || read_output(output, sink, cancel, exited))
}),
waiter: Box::new(move |actor| {
let pid = process.pid;
// Spawned last: the waiter must be joined before `reap`.
thread::Builder::new()
.name(format!("opencode-pty-wait-{waiter_id}"))
.spawn(move || {
let _ = actor.send(exit_message(wait_exit(pid)));
// Starts the reader's drain deadline.
drop(exited_writer);
})
}),
move |actor| read_output(output, OutputSink::new(sink, actor), cancel, exited),
move || super::Waiter {
exit: wait_exit(pid),
// Starts the reader's drain deadline, after the exit message.
after: Box::new(exited_writer),
},
)
}
Expand Down
Loading
Loading