Synced from monorepo
Synced from monorepo Changes: - Temporarily disable session share link creation in the TUI - Do not approve plan on empty Enter from the revise prompt - Expose chat product Skills via ACP available_commands_update - Return immediately from a blocking wait on an already-completed ACP task - Split headless pager module for clearer structure - Stop git worktree prune from removing user registrations on resume - Use compaction sampler tokenizer for item token counts - Opt-in extra root CAs via GROK_EXTRA_CA_BUNDLE - Cancel all session subagents when the user stops - Let the session persistence actor exit when its session ends - Make fullscreen terminal resize much cheaper on long sessions - Report honestly from kill_task when an ACP task does not exist - Hide /usage for external-auth deployments - Forward the history-load trailer’s computer_reason to the client - Remove ineffective no-op tool reminder - Declare slash-command screen-mode support in one place - Keep settings enum picker on the committed value until Enter - Reap a PTY’s full process tree - Stream tool calls from headless mode over ACP - Bridge gateway task lifecycle to ACP for chat session background tasks - Don’t warn about truncated history on a suppressed replay - Fit full-replace summarizer input and recover on context-length errors - Stop dropping agents over an unrecognized frontmatter color - Add /undo as a slash alias for /rewind - Harden sleep/wake token-refresh paths against forced re-login - Add session/list ACP method - Give each sampling backend its own conversion module - Treat an unenrolled child process as a lint error - Suppress the cancelled marker on send-now wake turns - Stop tearing down Roslyn on every edit, and read C# diagnostics Source-Revision: 2a28b4a86cfc4a4c133c35b7fc2a6a9964387c39
This commit is contained in:
parent
500129c714
commit
dd04f397b1
367 changed files with 29489 additions and 10051 deletions
|
|
@ -42,6 +42,9 @@ use std::io;
|
|||
mod process_scope;
|
||||
pub use process_scope::{ProcessScope, global_process_scope};
|
||||
|
||||
/// How long a shell gets to forward a hangup to its jobs before it is killed.
|
||||
pub const HANGUP_GRACE: std::time::Duration = std::time::Duration::from_millis(200);
|
||||
|
||||
pub mod runtime;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -367,6 +370,9 @@ pub struct ProcessGroup {
|
|||
leader: Option<ProcessGroupId>,
|
||||
#[cfg(windows)]
|
||||
job: windows::Win32::Foundation::HANDLE,
|
||||
/// Set for a shell that owns a terminal, whose job-control children only
|
||||
/// die if the shell is asked to hang up first.
|
||||
hangup_first: bool,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
|
|
@ -378,7 +384,10 @@ impl ProcessGroup {
|
|||
pub fn new() -> io::Result<Self> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
Ok(Self { leader: None })
|
||||
Ok(Self {
|
||||
leader: None,
|
||||
hangup_first: false,
|
||||
})
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
|
|
@ -410,7 +419,10 @@ impl ProcessGroup {
|
|||
return Err(io::Error::other(format!("SetInformationJobObject: {e}")));
|
||||
}
|
||||
|
||||
Ok(Self { job })
|
||||
Ok(Self {
|
||||
job,
|
||||
hangup_first: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -479,6 +491,33 @@ impl ProcessGroup {
|
|||
}
|
||||
}
|
||||
|
||||
/// Ask an interactive shell to hang up. Its job-control children each live
|
||||
/// in their own process group, which no `killpg` here reaches, but a shell
|
||||
/// forwards the hangup to them before it exits.
|
||||
pub fn hangup(&self) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
self.killpg_unix(nix::sys::signal::Signal::SIGHUP)?;
|
||||
// A stopped shell cannot forward the hangup until it resumes.
|
||||
self.killpg_unix(nix::sys::signal::Signal::SIGCONT)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether teardown should hang this group up before killing it. Never on
|
||||
/// Windows, where the hangup is a no-op and the Job Object takes the tree.
|
||||
pub fn wants_hangup(&self) -> bool {
|
||||
cfg!(unix) && self.hangup_first
|
||||
}
|
||||
|
||||
/// Mark this group as a terminal-owning shell. See [`Self::hangup`].
|
||||
pub fn hang_up_before_kill(&mut self) {
|
||||
self.hangup_first = true;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn killpg_unix(&self, signal: nix::sys::signal::Signal) -> io::Result<()> {
|
||||
// `leader` is `None` until a child is enrolled, and a `ProcessGroupId`
|
||||
|
|
@ -806,6 +845,7 @@ mod tests {
|
|||
cmd.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
#[allow(clippy::disallowed_methods)] // test fixture; the test kills it
|
||||
let error = cmd
|
||||
.spawn()
|
||||
.expect_err("cross-thread arm+spawn must fail in debug builds");
|
||||
|
|
@ -848,6 +888,7 @@ mod tests {
|
|||
// binding on the same command, in the documented order.
|
||||
detach_std_command(&mut cmd);
|
||||
kill_on_parent_death_std(&mut cmd);
|
||||
#[allow(clippy::disallowed_methods)] // test fixture; the test kills it
|
||||
let child = cmd.spawn().expect("spawn armed grandchild");
|
||||
println!("grandchild:{}", child.id());
|
||||
// Do not reap: the grandchild must outlive this handle and die only
|
||||
|
|
@ -879,6 +920,7 @@ mod tests {
|
|||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null());
|
||||
#[allow(clippy::disallowed_methods)] // test fixture; the test kills it
|
||||
let mut intermediate = cmd.spawn().expect("spawn intermediate test process");
|
||||
|
||||
// Grandchild pid from the intermediate's stdout. Substring-match, not
|
||||
|
|
@ -959,6 +1001,7 @@ mod tests {
|
|||
.stderr(std::process::Stdio::null());
|
||||
detach_std_command(&mut cmd);
|
||||
kill_on_parent_death_std(&mut cmd);
|
||||
#[allow(clippy::disallowed_methods)] // test fixture; the test kills it
|
||||
let mut child = cmd.spawn().expect("spawn armed child");
|
||||
|
||||
// The binding must not kill a child whose parent (this process) is
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ use std::io;
|
|||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError, Weak};
|
||||
|
||||
use crate::{ProcessGroup, new_process_group};
|
||||
use crate::{HANGUP_GRACE, ProcessGroup, new_process_group};
|
||||
|
||||
/// A `Send + Sync` kill-handle for one unit's child-process trees. Cheap to
|
||||
/// clone (shares one inner via `Arc`). See the [module docs](self) for the
|
||||
|
|
@ -88,9 +88,16 @@ impl ProcessScope {
|
|||
/// what keeps [`kill_all`] PID-reuse-safe.
|
||||
///
|
||||
/// Returns `true` if the group was enrolled. Returns `false` if the scope
|
||||
/// was already closed ([`kill_all`] latched): the group is killed on the
|
||||
/// spot, and the caller should treat the child as dead — fail its start
|
||||
/// rather than proceed against a killed process.
|
||||
/// was already closed ([`kill_all`] latched): the caller should treat the
|
||||
/// child as dead and fail its start rather than proceed against it.
|
||||
///
|
||||
/// A group is killed here unless it wants a hangup first, which needs a
|
||||
/// grace this lock cannot afford; [`enroll_terminal_pid`] is the only way to
|
||||
/// mark one and reaps it itself. Do not pair
|
||||
/// [`ProcessGroup::hang_up_before_kill`] with a bare `register`: nothing
|
||||
/// would reap the child.
|
||||
///
|
||||
/// [`enroll_terminal_pid`]: Self::enroll_terminal_pid
|
||||
///
|
||||
/// For spawn sites (e.g. the MCP child handle, the bash terminal) that
|
||||
/// already build their own [`ProcessGroup`] and own its lifecycle; sites that
|
||||
|
|
@ -106,8 +113,11 @@ impl ProcessScope {
|
|||
// `Weak` that would leak. Closes the close/spawn race where a
|
||||
// (possibly wedged) actor's spawn lands after teardown. `killpg` is
|
||||
// non-blocking, so killing under the lock is fine and serializes
|
||||
// with a concurrent `kill_all`.
|
||||
let _ = group.kill();
|
||||
// with a concurrent `kill_all`. A shell that needs a hangup first is
|
||||
// left for the caller, which can afford the grace outside this lock.
|
||||
if !group.wants_hangup() {
|
||||
let _ = group.kill();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
groups.retain(|w| w.strong_count() > 0);
|
||||
|
|
@ -136,6 +146,25 @@ impl ProcessScope {
|
|||
Ok(group)
|
||||
}
|
||||
|
||||
/// Enroll a shell this process did not spawn through a
|
||||
/// [`tokio::process::Command`]. Teardown gives it [`HANGUP_GRACE`] to hang
|
||||
/// up before the kill.
|
||||
#[must_use = "the returned Arc<ProcessGroup> must be kept alive or the scope cannot reap the child"]
|
||||
pub fn enroll_terminal_pid(&self, pid: u32) -> io::Result<Arc<ProcessGroup>> {
|
||||
let mut group = ProcessGroup::new()?;
|
||||
group.attach_pid(pid)?;
|
||||
group.hang_up_before_kill();
|
||||
let group = Arc::new(group);
|
||||
if !self.register(&group) {
|
||||
// Lost the close/spawn race; reap in the same order teardown uses.
|
||||
reap_groups(&[Arc::downgrade(&group)]);
|
||||
return Err(io::Error::other(
|
||||
"process scope already closed; child killed",
|
||||
));
|
||||
}
|
||||
Ok(group)
|
||||
}
|
||||
|
||||
/// Convenience for simple sites: [`prepare`] + spawn + [`enroll`]. Returns
|
||||
/// the child together with the owning `Arc<ProcessGroup>`, which the caller
|
||||
/// must keep alive for the scope to be able to reap the child.
|
||||
|
|
@ -160,21 +189,16 @@ impl ProcessScope {
|
|||
/// Groups whose owner already reaped+dropped them upgrade to `None` and are
|
||||
/// skipped — so this never `killpg`s a reused PID.
|
||||
pub fn kill_all(&self) {
|
||||
let mut groups = self.lock();
|
||||
for weak in groups.iter() {
|
||||
if let Some(group) = weak.upgrade() {
|
||||
// A failed kill means the group already exited (ESRCH) — benign,
|
||||
// and there is nothing actionable to log at this primitive layer.
|
||||
// Callers that care about the wedge-reclaim event log it there.
|
||||
let _ = group.kill();
|
||||
}
|
||||
}
|
||||
// Every weak has been handled above; clear the set.
|
||||
groups.clear();
|
||||
// Latch closed under the lock: a concurrent `register` either already
|
||||
// pushed (its group was just killed in the loop) or now sees `closed`
|
||||
// and kills its own child — nothing slips through after teardown.
|
||||
self.inner.closed.store(true, Ordering::Relaxed);
|
||||
let enrolled = {
|
||||
let mut groups = self.lock();
|
||||
let enrolled = std::mem::take(&mut *groups);
|
||||
// Latch closed under the lock: a concurrent `register` either already
|
||||
// pushed (its group is in `enrolled` and dies below) or now sees
|
||||
// `closed` and kills its own child — nothing slips past teardown.
|
||||
self.inner.closed.store(true, Ordering::Relaxed);
|
||||
enrolled
|
||||
};
|
||||
reap_groups(&enrolled);
|
||||
}
|
||||
|
||||
/// Lock the group set, tolerating a poisoned mutex: the critical sections
|
||||
|
|
@ -216,16 +240,34 @@ pub fn global_process_scope() -> &'static ProcessScope {
|
|||
impl Drop for ScopeInner {
|
||||
fn drop(&mut self) {
|
||||
// RAII backstop: if the last scope handle drops without an explicit
|
||||
// `kill_all`, still reap any group whose owner is alive (a wedged
|
||||
// unit). Dead weaks (clean teardown) upgrade to None and are skipped,
|
||||
// so this stays PID-reuse-safe.
|
||||
// `kill_all`, still reap any group whose owner is alive (a wedged unit),
|
||||
// in the same order.
|
||||
let groups = self.groups.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
for weak in groups.iter() {
|
||||
if let Some(group) = weak.upgrade() {
|
||||
let _ = group.kill();
|
||||
}
|
||||
reap_groups(&groups);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hang up the groups that asked for it, one shared grace, then kill.
|
||||
///
|
||||
/// A failed signal means the group already exited (ESRCH), which is benign and
|
||||
/// not actionable at this layer.
|
||||
fn reap_groups(enrolled: &[Weak<ProcessGroup>]) {
|
||||
let mut hung_up = false;
|
||||
for group in enrolled.iter().filter_map(Weak::upgrade) {
|
||||
if group.wants_hangup() {
|
||||
let _ = group.hangup();
|
||||
hung_up = true;
|
||||
}
|
||||
}
|
||||
if hung_up {
|
||||
std::thread::sleep(HANGUP_GRACE);
|
||||
}
|
||||
// Upgrade again rather than holding the `Arc`s across the grace: an owner
|
||||
// that reaped its child meanwhile has dropped its group, and its pgid may
|
||||
// already belong to someone else.
|
||||
for group in enrolled.iter().filter_map(Weak::upgrade) {
|
||||
let _ = group.kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
|
|
@ -310,6 +352,21 @@ mod tests {
|
|||
/// Close/spawn race: a child enrolled *after* `kill_all` must be killed on
|
||||
/// the spot, not leaked — `kill_all` won't run again to catch it — and the
|
||||
/// caller must be told (enroll errors) so it doesn't proceed with a dead child.
|
||||
#[tokio::test]
|
||||
async fn only_a_terminal_enrollment_asks_for_a_hangup() {
|
||||
let scope = ProcessScope::new();
|
||||
let mut cmd = sleeper();
|
||||
scope.prepare(&mut cmd);
|
||||
let (child, group) = scope.spawn(cmd).unwrap();
|
||||
let pid = child.id().expect("pid");
|
||||
assert!(!group.wants_hangup());
|
||||
|
||||
let terminal = scope.enroll_terminal_pid(pid).unwrap();
|
||||
assert!(terminal.wants_hangup());
|
||||
|
||||
scope.kill_all();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_after_kill_all_reaps_immediately() {
|
||||
let scope = ProcessScope::new();
|
||||
|
|
|
|||
Loading…
Reference in a new issue