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
1 change: 1 addition & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ tokio-tungstenite = { version = "0.24", default-features = false, features = ["c

[target.'cfg(windows)'.dependencies]
zip = { version = "2", default-features = false, features = ["deflate"], optional = true }
windows-sys = { version = "0.61", default-features = false, features = [
"Win32_Foundation",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_JobObjects",
"Win32_System_Threading",
] }

[dev-dependencies]
rusqlite = { version = "0.35", features = ["bundled"] }
Expand Down Expand Up @@ -105,6 +111,13 @@ required-features = ["test-support"]
test = false
bench = false

[[bin]]
name = "copilot-host-crash-fixture"
path = "tests/fixtures/host_crash_fixture.rs"
required-features = ["test-support"]
test = false
bench = false

[build-dependencies]
base64 = "0.22"
dirs = "5"
Expand Down
80 changes: 55 additions & 25 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub mod hooks;
mod jsonrpc;
/// Permission-policy helpers that produce a [`handler::PermissionHandler`].
pub mod permission;
mod process_tree;
/// BYOK bearer-token provider callbacks.
pub mod provider_token;
mod provider_token_dispatch;
Expand Down Expand Up @@ -1066,6 +1067,7 @@ impl std::fmt::Debug for Client {

struct ClientInner {
child: parking_lot::Mutex<Option<Child>>,
process_tree: parking_lot::Mutex<Option<process_tree::ProcessTree>>,
#[cfg(feature = "bundled-in-process")]
/// In-process FFI runtime host, set only for [`Transport::InProcess`].
/// Closing it tears down the native runtime connection.
Expand Down Expand Up @@ -1302,6 +1304,7 @@ impl Client {
reader,
writer,
None,
None,
working_directory,
options.on_list_models,
extension_launch_provider.clone(),
Expand All @@ -1317,7 +1320,7 @@ impl Client {
port,
connection_token: _,
} => {
let (mut child, actual_port, spawn_elapsed, port_wait_elapsed) =
let (mut child, tree, actual_port, spawn_elapsed, port_wait_elapsed) =
Self::spawn_tcp(&program, &options, &working_directory, port).await?;
timings.process_spawn_ms = Some(StartupTimings::millis(spawn_elapsed));
timings.port_wait_ms = Some(StartupTimings::millis(port_wait_elapsed));
Expand All @@ -1334,6 +1337,7 @@ impl Client {
reader,
writer,
Some(child),
tree,
working_directory,
options.on_list_models,
extension_launch_provider.clone(),
Expand All @@ -1346,7 +1350,7 @@ impl Client {
)?
}
Transport::Stdio => {
let (mut child, spawn_elapsed) =
let (mut child, tree, spawn_elapsed) =
Self::spawn_stdio(&program, &options, &working_directory)?;
timings.process_spawn_ms = Some(StartupTimings::millis(spawn_elapsed));
let stdin = child.stdin.take().expect("stdin is piped");
Expand All @@ -1356,6 +1360,7 @@ impl Client {
stdout,
stdin,
Some(child),
tree,
working_directory,
options.on_list_models,
extension_launch_provider.clone(),
Expand Down Expand Up @@ -1422,6 +1427,7 @@ impl Client {
reader,
writer,
None,
None,
working_directory,
options.on_list_models,
extension_launch_provider.clone(),
Expand Down Expand Up @@ -1563,6 +1569,7 @@ impl Client {
reader,
writer,
None,
None,
cwd,
None,
None,
Expand All @@ -1589,6 +1596,7 @@ impl Client {
reader,
writer,
None,
None,
cwd,
None,
Some(provider),
Expand Down Expand Up @@ -1619,6 +1627,7 @@ impl Client {
reader,
writer,
None,
None,
cwd,
None,
None,
Expand All @@ -1645,6 +1654,7 @@ impl Client {
reader,
writer,
None,
None,
cwd,
None,
None,
Expand All @@ -1671,6 +1681,7 @@ impl Client {
reader,
writer,
None,
None,
cwd,
None,
None,
Expand Down Expand Up @@ -1698,6 +1709,7 @@ impl Client {
reader: impl AsyncRead + Unpin + Send + 'static,
writer: impl AsyncWrite + Unpin + Send + 'static,
child: Option<Child>,
process_tree: Option<process_tree::ProcessTree>,
cwd: PathBuf,
on_list_models: Option<Arc<dyn ListModelsHandler>>,
extension_launch_provider: Option<
Expand Down Expand Up @@ -1732,6 +1744,7 @@ impl Client {
let client = Self {
inner: Arc::new(ClientInner {
child: parking_lot::Mutex::new(child),
process_tree: parking_lot::Mutex::new(process_tree),
#[cfg(feature = "bundled-in-process")]
ffi_host: parking_lot::Mutex::new(None),
rpc,
Expand Down Expand Up @@ -1870,13 +1883,6 @@ impl Client {
.stdout(Stdio::piped())
.stderr(Stdio::piped());

#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.as_std_mut().creation_flags(CREATE_NO_WINDOW);
}

command
}

Expand Down Expand Up @@ -1933,7 +1939,7 @@ impl Client {
program: &Path,
options: &ClientOptions,
working_directory: &Path,
) -> Result<(Child, Duration)> {
) -> Result<(Child, Option<process_tree::ProcessTree>, Duration)> {
info!(cwd = ?working_directory, program = %program.display(), "spawning copilot CLI (stdio)");
let mut command = Self::build_command(program, options, working_directory);
command
Expand All @@ -1945,21 +1951,27 @@ impl Client {
.args(&options.extra_args)
.stdin(Stdio::piped());
let spawn_start = Instant::now();
let child = command.spawn()?;
let (child, tree) = process_tree::spawn(&mut command)?;
let spawn_elapsed = spawn_start.elapsed();
debug!(
elapsed_ms = spawn_elapsed.as_millis(),
"Client::spawn_stdio subprocess spawned"
);
Ok((child, spawn_elapsed))
Ok((child, tree, spawn_elapsed))
}

async fn spawn_tcp(
program: &Path,
options: &ClientOptions,
working_directory: &Path,
port: u16,
) -> Result<(Child, u16, Duration, Duration)> {
) -> Result<(
Child,
Option<process_tree::ProcessTree>,
u16,
Duration,
Duration,
)> {
info!(cwd = ?working_directory, program = %program.display(), port = %port, "spawning copilot CLI (tcp)");
let mut command = Self::build_command(program, options, working_directory);
command
Expand All @@ -1971,7 +1983,7 @@ impl Client {
.args(&options.extra_args)
.stdin(Stdio::null());
let spawn_start = Instant::now();
let mut child = command.spawn()?;
let (mut child, tree) = process_tree::spawn(&mut command)?;
let spawn_elapsed = spawn_start.elapsed();
debug!(
elapsed_ms = spawn_elapsed.as_millis(),
Expand Down Expand Up @@ -2018,7 +2030,7 @@ impl Client {
"Client::spawn_tcp TCP port wait complete"
);
info!(port = %actual_port, "CLI server listening");
Ok((child, actual_port, spawn_elapsed, port_wait_elapsed))
Ok((child, tree, actual_port, spawn_elapsed, port_wait_elapsed))
}

fn drain_stderr(child: &mut Child) {
Expand Down Expand Up @@ -2561,11 +2573,12 @@ impl Client {
/// Cooperatively shut down the client and the CLI child process.
///
/// Walks every still-registered session and sends `session.destroy`
/// for each one, asks SDK-owned runtimes to shut down, then kills the
/// CLI child. Errors from per-session destroys, runtime shutdown, and
/// the final child-kill are collected into
/// [`StopErrors`] rather than short-circuiting on the first failure
/// — so callers see the full picture of teardown.
/// for each one, asks SDK-owned runtimes to shut down, terminates the
/// Windows-owned CLI Job Object when present, and reaps the root process.
/// Errors from per-session destroys, runtime shutdown, and final process
/// termination are collected into [`StopErrors`] rather than
/// short-circuiting on the first failure — so callers see the full picture
/// of teardown.
///
/// If you have already called [`Session::disconnect`] on every
/// session this client created, the per-session destroy step is a
Expand Down Expand Up @@ -2654,8 +2667,14 @@ impl Client {
}

let child = self.inner.child.lock().take();
let process_tree = self.inner.process_tree.lock().take();
*self.inner.state.lock() = ConnectionState::Disconnected;
*self.inner.models_cache.lock() = Arc::new(tokio::sync::OnceCell::new());
if let Some(process_tree) = process_tree
&& let Err(error) = process_tree.terminate()
{
errors.push(error.into());
}
if let Some(mut child) = child {
match child.try_wait() {
Ok(Some(_status)) => {}
Expand Down Expand Up @@ -2696,10 +2715,9 @@ impl Client {
///
/// Synchronous fallback when [`stop`](Self::stop) is unsuitable — for
/// example when the awaiting tokio runtime is shutting down or the
/// process is wedged on I/O. Sends a kill signal without awaiting
/// reaper completion and immediately drops all per-session router
/// state so dependent tasks observe a closed channel rather than a
/// hang.
/// process is wedged on I/O. Terminates the Windows-owned CLI Job Object
/// when present and immediately drops all per-session router state so
/// dependent tasks observe a closed channel rather than a hang.
///
/// # Cancel safety
///
Expand All @@ -2725,6 +2743,11 @@ impl Client {
let pid = self.pid();
info!(pid = ?pid, "force-stopping CLI process");
self.inner.extension_launch_provider.clear();
if let Some(process_tree) = self.inner.process_tree.lock().take()
&& let Err(error) = process_tree.terminate()
{
error!(pid = ?pid, %error, "failed to terminate CLI process tree");
}
if let Some(mut child) = self.inner.child.lock().take()
&& let Err(e) = child.start_kill()
{
Expand Down Expand Up @@ -2786,8 +2809,13 @@ impl Client {

impl Drop for ClientInner {
fn drop(&mut self) {
let pid = self.child.lock().as_ref().and_then(Child::id);
if let Some(process_tree) = self.process_tree.lock().take()
&& let Err(error) = process_tree.terminate()
{
error!(pid = ?pid, %error, "failed to terminate CLI process tree on drop");
}
if let Some(ref mut child) = *self.child.lock() {
let pid = child.id();
if let Err(e) = child.start_kill() {
error!(pid = ?pid, error = %e, "failed to kill CLI process on drop");
} else {
Expand Down Expand Up @@ -3447,6 +3475,7 @@ mod tests {
client_read,
client_write,
Some(child),
None,
temp.path().to_path_buf(),
None,
None,
Expand Down Expand Up @@ -3536,6 +3565,7 @@ mod tests {
Client {
inner: Arc::new(ClientInner {
child: parking_lot::Mutex::new(None),
process_tree: parking_lot::Mutex::new(None),
#[cfg(feature = "bundled-in-process")]
ffi_host: parking_lot::Mutex::new(None),
rpc: {
Expand Down
Loading
Loading