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
2 changes: 2 additions & 0 deletions crates/goose-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ mod memory;
pub mod peekaboo;
pub mod subprocess;
pub mod tutorial;
#[cfg(windows)]
pub mod windows_job;

pub use autovisualiser::AutoVisualiserRouter;
pub use computercontroller::ComputerControllerServer;
Expand Down
2 changes: 1 addition & 1 deletion crates/goose-mcp/src/subprocess.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ fn resolve_login_shell_path() -> Option<String> {
.stdout(Stdio::piped())
.stderr(Stdio::null());

// Spawn in a new session so that interactive shell job-control setup
// Spawn in a new session so that interactive shells job-control setup
// cannot steal the terminal foreground from the parent goose process.
cmd.wrap(ProcessSession);

Expand Down
22 changes: 22 additions & 0 deletions crates/goose-mcp/src/windows_job.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Stub Windows Job Object helpers for MCP path (no-ops on Windows).
// This file provides the minimal surface required by crates/goose-mcp/src/subprocess.rs
// when built on Windows. The real Windows Job Object integration lives in the goose crate.

#[cfg(windows)]
pub type HANDLE = *mut std::ffi::c_void;

#[cfg(windows)]
pub fn ensure_job_object() -> Option<HANDLE> {
None
}

#[cfg(windows)]
pub fn attach_pid_to_job(_pid: u32) {}

#[cfg(windows)]
pub fn init_windows_cleanup() {}

#[cfg(windows)]
pub fn windows_cleanup_enabled() -> bool {
false
}
2 changes: 1 addition & 1 deletion crates/goose/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ subtle = { version = "2.5", default-features = false, features = ["std"] }
gethostname = "1.1.0"

[target.'cfg(target_os = "windows")'.dependencies]
winapi = { workspace = true }
winapi = { version = "0.3.9", default-features = false, features = ["wincred", "std", "jobapi2", "winbase", "winnt", "processthreadsapi", "handleapi", "minwindef"] }
keyring = { workspace = true, features = ["windows-native"], optional = true }

# Platform-specific GPU acceleration for Whisper and local inference
Expand Down
9 changes: 9 additions & 0 deletions crates/goose/src/agents/extension_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,15 @@ async fn child_process_client(
let (transport, mut stderr) = TokioChildProcess::builder(command)
.stderr(Stdio::piped())
.spawn()?;
// Attach the child to a Windows Job Object to ensure proper cleanup on Goose exit
#[cfg(windows)]
{
if let Some(pid) = transport.id() {
// Initialize Job Object and attach the child process to it
crate::windows_job::init_windows_cleanup();
crate::windows_job::attach_pid_to_job(pid);
Comment on lines +359 to +362

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Put the child in the job before it can spawn helpers

On Windows, this assigns the PID only after .spawn() has already started the MCP command, so launcher-style extensions such as uvx/npx can create the real server or helper processes before the parent is added to the job. Job membership is inherited only by children created after their parent is in the job, so those early descendants remain outside the job and can survive when Goose exits; create the process in the job or start it suspended and assign it before resuming.

Useful? React with 👍 / 👎.

}
}
let mut stderr = stderr.take().ok_or_else(|| {
ExtensionError::SetupError("failed to attach child process stderr".to_owned())
})?;
Expand Down
3 changes: 3 additions & 0 deletions crates/goose/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@ pub mod slash_commands;
pub mod source_roots;
pub mod sources;
pub mod subprocess;

pub mod token_counter;
pub mod tool_inspection;
pub mod tool_monitor;
pub mod tracing;
pub mod utils;
#[cfg(windows)]
pub mod windows_job;
102 changes: 102 additions & 0 deletions crates/goose/src/windows_job.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Windows Job Object cleanup for child processes (Goose Windows support).
//
// Attaches spawned MCP subprocesses to a Job Object configured with
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. When the Goose process exits (and the
// job handle is closed by the OS), Windows terminates every process in the
// job, preventing orphaned child processes. This is the Windows analog of the
// Linux PR_SET_PDEATHSIG behavior in subprocess.rs.

#![allow(dead_code)]

#[cfg(windows)]
mod windows_impl {
use std::mem::{size_of, zeroed};
use std::ptr::null_mut;
use std::sync::atomic::{AtomicUsize, Ordering};

use winapi::shared::minwindef::FALSE;
use winapi::um::handleapi::CloseHandle;
use winapi::um::jobapi2::{AssignProcessToJobObject, CreateJobObjectW};
use winapi::um::processthreadsapi::OpenProcess;
use winapi::um::winbase::SetInformationJobObject;
use winapi::um::winnt::{
JobObjectExtendedLimitInformation, HANDLE, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
};

// HANDLE (*mut c_void) is not Send/Sync, so we store the handle as a usize
// and cast back to HANDLE at use sites. 0 means "not yet created".
static JOB_HANDLE: AtomicUsize = AtomicUsize::new(0);

pub fn ensure_job_object() -> Option<HANDLE> {
let existing = JOB_HANDLE.load(Ordering::Acquire);
if existing != 0 {
return Some(existing as HANDLE);
}

unsafe {
let job = CreateJobObjectW(null_mut(), null_mut());
if job.is_null() {
return None;
}

let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = zeroed();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let set_res = SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
&mut info as *mut _ as *mut _,
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
);
if set_res == FALSE {
// If we fail to configure the job object to terminate on close, do not publish
// this handle. Cleaning up here avoids mutating global state with a partially
// configured Job Object.
CloseHandle(job);
return None;
}

// Publish the handle, but if another thread won the race, close ours.
match JOB_HANDLE.compare_exchange(0, job as usize, Ordering::AcqRel, Ordering::Acquire)
{
Ok(_) => Some(job),
Err(winner) => {
CloseHandle(job);
Some(winner as HANDLE)
}
}
}
}

pub fn attach_pid_to_job(pid: u32) {
let job = match ensure_job_object() {
Some(job) => job,
None => return,
};
unsafe {
// AssignProcessToJobObject requires PROCESS_SET_QUOTA in addition to
// PROCESS_TERMINATE; without it the assignment fails and the child is
// never tied to the job, leaving it orphaned on exit.
let proc = OpenProcess(PROCESS_TERMINATE | PROCESS_SET_QUOTA, FALSE, pid);
if !proc.is_null() {
if AssignProcessToJobObject(job, proc) == FALSE {
tracing::warn!(pid, "failed to assign child process to Windows job object");
}
CloseHandle(proc);
}
}
}

pub fn init_windows_cleanup() {
let _ = ensure_job_object();
}

pub fn windows_cleanup_enabled() -> bool {
JOB_HANDLE.load(Ordering::Acquire) != 0
}
}

#[cfg(windows)]
pub use windows_impl::{
attach_pid_to_job, ensure_job_object, init_windows_cleanup, windows_cleanup_enabled,
};
Loading