Files
openidea/crates/ide-github/src/process.rs
T
l0ng-ai 96be40cd84 fix(process): Give child processes the host environment inside an AppImage (#118)
Fixes #117

Inside an AppImage, AppRun and the linuxdeploy GTK/GStreamer hooks point LD_LIBRARY_PATH, PATH, XDG_DATA_DIRS, GIO_MODULE_DIR, GTK_PATH, GST_PLUGIN_* … into the mounted image, and every child inherited them, so host programs loaded the image's older libraries (curl + bundled nghttp2 → symbol lookup error when installing Kotlin LSP). ide_core::process::prepare (formerly no_window) now gives children the host environment back: entries inside $APPDIR are dropped (a <NAME>_ORIG wins), emptied variables and the runtime's launch variables are removed, explicit call-site values are kept; the IDE process itself is unchanged. Covers all spawn sites that used no_window plus terminal/run PTYs, run configuration environments and stop commands, login-shell probes, attach ps/kill, docker PATH, tool search PATH and the Linux browser opener. No-op outside an AppImage. Not verified on a Linux AppImage.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-28 13:50:20 +08:00

182 lines
5.7 KiB
Rust

//! Child processes of the gh CLI: bounded runs and cancellable line streams.
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::io::Read;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
pub(crate) struct Output {
pub stdout: String,
pub stderr: String,
pub success: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Stream {
Stdout,
Stderr,
}
fn prepare(cmd: &mut Command) {
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
ide_core::process::prepare(cmd);
}
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run gh: {e}")))
}
/// Kills the child and, on Unix, its whole process group (gh spawns git and helpers).
pub(crate) fn kill(child: &mut Child) {
#[cfg(unix)]
{
let pid = child.id() as libc::pid_t;
// SAFETY: plain syscall; the child leads its own process group (`prepare`).
unsafe {
libc::kill(-pid, libc::SIGTERM);
}
std::thread::sleep(Duration::from_millis(20));
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
}
let _ = child.kill();
let _ = child.wait();
}
fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<String> {
std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = pipe.read_to_end(&mut buf);
String::from_utf8_lossy(&buf).into_owned()
})
}
/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires;
/// a non-zero exit is reported in [`Output::success`], not as an error.
pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
prepare(&mut cmd);
let mut child = spawn(&mut cmd)?;
let out = child.stdout.take().map(read_all);
let err = child.stderr.take().map(read_all);
let deadline = Instant::now() + timeout;
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if cancel.is_some_and(CancellationToken::is_cancelled) {
kill(&mut child);
return Err(IdeError::cancelled());
}
if Instant::now() >= deadline {
kill(&mut child);
return Err(IdeError::unavailable("gh did not respond in time"));
}
std::thread::sleep(Duration::from_millis(10));
};
Ok(Output {
stdout: out.map(|t| t.join().unwrap_or_default()).unwrap_or_default(),
stderr: err.map(|t| t.join().unwrap_or_default()).unwrap_or_default(),
success: status.success(),
})
}
enum Event {
Line(Stream, String),
Closed,
}
fn spawn_reader(mut pipe: impl Read + Send + 'static, tx: mpsc::Sender<Event>, stream: Stream) {
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
let mut pending: Vec<u8> = Vec::new();
let flush = |pending: &mut Vec<u8>| {
if !pending.is_empty() {
let line = String::from_utf8_lossy(pending).trim_end().to_string();
pending.clear();
let _ = tx.send(Event::Line(stream, line));
}
};
loop {
match pipe.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
for &b in &buf[..n] {
// `\r` too: git progress rewrites its line in place.
if b == b'\n' || b == b'\r' {
flush(&mut pending);
} else {
pending.push(b);
}
}
}
}
}
flush(&mut pending);
let _ = tx.send(Event::Closed);
});
}
/// Runs `cmd`, handing every output line to `on_line` until it exits or `cancel`
/// fires. Returns whether it exited successfully plus the last stderr lines.
pub(crate) fn stream(
mut cmd: Command,
cancel: &CancellationToken,
on_line: &mut dyn FnMut(Stream, String),
) -> IdeResult<(bool, Vec<String>)> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
prepare(&mut cmd);
let mut child = spawn(&mut cmd)?;
let (tx, rx) = mpsc::channel::<Event>();
let mut open = 0;
if let Some(out) = child.stdout.take() {
spawn_reader(out, tx.clone(), Stream::Stdout);
open += 1;
}
if let Some(err) = child.stderr.take() {
spawn_reader(err, tx.clone(), Stream::Stderr);
open += 1;
}
drop(tx);
let mut stderr_tail: Vec<String> = Vec::new();
while open > 0 {
if cancel.is_cancelled() {
kill(&mut child);
return Err(IdeError::cancelled());
}
match rx.recv_timeout(Duration::from_millis(50)) {
Ok(Event::Line(stream, line)) => {
if stream == Stream::Stderr && !line.trim().is_empty() {
stderr_tail.push(line.clone());
if stderr_tail.len() > 20 {
stderr_tail.remove(0);
}
}
on_line(stream, line);
}
Ok(Event::Closed) => open -= 1,
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if cancel.is_cancelled() {
kill(&mut child);
return Err(IdeError::cancelled());
}
std::thread::sleep(Duration::from_millis(10));
};
Ok((status.success(), stderr_tail))
}