Файли
openidea/crates/ide-run/src/process.rs
T

1386 рядки
52 KiB
Rust

//! Process lifecycle for run configurations: spawn (on pipes, or on a pseudo-terminal
//! for "Emulate terminal in output console"), stream output, stdin, stop.
use crate::utf8::OutputDecoder;
use crate::{OutputChunk, RunConfig, RunInfo};
use ide_core::{IdeError, IdeEvent, IdeResult, RunId, SharedSink};
use parking_lot::Mutex;
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
use std::time::{Duration, Instant};
const BATCH_INTERVAL: Duration = Duration::from_millis(8);
const BATCH_BYTES: usize = 64 * 1024;
/// After the main process exits, how long to wait for pipes still held by
/// orphaned descendants before reporting the exit.
const EXIT_DRAIN: Duration = Duration::from_millis(500);
/// Delay between SIGINT and SIGKILL on [`ProcessManager::stop`].
const STOP_GRACE: Duration = Duration::from_secs(3);
/// Longest a configuration's stop command (e.g. `docker stop`) may take before signals follow.
const STOP_COMMAND_TIMEOUT: Duration = Duration::from_secs(60);
/// Finished runs kept for [`ProcessManager::list`].
const MAX_FINISHED: usize = 20;
/// PTY size until the console reports its own.
const DEFAULT_PTY: (u16, u16) = (120, 30);
type OutputFn = Arc<dyn Fn(OutputChunk) + Send + Sync>;
/// Environment of a launched process: the inherited one (unless cleared) plus `vars`.
#[derive(Clone, Debug, Default)]
struct LaunchEnv {
clear: bool,
vars: BTreeMap<String, String>,
}
/// A configuration's stop command and where it runs.
struct StopCommand {
line: String,
cwd: PathBuf,
env: LaunchEnv,
}
struct Entry {
project_root: PathBuf,
id: RunId,
config_id: String,
name: String,
pid: Mutex<Option<u32>>,
stdin: Mutex<Option<ide_core::input::InputWriter>>,
/// The PTY master of a terminal-emulated run (resize; dropped on exit).
pty: Option<Mutex<Option<Box<dyn MasterPty + Send>>>>,
stop: Option<StopCommand>,
output: OutputFn,
exited: AtomicBool,
code: Mutex<Option<i32>>,
stopping: AtomicBool,
}
impl Entry {
fn info(&self) -> RunInfo {
RunInfo {
id: self.id,
config_id: self.config_id.clone(),
name: self.name.clone(),
pid: *self.pid.lock(),
running: !self.exited.load(Ordering::Acquire),
pty: self.pty.is_some(),
}
}
}
/// Per-start options taken from settings.
#[derive(Clone, Debug, Default)]
pub struct StartOptions {
/// Output encoding label (`UTF-8`, `GBK`, `windows-1252`…); `None` = UTF-8.
pub encoding: Option<String>,
/// Project content and source roots added to Python launch environments.
pub python_paths: Vec<PathBuf>,
/// Where the Ruby test reporter is installed (see [`crate::ruby_reporter`]); Ruby test runs load it.
pub ruby_reporter: Option<PathBuf>,
}
#[derive(Default)]
pub struct ProcessManager {
runs: Arc<Mutex<BTreeMap<RunId, Arc<Entry>>>>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Stream {
Stdout,
Stderr,
}
enum Exit {
Pipes(std::io::Result<ExitStatus>),
Pty(std::io::Result<portable_pty::ExitStatus>),
}
enum Msg {
Data(Stream, Vec<u8>),
Exited(Exit),
}
/// The program and arguments actually executed, plus the echoed command line.
pub(crate) struct Invocation {
pub program: PathBuf,
pub args: Vec<String>,
/// Passed verbatim (Windows `cmd /S /C "…"`: cmd does not parse C runtime quoting).
pub raw_args: bool,
/// A command line run by the platform shell (`display` is the line).
pub shell: bool,
pub display: String,
}
const SHELL_CHARS: &[char] = &[
'|', '&', ';', '<', '>', '(', ')', '$', '`', '\\', '"', '\'', '*', '?', '[', ']', '#', '~', '=', '%', '{', '}',
'\n',
];
fn needs_quoting(s: &str) -> bool {
s.is_empty() || s.contains(char::is_whitespace) || s.contains(SHELL_CHARS)
}
fn quote(s: &str) -> String {
if !needs_quoting(s) {
s.to_owned()
} else if cfg!(windows) {
format!("\"{}\"", s.replace('"', "\\\""))
} else {
format!("'{}'", s.replace('\'', "'\\''"))
}
}
pub(crate) fn invocation(config: &RunConfig, cwd: &Path) -> Invocation {
let windows = cfg!(windows);
let command = config.command.trim();
let looks_like_shell = command.contains(char::is_whitespace) || command.contains(SHELL_CHARS);
if config.args.is_empty() && looks_like_shell && !cwd.join(command).is_file() {
return shell_invocation(command, windows, std::env::var("COMSPEC").ok());
}
let program = resolve_program(command, cwd, config, windows);
let mut display = quote(command);
for a in &config.args {
display.push(' ');
display.push_str(&quote(a));
}
Invocation { program, args: config.args.clone(), raw_args: false, shell: false, display }
}
/// "Shorten command line" for a `java` launch too long for the platform (see
/// [`crate::java_cmdline`]): rewrites `inv` and returns the temporary file to delete on exit.
fn shorten_java(inv: &mut Invocation, config: &RunConfig, cwd: &Path) -> IdeResult<TempFile> {
if inv.raw_args || !crate::java_cmdline::is_java_launcher(&inv.program) {
return Ok(TempFile(None));
}
let java = if inv.program.components().count() == 1 {
crate::which::find_executable(&inv.program.to_string_lossy(), &crate::wrap::search_path(config))
.unwrap_or_else(|| inv.program.clone())
} else {
inv.program.clone()
};
let dir = crate::java_cmdline::default_temp_dir();
let shortened = crate::java_cmdline::shorten(&java, &inv.args, cwd, cfg!(windows), &dir)
.map_err(|e| IdeError::internal(format!("cannot shorten the command line: {e}")))?;
let Some(s) = shortened else { return Ok(TempFile(None)) };
tracing::debug!(how = s.how.as_str(), file = %s.file.display(), "java command line shortened");
let mut display = quote(config.command.trim());
for a in &s.args {
display.push(' ');
display.push_str(&quote(a));
}
inv.args = s.args;
inv.display = display;
Ok(TempFile(Some(s.file)))
}
/// Tells JVM and Python processes to write the encoding the console decodes (see
/// [`ide_core::output_encoding`]); returns the notice line to hide from the output.
fn match_console_encoding(
inv: &mut Invocation,
env: &mut LaunchEnv,
config: &RunConfig,
options: &StartOptions,
) -> Option<String> {
use ide_core::output_encoding;
let inherit = !env.clear;
let inherited = |key: &str| if inherit { std::env::var(key).ok() } else { None };
let encoding = options.encoding.as_deref().unwrap_or("UTF-8");
let fix = if inv.shell {
output_encoding::fix_command_line(&inv.display, &env.vars, inherited, encoding, cfg!(windows))
} else {
let program = inv.program.to_string_lossy();
output_encoding::fix(&program, &inv.args, &env.vars, inherited, encoding, cfg!(windows))
};
env.vars.extend(fix.env);
let mut prepend = fix.java_args;
if fix.gradle_init_script {
match crate::gradle_jvm::write_script(
&crate::gradle_jvm::default_dir(),
"file-encoding",
output_encoding::GRADLE_FILE_ENCODING_SCRIPT,
) {
Ok(script) => prepend.extend(["--init-script".to_owned(), script.to_string_lossy().into_owned()]),
Err(e) => tracing::warn!("cannot write the Gradle file.encoding init script: {e}"),
}
}
let changed = fix.args.is_some() || !prepend.is_empty();
if changed && !inv.raw_args {
inv.args = fix.args.unwrap_or_else(|| std::mem::take(&mut inv.args));
inv.args.splice(0..0, prepend);
let mut display = quote(config.command.trim());
for a in &inv.args {
display.push(' ');
display.push_str(&quote(a));
}
inv.display = display;
}
fix.notice
}
/// A launch's pathing jar / argfile, deleted when dropped (the process exited, or never started).
struct TempFile(Option<PathBuf>);
impl Drop for TempFile {
fn drop(&mut self) {
if let Some(f) = self.0.take() {
let _ = std::fs::remove_file(f);
}
}
}
/// `command` as one line for the platform shell: `/bin/sh -c`, or `cmd /D /S /C "…"`
/// (`/S` strips exactly the outer quotes, so the line's own quoting survives).
fn shell_invocation(command: &str, windows: bool, comspec: Option<String>) -> Invocation {
if windows {
return Invocation {
program: comspec.filter(|c| !c.is_empty()).unwrap_or_else(|| "cmd.exe".into()).into(),
args: vec!["/D".into(), "/S".into(), "/C".into(), format!("\"{command}\"")],
raw_args: true,
shell: true,
display: command.into(),
};
}
Invocation {
program: "/bin/sh".into(),
args: vec!["-c".into(), command.into()],
raw_args: false,
shell: true,
display: command.into(),
}
}
/// The program to spawn: relative paths like `./gradlew` or `.venv/bin/python`
/// against the run directory; a bare name through the configuration's search path
/// (on Windows, first the run directory, also with `PATHEXT`: `kotlinc` → `kotlinc.bat`), so launchers installed
/// outside the app's own `PATH` (`mvn` in a version manager's shims) still start.
fn resolve_program(command: &str, cwd: &Path, config: &RunConfig, windows: bool) -> PathBuf {
let path = Path::new(command);
if path.is_relative() && path.components().count() > 1 {
return cwd.join(path);
}
if path.components().count() == 1 && path.is_relative() {
let search = crate::wrap::search_path(config);
let pathext = std::env::var("PATHEXT").ok();
if windows {
// As cmd.exe does: the run directory first, a name with an extension as is,
// an extensionless one only through PATHEXT (a POSIX `gradlew` script is not runnable).
let local = cwd.join(path);
if path.extension().is_some() && local.is_file() {
return local;
}
if path.extension().is_none()
&& let Some(found) = crate::which::executable_names(command, true, pathext.as_deref())
.iter()
.map(|name| cwd.join(name))
.find(|p| p.is_file())
{
return found;
}
}
if let Some(found) = crate::which::find_executable_in(command, &search, windows, pathext.as_deref()) {
return found;
}
}
path.to_path_buf()
}
/// "Save console output to file": everything shown in the console is also written to `path`.
fn tee_output(path: &Path, on_output: OutputFn) -> IdeResult<OutputFn> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let file = std::fs::File::create(path)
.map_err(|e| IdeError::invalid(format!("cannot write console output to {}: {e}", path.display())))?;
let file = Mutex::new(std::io::LineWriter::new(file));
Ok(Arc::new(move |chunk: OutputChunk| {
let text = match &chunk {
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => text,
};
let _ = file.lock().write_all(text.as_bytes());
on_output(chunk);
}))
}
fn resolve_cwd(root: &Path, cwd: Option<&Path>) -> PathBuf {
match cwd {
Some(c) if c.is_absolute() => c.to_path_buf(),
Some(c) if !c.as_os_str().is_empty() => root.join(c),
_ => root.to_path_buf(),
}
}
impl ProcessManager {
pub fn new() -> Self {
Self::default()
}
/// Starts a run; its `IdeEvent::RunExited` goes to `sink` (the starter's) only.
pub fn start(
&self,
root: &Path,
config: &RunConfig,
options: &StartOptions,
sink: SharedSink,
on_output: Box<dyn Fn(OutputChunk) + Send + Sync>,
) -> IdeResult<RunInfo> {
if config.command.trim().is_empty() {
return Err(IdeError::invalid("run configuration has no command"));
}
let approved = match crate::external::approved(config) {
Some(Ok(c)) => Some(c),
Some(Err(e)) => return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e)),
None => None,
};
let config = approved.as_ref().unwrap_or(config);
let plan = crate::go::plan(root, config)?;
let followup = plan.as_ref().and_then(|p| p.run.as_ref()).map(|run| {
let cwd = resolve_cwd(root, run.cwd.as_deref());
(run.clone(), cwd)
});
let config = plan.as_ref().map_or(config, |p| &p.build);
let cwd = resolve_cwd(root, config.cwd.as_deref());
if !cwd.is_dir() {
return Err(IdeError::not_found(format!("working directory does not exist: {}", cwd.display())));
}
crate::detect::cmake::prepare(config, &cwd)?;
let mut inv = invocation(config, &cwd);
let extras = &config.extras;
if extras.emulate_terminal && extras.stdin_file.is_some() {
return Err(IdeError::invalid("input cannot be redirected from a file when emulating a terminal"));
}
let mut env = launch_env(root, config)?;
if matches!(config.kind.as_str(), "python" | "pytest" | "django-test") {
crate::python_path::extend(&mut env.vars, &options.python_paths, !env.clear)?;
}
if let Some(dir) = options.ruby_reporter.as_deref().filter(|_| crate::ruby_reporter::applies(config)) {
crate::ruby_preload::add(&mut env.vars, dir, crate::ruby_reporter::FEATURE, !env.clear);
env.vars.insert("OPENIDEA_TEST_REPORT".into(), "1".into());
}
let on_output: OutputFn = Arc::from(on_output);
let on_output = match extras.output_file.as_deref() {
Some(file) => tee_output(&resolve_cwd(root, Some(Path::new(file))), on_output)?,
None => on_output,
};
let stop = extras.stop_command.as_deref().map(str::trim).filter(|l| !l.is_empty()).map(|line| StopCommand {
line: line.to_owned(),
cwd: cwd.clone(),
env: env.clone(),
});
// A terminal re-encodes output itself; pipes carry the process's bytes as they are.
let notice =
if extras.emulate_terminal { None } else { match_console_encoding(&mut inv, &mut env, config, options) };
let temp = shorten_java(&mut inv, config, &cwd)?;
if extras.emulate_terminal && followup.is_some() {
return Err(IdeError::invalid("Go build and run requires the output console"));
}
if extras.emulate_terminal {
return self.start_pty(root, config, &inv, &cwd, &env, stop, options, sink, on_output, temp);
}
let mut cmd = Command::new(&inv.program);
if inv.raw_args {
add_raw_args(&mut cmd, &inv.args);
} else {
cmd.args(&inv.args);
}
cmd.current_dir(&cwd).stdout(Stdio::piped()).stderr(Stdio::piped());
let redirected = match extras.stdin_file.as_deref() {
Some(file) => {
let path = resolve_cwd(root, Some(Path::new(file)));
let f = std::fs::File::open(&path)
.map_err(|e| IdeError::not_found(format!("cannot redirect input from {}: {e}", path.display())))?;
cmd.stdin(Stdio::from(f));
true
}
None => {
cmd.stdin(Stdio::piped());
false
}
};
apply_env(&mut cmd, &env);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | ide_core::process::CREATE_NO_WINDOW);
}
let mut child = cmd.spawn().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => IdeError::unavailable(format!("command not found: {}", config.command)),
_ => IdeError::from(e),
})?;
let id = RunId::next();
let stdin = (|| -> std::io::Result<_> {
Ok(match child.stdin.take() {
Some(pipe) if !redirected => {
#[cfg(unix)]
let wait_writable = {
use std::os::fd::AsRawFd;
ide_core::input::nonblocking(pipe.as_raw_fd())?
};
Some(input_writer(
id,
Box::new(pipe),
#[cfg(unix)]
wait_writable,
)?)
}
_ => None,
})
})()
.inspect_err(|_| {
let _ = child.kill();
let _ = child.wait();
})?;
let entry = Arc::new(Entry {
project_root: root.to_path_buf(),
id,
config_id: config.id.clone(),
name: config.name.clone(),
pid: Mutex::new(Some(child.id())),
stdin: Mutex::new(stdin),
pty: None,
stop,
output: on_output.clone(),
exited: AtomicBool::new(false),
code: Mutex::new(None),
stopping: AtomicBool::new(false),
});
self.register(entry.clone());
on_output(OutputChunk::System { text: format!("{}\n", inv.display) });
let (tx, rx) = mpsc::channel();
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let result = (|| -> std::io::Result<()> {
if let Some(out) = stdout {
spawn_reader(
id,
Stream::Stdout,
out,
tx.clone(),
#[cfg(unix)]
None,
)?;
}
if let Some(err) = stderr {
spawn_reader(
id,
Stream::Stderr,
err,
tx.clone(),
#[cfg(unix)]
None,
)?;
}
spawn_pump(rx, on_output.clone(), entry.clone(), sink, options.encoding.clone(), notice)?;
Ok(())
})();
if let Err(e) = result {
let _ = child.kill();
let _ = child.wait();
entry.exited.store(true, Ordering::Release);
return Err(IdeError::internal(format!("failed to start output threads: {e}")));
}
// The waiter owns the child; it must exist for the pump to ever finish.
let waiter_tx = tx;
let waiter_entry = entry.clone();
let run_root = root.to_path_buf();
std::thread::Builder::new()
.name(format!("openidea-run-wait-{}", id.0))
.spawn(move || {
let mut status = child.wait();
if status.as_ref().is_ok_and(ExitStatus::success)
&& let Some((run, cwd)) = followup
{
status = run_followup(&run_root, &run, &cwd, &waiter_entry, &waiter_tx);
}
drop(temp);
let _ = waiter_tx.send(Msg::Exited(Exit::Pipes(status)));
})
.map_err(|e| IdeError::internal(format!("failed to start waiter thread: {e}")))?;
tracing::debug!(%id, config = %config.id, pid = ?entry.pid, "run started");
Ok(entry.info())
}
/// [`Self::start`] on a pseudo-terminal: one merged output stream of raw terminal data.
#[allow(clippy::too_many_arguments)]
fn start_pty(
&self,
root: &Path,
config: &RunConfig,
inv: &Invocation,
cwd: &Path,
env: &LaunchEnv,
stop: Option<StopCommand>,
options: &StartOptions,
sink: SharedSink,
on_output: OutputFn,
temp: TempFile,
) -> IdeResult<RunInfo> {
let pair = native_pty_system()
.openpty(pty_size(DEFAULT_PTY.0, DEFAULT_PTY.1))
.map_err(|e| IdeError::internal(format!("openpty failed: {e}")))?;
let mut cmd = CommandBuilder::new(&inv.program);
cmd.args(&inv.args);
cmd.cwd(cwd);
if env.clear {
cmd.env_clear();
} else {
for (k, v) in ide_core::process::host_env_fixes() {
match v {
Some(v) => cmd.env(k, v),
None => cmd.env_remove(k),
}
}
for (k, v) in ide_core::process::shell_env() {
cmd.env(k, v);
}
}
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
for (k, v) in &env.vars {
cmd.env(k, v);
}
#[cfg(unix)]
let wait_writable = ide_core::input::nonblocking(
pair.master.as_raw_fd().ok_or_else(|| std::io::Error::other("PTY has no file descriptor"))?,
)?;
#[cfg(unix)]
let wait_readable = ide_core::input::readable(
pair.master.as_raw_fd().ok_or_else(|| std::io::Error::other("PTY has no file descriptor"))?,
)?;
let mut child = pair.slave.spawn_command(cmd).map_err(|e| {
let text = e.to_string();
if text.contains("No such file") || text.contains("not found") {
IdeError::unavailable(format!("command not found: {}", config.command))
} else {
IdeError::unavailable(format!("failed to start `{}`: {text}", config.command))
}
})?;
// The child holds its own copy of the slave; dropping ours lets reads hit EOF on exit.
drop(pair.slave);
let reader = pair.master.try_clone_reader().map_err(|e| IdeError::internal(format!("pty reader: {e}")))?;
let writer = pair.master.take_writer().map_err(|e| IdeError::internal(format!("pty writer: {e}")))?;
let id = RunId::next();
let writer = input_writer(
id,
writer,
#[cfg(unix)]
wait_writable,
)
.inspect_err(|_| {
let _ = child.kill();
let _ = child.wait();
})?;
let entry = Arc::new(Entry {
project_root: root.to_path_buf(),
id,
config_id: config.id.clone(),
name: config.name.clone(),
pid: Mutex::new(child.process_id()),
stdin: Mutex::new(Some(writer)),
pty: Some(Mutex::new(Some(pair.master))),
stop,
output: on_output.clone(),
exited: AtomicBool::new(false),
code: Mutex::new(None),
stopping: AtomicBool::new(false),
});
self.register(entry.clone());
on_output(OutputChunk::System { text: format!("{}\n", inv.display) });
let (tx, rx) = mpsc::channel();
let result = spawn_reader(
id,
Stream::Stdout,
reader,
tx.clone(),
#[cfg(unix)]
Some(Box::new(wait_readable)),
)
.and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), None));
if let Err(e) = result {
let _ = child.kill();
let _ = child.wait();
entry.exited.store(true, Ordering::Release);
return Err(IdeError::internal(format!("failed to start output threads: {e}")));
}
std::thread::Builder::new()
.name(format!("openidea-run-wait-{}", id.0))
.spawn(move || {
let status = child.wait();
drop(temp);
let _ = tx.send(Msg::Exited(Exit::Pty(status)));
})
.map_err(|e| IdeError::internal(format!("failed to start waiter thread: {e}")))?;
tracing::debug!(%id, config = %config.id, pid = ?entry.pid, "run started on a pty");
Ok(entry.info())
}
/// Resizes the pseudo-terminal of a terminal-emulated run.
pub fn resize(&self, id: RunId, cols: u16, rows: u16) -> IdeResult<()> {
let entry = self.entry(id)?;
let Some(pty) = &entry.pty else { return Err(IdeError::invalid("the process does not run in a terminal")) };
match pty.lock().as_ref() {
Some(master) => {
master.resize(pty_size(cols, rows)).map_err(|e| IdeError::internal(format!("resize failed: {e}")))
}
None => Ok(()),
}
}
fn register(&self, entry: Arc<Entry>) {
let mut runs = self.runs.lock();
runs.retain(|_, e| !(e.config_id == entry.config_id && e.exited.load(Ordering::Acquire)));
let finished: Vec<RunId> = runs.values().filter(|e| e.exited.load(Ordering::Acquire)).map(|e| e.id).collect();
if finished.len() >= MAX_FINISHED {
for id in &finished[..=finished.len() - MAX_FINISHED] {
runs.remove(id);
}
}
runs.insert(entry.id, entry);
}
fn entry(&self, id: RunId) -> IdeResult<Arc<Entry>> {
self.runs.lock().get(&id).cloned().ok_or_else(|| IdeError::not_found(format!("no such run: {id}")))
}
pub fn write_stdin(&self, id: RunId, data: &str) -> IdeResult<()> {
let entry = self.entry(id)?;
if entry.exited.load(Ordering::Acquire) {
return Err(IdeError::invalid("process has exited"));
}
let stdin = entry.stdin.lock();
let pipe = stdin.as_ref().ok_or_else(|| IdeError::invalid("stdin is closed"))?;
pipe.write(data.as_bytes()).map_err(Into::into)
}
/// Closes the process's stdin (sends EOF; on a terminal, types ^D).
pub fn close_stdin(&self, id: RunId) -> IdeResult<()> {
let entry = self.entry(id)?;
if entry.pty.is_some() {
return self.write_stdin(id, "\u{4}");
}
if let Some(stdin) = entry.stdin.lock().as_ref() {
stdin.finish();
}
Ok(())
}
/// Graceful stop (SIGINT, then SIGKILL after a timeout), whole process group.
pub fn stop(&self, id: RunId) -> IdeResult<()> {
self.stop_with_grace(id, STOP_GRACE)
}
/// [`Self::stop`] with an explicit grace period for the configured stop command. The
/// first stop only asks the process tree to exit (stop command, then `SIGINT`) and waits;
/// a second stop kills it.
pub fn stop_with_grace(&self, id: RunId, grace: Duration) -> IdeResult<()> {
let entry = self.entry(id)?;
let already_stopping = entry.stopping.swap(true, Ordering::AcqRel);
let Some(pid) = *entry.pid.lock() else { return Ok(()) };
// A run that ended by itself can leave members of its group behind: a Stop must
// still be able to reach them.
if entry.exited.load(Ordering::Acquire) && !group_alive(pid, &entry) {
return Ok(());
}
if already_stopping {
// Second stop request: don't wait out the grace period.
kill_tree(pid, true);
return Ok(());
}
if entry.stop.is_none() {
kill_tree(pid, false);
}
let entry = entry.clone();
std::thread::Builder::new()
.name(format!("openidea-run-stop-{}", id.0))
.spawn(move || {
let exits_within = |limit: Duration| {
let deadline = Instant::now() + limit;
while Instant::now() < deadline {
if !group_alive(pid, &entry) {
return true;
}
std::thread::sleep(Duration::from_millis(50));
}
!group_alive(pid, &entry)
};
if let Some(stop) = &entry.stop {
run_stop_command(stop, &entry.output);
if exits_within(grace) {
return;
}
kill_tree(pid, false);
}
// A first Stop only asks the process tree to exit, as IDEA does: it waits for
// the shutdown to finish (a Spring Boot app can take seconds) and a second Stop
// is what kills. Killing on a timer here cut shutdown logs off with SIGKILL.
while group_alive(pid, &entry) {
std::thread::sleep(Duration::from_millis(50));
}
})
.map_err(|e| IdeError::internal(format!("failed to start stop thread: {e}")))?;
Ok(())
}
/// Returns `None` while running, or the exit code once output has drained.
pub fn exit_code(&self, id: RunId) -> IdeResult<Option<Option<i32>>> {
let entry = self.entry(id)?;
if !entry.exited.load(Ordering::Acquire) {
return Ok(None);
}
Ok(Some(*entry.code.lock()))
}
pub fn list(&self) -> Vec<RunInfo> {
self.runs.lock().values().map(|e| e.info()).collect()
}
/// Kills every running process tree at once (shutting down).
pub fn kill_project(&self, root: &Path) {
for entry in self.runs.lock().values().filter(|e| e.project_root == root) {
if !entry.exited.load(Ordering::Acquire)
&& let Some(pid) = *entry.pid.lock()
{
kill_tree(pid, true);
}
}
}
pub fn kill_all(&self) {
for entry in self.runs.lock().values() {
entry.stopping.store(true, Ordering::Release);
if !entry.exited.load(Ordering::Acquire)
&& let Some(pid) = *entry.pid.lock()
{
kill_tree(pid, true);
}
}
}
}
impl Drop for ProcessManager {
fn drop(&mut self) {
self.kill_all();
}
}
/// The environment a configuration's process gets on top of the inherited one: `.env`
/// files, then the configured variables (later wins).
fn run_followup(
root: &Path,
config: &RunConfig,
cwd: &Path,
entry: &Entry,
tx: &Sender<Msg>,
) -> std::io::Result<ExitStatus> {
let mut pid = entry.pid.lock();
if entry.stopping.load(Ordering::Acquire) {
return Err(std::io::Error::other("run stopped after build"));
}
let inv = invocation(config, cwd);
let mut cmd = Command::new(&inv.program);
cmd.args(&inv.args).current_dir(cwd).stdout(Stdio::piped()).stderr(Stdio::piped());
match config.extras.stdin_file.as_deref() {
Some(file) => {
cmd.stdin(std::fs::File::open(resolve_cwd(root, Some(Path::new(file))))?);
}
None => {
cmd.stdin(Stdio::piped());
}
}
let env = launch_env(root, config).map_err(|e| std::io::Error::other(e.to_string()))?;
apply_env(&mut cmd, &env);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0000_0200 | ide_core::process::CREATE_NO_WINDOW);
}
(entry.output)(OutputChunk::System { text: format!("{}\n", inv.display) });
let mut child = cmd.spawn()?;
*pid = Some(child.id());
*entry.stdin.lock() = (|| -> std::io::Result<_> {
Ok(match child.stdin.take() {
Some(pipe) => {
#[cfg(unix)]
let wait_writable = {
use std::os::fd::AsRawFd;
ide_core::input::nonblocking(pipe.as_raw_fd())?
};
Some(input_writer(
entry.id,
Box::new(pipe),
#[cfg(unix)]
wait_writable,
)?)
}
None => None,
})
})()
.inspect_err(|_| {
let _ = child.kill();
let _ = child.wait();
})?;
drop(pid);
let readers = (|| {
if let Some(out) = child.stdout.take() {
spawn_reader(
entry.id,
Stream::Stdout,
out,
tx.clone(),
#[cfg(unix)]
None,
)?;
}
if let Some(err) = child.stderr.take() {
spawn_reader(
entry.id,
Stream::Stderr,
err,
tx.clone(),
#[cfg(unix)]
None,
)?;
}
Ok::<_, std::io::Error>(())
})();
if let Err(e) = readers {
let _ = child.kill();
let _ = child.wait();
return Err(e);
}
child.wait()
}
pub(crate) fn configure_environment(command: &mut Command, root: &Path, config: &RunConfig) -> IdeResult<()> {
apply_env(command, &launch_env(root, config)?);
Ok(())
}
fn launch_env(root: &Path, config: &RunConfig) -> IdeResult<LaunchEnv> {
let extras = &config.extras;
let mut vars = if extras.env_files.is_empty() {
BTreeMap::new()
} else {
let base: BTreeMap<String, String> =
if extras.include_system_env { crate::system_env() } else { BTreeMap::new() };
let paths: Vec<PathBuf> = extras.env_files.iter().map(|f| resolve_cwd(root, Some(Path::new(f)))).collect();
crate::envfile::load(&paths, &base).map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}")))?
};
if matches!(config.kind.as_str(), "python" | "pytest" | "django-test")
&& !config.env.contains_key("PYTHONUNBUFFERED")
&& !vars.contains_key("PYTHONUNBUFFERED")
{
vars.insert("PYTHONUNBUFFERED".into(), "1".into());
}
vars.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
Ok(LaunchEnv { clear: !extras.include_system_env, vars })
}
/// What a run inherits with "Include system environment variables" on: this process's
/// variables and the login shell's ones it lacks.
pub(crate) fn inherited_env() -> BTreeMap<String, String> {
let mut vars = crate::system_env();
vars.extend(
ide_core::process::shell_env().filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))),
);
vars
}
pub(crate) fn resolved_env(root: &Path, config: &RunConfig) -> IdeResult<BTreeMap<String, String>> {
let env = launch_env(root, config)?;
let mut vars = if env.clear { BTreeMap::new() } else { inherited_env() };
vars.extend(env.vars);
Ok(vars)
}
fn apply_env(cmd: &mut Command, env: &LaunchEnv) {
if env.clear {
cmd.env_clear();
} else {
ide_core::process::host_env(cmd).envs(ide_core::process::shell_env());
}
cmd.envs(&env.vars);
}
/// Runs `stop.line` through the platform shell and waits for it (bounded), echoing it
/// and any error output to the console.
fn run_stop_command(stop: &StopCommand, output: &OutputFn) {
output(OutputChunk::System { text: format!("{}\n", stop.line) });
let inv = shell_invocation(&stop.line, cfg!(windows), std::env::var("COMSPEC").ok());
let mut cmd = Command::new(&inv.program);
ide_core::process::prepare(&mut cmd);
if inv.raw_args {
add_raw_args(&mut cmd, &inv.args);
} else {
cmd.args(&inv.args);
}
cmd.current_dir(&stop.cwd).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped());
apply_env(&mut cmd, &stop.env);
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
output(OutputChunk::Stderr { text: format!("Cannot run the stop command: {e}\n") });
return;
}
};
let stderr = child.stderr.take().map(|mut pipe| {
std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = pipe.read_to_end(&mut buf);
String::from_utf8_lossy(&buf).into_owned()
})
});
let deadline = Instant::now() + STOP_COMMAND_TIMEOUT;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),
_ => {
let _ = child.kill();
let _ = child.wait();
break None;
}
}
};
let err = stderr.and_then(|h| h.join().ok()).unwrap_or_default();
if !status.is_some_and(|s| s.success()) && !err.trim().is_empty() {
output(OutputChunk::Stderr { text: format!("{}\n", err.trim_end()) });
}
}
fn pty_size(cols: u16, rows: u16) -> PtySize {
PtySize {
cols: if cols == 0 { DEFAULT_PTY.0 } else { cols },
rows: if rows == 0 { DEFAULT_PTY.1 } else { rows },
pixel_width: 0,
pixel_height: 0,
}
}
#[cfg(unix)]
fn group_alive(pid: u32, _entry: &Entry) -> bool {
// Signal 0 probes the group; it fails with ESRCH once every member is gone.
unsafe { libc::kill(-(pid as libc::pid_t), 0) == 0 }
}
#[cfg(not(unix))]
fn group_alive(_pid: u32, entry: &Entry) -> bool {
!entry.exited.load(Ordering::Acquire)
}
#[cfg(unix)]
fn kill_tree(pid: u32, force: bool) {
let sig = if force { libc::SIGKILL } else { libc::SIGINT };
let pid = pid as libc::pid_t;
if pid <= 0 {
return;
}
unsafe {
if libc::kill(-pid, sig) != 0 {
// Not a group leader after all (e.g. setpgid raced); signal the process itself.
libc::kill(pid, sig);
}
}
}
#[cfg(windows)]
fn add_raw_args(cmd: &mut Command, args: &[String]) {
use std::os::windows::process::CommandExt;
for a in args {
cmd.raw_arg(a);
}
}
#[cfg(not(windows))]
fn add_raw_args(cmd: &mut Command, args: &[String]) {
cmd.args(args);
}
#[cfg(windows)]
fn kill_tree(pid: u32, _force: bool) {
use std::os::windows::process::CommandExt;
// Console children have no reliable graceful signal from outside their console.
let _ = Command::new("taskkill")
.args(["/T", "/F", "/PID", &pid.to_string()])
.creation_flags(ide_core::process::CREATE_NO_WINDOW)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
fn spawn_reader(
id: RunId,
stream: Stream,
mut pipe: impl Read + Send + 'static,
tx: Sender<Msg>,
#[cfg(unix)] mut wait_readable: Option<Box<dyn FnMut() -> std::io::Result<()> + Send>>,
) -> std::io::Result<()> {
let label = if stream == Stream::Stdout { "out" } else { "err" };
std::thread::Builder::new().name(format!("openidea-run-{label}-{}", id.0)).spawn(move || {
let mut buf = vec![0u8; 16 * 1024];
loop {
match pipe.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if tx.send(Msg::Data(stream, buf[..n].to_vec())).is_err() {
break;
}
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
#[cfg(unix)]
if let Some(wait) = &mut wait_readable {
if let Err(e) = wait()
&& e.kind() != std::io::ErrorKind::Interrupted
{
tracing::warn!(%id, "run read: {e}");
break;
}
continue;
}
std::thread::sleep(BATCH_INTERVAL);
}
#[cfg(unix)]
Err(e) if e.raw_os_error() == Some(libc::EIO) => break,
Err(e) => {
tracing::warn!(%id, "run read: {e}");
break;
}
}
}
})?;
Ok(())
}
fn chunk(stream: Stream, text: String) -> OutputChunk {
match stream {
Stream::Stdout => OutputChunk::Stdout { text },
Stream::Stderr => OutputChunk::Stderr { text },
}
}
/// Merges both streams in arrival order, batching consecutive output of the same stream.
fn spawn_pump(
rx: mpsc::Receiver<Msg>,
on_output: OutputFn,
entry: Arc<Entry>,
sink: SharedSink,
encoding: Option<String>,
notice: Option<String>,
) -> std::io::Result<()> {
std::thread::Builder::new().name(format!("openidea-run-pump-{}", entry.id.0)).spawn(move || {
let mut out_dec = OutputDecoder::for_label(encoding.as_deref());
let mut err_dec = OutputDecoder::for_label(encoding.as_deref());
// "Picked up JAVA_TOOL_OPTIONS: …" from every JVM given the console encoding, on
// either stream (`spring-boot:run` copies its fork's stderr to Maven's stdout).
let filter = || notice.clone().map(ide_core::output_encoding::NoticeFilter::new);
let (mut out_notice, mut err_notice) = (filter(), filter());
let mut decoded = String::new();
let mut pending = String::new();
let mut pending_stream = Stream::Stdout;
let mut batch_started: Option<Instant> = None;
let mut status: Option<Exit> = None;
let mut drain_deadline: Option<Instant> = None;
let flush = |pending: &mut String, stream: Stream, started: &mut Option<Instant>| {
if !pending.is_empty() {
on_output(chunk(stream, std::mem::take(pending)));
}
*started = None;
};
loop {
let now = Instant::now();
let wait = [
batch_started.map(|t| (t + BATCH_INTERVAL).saturating_duration_since(now)),
drain_deadline.map(|t| t.saturating_duration_since(now)),
]
.into_iter()
.flatten()
.min()
.unwrap_or(Duration::from_secs(3600));
match rx.recv_timeout(wait) {
Ok(Msg::Data(stream, bytes)) => {
if stream != pending_stream {
flush(&mut pending, pending_stream, &mut batch_started);
pending_stream = stream;
}
let (dec, notice) = match stream {
Stream::Stdout => (&mut out_dec, &mut out_notice),
Stream::Stderr => (&mut err_dec, &mut err_notice),
};
match notice {
Some(filter) => {
dec.decode_into(&bytes, &mut decoded);
filter.filter(&decoded, &mut pending);
decoded.clear();
}
None => dec.decode_into(&bytes, &mut pending),
}
if !pending.is_empty() && batch_started.is_none() {
batch_started = Some(Instant::now());
}
if pending.len() >= BATCH_BYTES {
flush(&mut pending, pending_stream, &mut batch_started);
}
}
Ok(Msg::Exited(s)) => {
status = Some(s);
drain_deadline = Some(Instant::now() + EXIT_DRAIN);
}
Err(RecvTimeoutError::Timeout) => {
if drain_deadline.is_some_and(|d| Instant::now() >= d) {
break;
}
if batch_started.is_some_and(|t| t.elapsed() >= BATCH_INTERVAL) {
flush(&mut pending, pending_stream, &mut batch_started);
}
}
Err(RecvTimeoutError::Disconnected) => break,
}
}
flush(&mut pending, pending_stream, &mut batch_started);
for (stream, dec, notice) in
[(Stream::Stdout, &mut out_dec, &mut out_notice), (Stream::Stderr, &mut err_dec, &mut err_notice)]
{
let mut rest = dec.finish();
if let Some(filter) = notice {
let tail = std::mem::take(&mut rest);
filter.filter(&tail, &mut rest);
rest.push_str(&filter.finish());
}
if !rest.is_empty() {
on_output(chunk(stream, rest));
}
}
let (code, message) = describe_exit(status);
on_output(OutputChunk::System { text: format!("\n{message}\n") });
entry.stdin.lock().take();
if let Some(pty) = &entry.pty {
pty.lock().take();
}
// A stopped run is over when the whole process group is gone, not when the child we
// spawned exits: a shell or `mvn` can end while the application it started keeps its
// port, and reporting "finished" then would let Run start into an occupied port.
// A second Stop kills the group, which ends this wait.
#[cfg(unix)]
if entry.stopping.load(Ordering::Acquire)
&& let Some(pid) = *entry.pid.lock()
{
while group_alive(pid, &entry) {
std::thread::sleep(Duration::from_millis(50));
}
}
*entry.code.lock() = code;
entry.exited.store(true, Ordering::Release);
tracing::debug!(id = %entry.id, ?code, "run exited");
sink.emit(IdeEvent::RunExited { run: entry.id, code });
})?;
Ok(())
}
#[cfg(unix)]
fn signal_name(sig: i32) -> &'static str {
match sig {
libc::SIGHUP => "SIGHUP",
libc::SIGINT => "SIGINT",
libc::SIGQUIT => "SIGQUIT",
libc::SIGILL => "SIGILL",
libc::SIGTRAP => "SIGTRAP",
libc::SIGABRT => "SIGABRT",
libc::SIGBUS => "SIGBUS",
libc::SIGFPE => "SIGFPE",
libc::SIGKILL => "SIGKILL",
libc::SIGUSR1 => "SIGUSR1",
libc::SIGSEGV => "SIGSEGV",
libc::SIGUSR2 => "SIGUSR2",
libc::SIGPIPE => "SIGPIPE",
libc::SIGALRM => "SIGALRM",
libc::SIGTERM => "SIGTERM",
_ => "signal",
}
}
/// IDEA wording: `Process finished with exit code 130 (interrupted by signal 2:SIGINT)`.
fn describe_exit(status: Option<Exit>) -> (Option<i32>, String) {
match status {
Some(Exit::Pipes(Ok(s))) => {
if let Some(code) = s.code() {
return (Some(code), format!("Process finished with exit code {code}"));
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(sig) = s.signal() {
return signalled(sig);
}
}
(None, "Process finished".to_owned())
}
Some(Exit::Pty(Ok(s))) => match s.signal() {
None => {
let code = s.exit_code() as i32;
(Some(code), format!("Process finished with exit code {code}"))
}
Some(name) => match signal_number(name) {
Some(sig) => signalled(sig),
None => (None, format!("Process finished ({name})")),
},
},
Some(Exit::Pipes(Err(e)) | Exit::Pty(Err(e))) => {
(None, format!("Process finished with unknown exit status: {e}"))
}
None => (None, "Process finished".to_owned()),
}
}
#[cfg(unix)]
fn signalled(sig: i32) -> (Option<i32>, String) {
let code = 128 + sig;
(Some(code), format!("Process finished with exit code {code} (interrupted by signal {sig}:{})", signal_name(sig)))
}
#[cfg(not(unix))]
fn signalled(sig: i32) -> (Option<i32>, String) {
(Some(128 + sig), format!("Process finished with exit code {}", 128 + sig))
}
/// The number of a signal from the `strsignal` description portable-pty reports.
#[cfg(unix)]
fn signal_number(description: &str) -> Option<i32> {
(1..32).find(|&sig| {
// SAFETY: `strsignal` returns a pointer to a NUL-terminated C string that stays
// valid until the next call on this thread; it is copied before that.
let name = unsafe { libc::strsignal(sig) };
!name.is_null() && unsafe { std::ffi::CStr::from_ptr(name) }.to_string_lossy() == description
})
}
#[cfg(not(unix))]
fn signal_number(_description: &str) -> Option<i32> {
None
}
fn input_writer(
id: RunId,
writer: Box<dyn std::io::Write + Send>,
#[cfg(unix)] wait_writable: impl FnMut() -> std::io::Result<()> + Send + 'static,
) -> std::io::Result<ide_core::input::InputWriter> {
ide_core::input::InputWriter::new(
format!("openidea-run-input-{}", id.0),
writer,
#[cfg(unix)]
wait_writable,
move |e| {
tracing::warn!(%id, "run input: {e}");
},
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_lines_use_cmd_verbatim_on_windows() {
let line = r#"(if not exist "C:\T\openidea-run" mkdir "C:\T\openidea-run") && "C:\Program Files\rustc.exe" -o "C:\T\openidea-run\hi.exe" hi.rs"#;
let inv = shell_invocation(line, true, Some(r"C:\Windows\system32\cmd.exe".into()));
assert_eq!(inv.program, PathBuf::from(r"C:\Windows\system32\cmd.exe"));
assert_eq!(inv.args, ["/D", "/S", "/C", &format!("\"{line}\"")]);
assert!(inv.raw_args);
assert_eq!(inv.display, line);
assert_eq!(shell_invocation("dir", true, None).program, PathBuf::from("cmd.exe"));
assert_eq!(shell_invocation("dir", true, Some(String::new())).program, PathBuf::from("cmd.exe"));
let unix = shell_invocation("mkdir -p /tmp/x && cc -o /tmp/x/a a.c", false, None);
assert_eq!(unix.program, PathBuf::from("/bin/sh"));
assert_eq!(unix.args, ["-c", "mkdir -p /tmp/x && cc -o /tmp/x/a a.c"]);
assert!(!unix.raw_args);
}
#[test]
fn resolves_bare_names_through_pathext_on_windows() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(bin.join("kotlinc.bat"), "@echo off\n").unwrap();
let cwd = dir.path();
let path = std::env::join_paths([&bin]).unwrap().to_string_lossy().into_owned();
// Windows environment keys are case-insensitive (`Path`).
let config = |env: BTreeMap<String, String>| RunConfig {
id: "c".into(),
name: "c".into(),
kind: "maven".into(),
command: String::new(),
args: Vec::new(),
cwd: None,
env,
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
let config = config(BTreeMap::from([("Path".to_owned(), path)]));
assert_eq!(resolve_program("kotlinc", cwd, &config, true), bin.join("kotlinc.bat"));
// Project wrappers win over an identically named launcher on PATH on Windows only.
for name in ["gradlew.bat", "mvnw.cmd"] {
std::fs::write(cwd.join(name), "@echo off\n").unwrap();
std::fs::write(bin.join(name), "@echo off\n").unwrap();
assert_eq!(resolve_program(name, cwd, &config, true), cwd.join(name));
assert_eq!(resolve_program(name, cwd, &config, false), bin.join(name));
let bare = name.split('.').next().unwrap();
assert_eq!(resolve_program(bare, cwd, &config, true), cwd.join(name));
assert_eq!(resolve_program(bare, cwd, &config, false), PathBuf::from(bare));
std::fs::remove_file(cwd.join(name)).unwrap();
std::fs::create_dir(cwd.join(name)).unwrap();
assert_eq!(resolve_program(name, cwd, &config, true), bin.join(name));
assert_eq!(resolve_program(bare, cwd, &config, true), bin.join(name));
}
// An extensionless file is not runnable on Windows: only PATHEXT candidates count,
// and directories are ignored.
std::fs::write(cwd.join("local-tool"), "tool").unwrap();
std::fs::write(cwd.join("local-tool.exe"), "tool").unwrap();
assert_eq!(resolve_program("local-tool", cwd, &config, true), cwd.join("local-tool.exe"));
assert_eq!(resolve_program("local-tool", cwd, &config, false), PathBuf::from("local-tool"));
std::fs::remove_file(cwd.join("local-tool")).unwrap();
assert_eq!(resolve_program("local-tool", cwd, &config, true), cwd.join("local-tool.exe"));
std::fs::remove_file(cwd.join("local-tool.exe")).unwrap();
std::fs::create_dir(cwd.join("local-tool")).unwrap();
std::fs::create_dir(cwd.join("local-tool.exe")).unwrap();
assert_eq!(resolve_program("local-tool", cwd, &config, true), PathBuf::from("local-tool"));
// Not found: left to the OS (`command not found` at spawn).
assert_eq!(resolve_program("nope", cwd, &config, true), PathBuf::from("nope"));
// Unix: extensionless names only (`kotlinc.bat` does not match `kotlinc`).
assert_eq!(resolve_program("kotlinc", cwd, &config, false), PathBuf::from("kotlinc"));
// Relative paths resolve against the run directory on every platform.
assert_eq!(resolve_program("./gradlew", cwd, &config, true), cwd.join("./gradlew"));
assert_eq!(resolve_program(".venv/bin/python", cwd, &config, false), cwd.join(".venv/bin/python"));
}
}