diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 4cf8fdbeb..53e2bf18a 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -8,11 +8,13 @@ use ide_extension::Package; use ide_language::ProgramPin; use ide_plugin::{ CommitChange, CommitDraft, EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, - ProcessOutput, Prompt, RepositoryStatus, StatusItem, + ProcessChunk, ProcessCommand, ProcessOutput, Prompt, RepositoryStatus, StatusItem, }; use parking_lot::{Mutex, RwLock}; use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::io::Read; use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command as StdCommand, Stdio}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; @@ -124,6 +126,10 @@ impl SecretStore for FileSecretStore { const MAX_RESPONSE: usize = 8 << 20; /// Largest stdout / stderr kept from a process. const MAX_OUTPUT: usize = 4 << 20; +/// Most processes one plugin keeps running at once. +const MAX_PROCESSES: usize = 8; +/// Most output one stream of a handle holds until the plugin reads it. +const MAX_PENDING: usize = 1 << 20; /// Most files `find-files` walks before giving up. const MAX_WALK: usize = 200_000; @@ -176,6 +182,218 @@ enum Waiter { Commit(std::sync::mpsc::SyncSender), } +/// A process a plugin started and has not released (API 1.3 `process.start`). +/// Two reader threads copy its output into bounded buffers; the handle lives +/// as long as the plugin instance that started it. +struct Proc { + client: Option, + state: Mutex, +} + +struct ProcState { + child: Child, + stdin: Option, + /// What it printed and the plugin hasn't read yet. + stdout: Vec, + stderr: Vec, + /// Its exit status once it ended (a code, or -1 when a signal ended it). + status: Option, + /// Reader threads still copying its pipes into the buffers above. + readers: usize, +} + +/// Which pipe a reader thread drains. +#[derive(Clone, Copy)] +enum Stream { + Out, + Err, +} + +impl Proc { + /// Copies `pipe` into the matching buffer until it closes, keeping the last + /// [`MAX_PENDING`] bytes, then notes that the reader finished. + fn read(self: &Arc, mut pipe: impl Read + Send + 'static, stream: Stream) { + self.state.lock().readers += 1; + let this = Arc::clone(self); + std::thread::spawn(move || { + let mut buf = [0u8; 8 << 10]; + loop { + match pipe.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let mut state = this.state.lock(); + let out = match stream { + Stream::Out => &mut state.stdout, + Stream::Err => &mut state.stderr, + }; + out.extend_from_slice(&buf[..n]); + let over = out.len().saturating_sub(MAX_PENDING); + if over > 0 { + out.drain(..over); + let continuation = out.iter().take_while(|b| (**b & 0xC0) == 0x80).count(); + out.drain(..continuation); + } + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + this.state.lock().readers -= 1; + }); + } + + /// Notes its exit status if it ended (dropping its stdin then). + fn reap(&self) -> Result<(), HostError> { + let mut state = self.state.lock(); + if state.status.is_none() { + match try_wait_process(&mut state.child) { + Ok(Some(status)) => { + state.status = Some(status.code().unwrap_or(-1)); + state.stdin = None; + } + Ok(None) => {} + Err(e) => return Err(failed(e)), + } + } + Ok(()) + } + + /// Ends it and reaps it, so its status is known right away; killing twice + /// does nothing. + fn kill(&self) { + let mut state = self.state.lock(); + state.stdin = None; + if state.status.is_none() { + ide_core::process::kill_tree(&mut state.child); + if let Ok(status) = state.child.wait() { + state.status = Some(status.code().unwrap_or(-1)); + } + } + } + + /// What it printed since the last call, each stream at most `max` bytes. + fn chunk(&self, max: usize) -> ProcessChunk { + let mut state = self.state.lock(); + let eof = state.readers == 0; + ProcessChunk { + stdout: take_text(&mut state.stdout, max, eof), + stderr: take_text(&mut state.stderr, max, eof), + status: state.status, + } + } +} + +/// Decodes complete characters within the byte budget, retaining partial UTF-8 +/// until the reader gets the rest (or EOF). Even a tiny budget makes progress. +fn take_text(buf: &mut Vec, max: usize, eof: bool) -> String { + let limit = max.clamp(1, MAX_PENDING); + let mut out = String::new(); + let mut consumed = 0; + while consumed < buf.len() && out.len() < limit { + let bytes = &buf[consumed..]; + let (valid, invalid) = match std::str::from_utf8(bytes) { + Ok(text) => (text.len(), None), + Err(e) => (e.valid_up_to(), Some(e.error_len())), + }; + let text = std::str::from_utf8(&bytes[..valid]).expect("valid prefix"); + let mut end = text.len().min(limit - out.len()); + while !text.is_char_boundary(end) { + end -= 1; + } + if end == 0 && out.is_empty() && !text.is_empty() { + end = text.chars().next().unwrap().len_utf8(); + } + out.push_str(&text[..end]); + consumed += end; + if end < valid { + break; + } + match invalid { + Some(len) if len.is_some() || eof => { + if !out.is_empty() && out.len() + '\u{fffd}'.len_utf8() > limit { + break; + } + out.push('\u{fffd}'); + consumed += len.unwrap_or(bytes.len() - valid); + } + _ => break, + } + } + buf.drain(..consumed); + out +} + +/// Ends descendants before reaping an exited leader, while its process id is +/// still reserved. Otherwise inherited pipes can keep a completed call waiting. +fn try_wait_process(child: &mut Child) -> std::io::Result> { + #[cfg(unix)] + { + use rustix::process::{Pid, WaitId, WaitIdOptions, waitid}; + let status = waitid( + WaitId::Pid(Pid::from_child(child)), + WaitIdOptions::EXITED | WaitIdOptions::NOHANG | WaitIdOptions::NOWAIT, + )?; + if status.is_none() { + return Ok(None); + } + ide_core::process::kill_tree(child); + child.try_wait() + } + #[cfg(not(unix))] + { + let status = child.try_wait()?; + if status.is_some() { + ide_core::process::kill_tree(child); + } + Ok(status) + } +} + +fn env_name_eq(a: &std::ffi::OsStr, b: &std::ffi::OsStr) -> bool { + #[cfg(windows)] + { + // Use the platform's Command key comparison, including non-ASCII names. + let mut probe = StdCommand::new("unused"); + probe.env(a, "").env(b, ""); + probe.get_envs().count() == 1 + } + #[cfg(not(windows))] + { + a == b + } +} + +/// Validates against the environment after `prepare`, then freezes that snapshot +/// so only new keys are added, including when the host environment changes later. +fn append_process_env(command: &mut StdCommand, env: &[(String, String)]) -> Result<(), HostError> { + if env.len() > 64 { + return Err(HostError::Invalid("at most 64 environment variables".into())); + } + let mut inherited: Vec<_> = std::env::vars_os().collect(); + for (key, value) in command.get_envs() { + inherited.retain(|(k, _)| !env_name_eq(k, key)); + if let Some(value) = value { + inherited.push((key.to_owned(), value.to_owned())); + } + } + for (key, value) in env { + if key.is_empty() || key.len() > 256 || key.contains(['=', '\0']) { + return Err(HostError::Invalid(format!("{key:?} is not an environment variable name"))); + } + if value.len() > 4096 || value.contains('\0') { + return Err(HostError::Invalid(format!("the value of {key:?} is too long or holds a NUL"))); + } + if inherited.iter().any(|(k, _)| env_name_eq(k, std::ffi::OsStr::new(key))) { + return Err(HostError::Invalid(format!( + "environment variable {key:?} already exists; only new variables may be added" + ))); + } + inherited.push((key.into(), value.into())); + } + command.env_clear().envs(inherited); + Ok(()) +} + /// State shared by the host and every plugin's [`PluginApi`]. pub(super) struct Shared { pub sink: Arc, @@ -184,6 +402,10 @@ pub(super) struct Shared { pub data_dir: PathBuf, tasks: Mutex>, next_task: AtomicU64, + /// (plugin, handle id) → the processes it started and has not released. + processes: Mutex>>, + next_process: AtomicU64, + process_shutdowns: Mutex>>, prompts: Mutex>, /// Plugins being stopped: their calls may not open new prompts (taken before `prompts`). prompts_blocked: Mutex>, @@ -223,6 +445,9 @@ impl Shared { data_dir, tasks: Mutex::default(), next_task: AtomicU64::new(1), + processes: Mutex::default(), + next_process: AtomicU64::new(1), + process_shutdowns: Mutex::default(), prompts: Mutex::default(), prompts_blocked: Mutex::default(), next_prompt: AtomicU64::new(1), @@ -295,6 +520,33 @@ impl Shared { self.prompts.lock().retain(|_, entry| entry.client != client); drop(closed); self.contexts.lock().retain(|(scope, ..), _| scope.as_deref() != Some(client)); + let mine: Vec<_> = { + let mut processes = self.processes.lock(); + let keys: Vec<_> = processes + .iter() + .filter(|(_, p)| p.client.as_deref() == Some(client)) + .map(|(key, _)| key.clone()) + .collect(); + keys.iter().filter_map(|key| processes.remove(key)).collect() + }; + if !mine.is_empty() { + // Window destruction runs on the UI thread; tree shutdown may wait. + let worker = std::thread::spawn(move || { + for proc in mine { + proc.kill(); + } + }); + let mut workers = self.process_shutdowns.lock(); + workers.retain(|worker| !worker.is_finished()); + workers.push(worker); + } + } + + pub fn finish_process_shutdowns(&self) { + let workers = std::mem::take(&mut *self.process_shutdowns.lock()); + for worker in workers { + let _ = worker.join(); + } } /// A window labelled `client` is being created (labels are reused after a close). @@ -305,6 +557,7 @@ impl Shared { /// Withdraws a plugin's run-time state (it stopped or failed). Its /// subscriptions go only with `subscriptions`, as they survive restarts. pub fn clear_plugin(&self, plugin: &str, subscriptions: bool) { + self.kill_processes(plugin); let removed: Vec = { let mut status = self.status.lock(); let ids: Vec = status.keys().filter(|(p, _)| p == plugin).map(|(_, id)| id.clone()).collect(); @@ -345,6 +598,19 @@ impl Shared { } } + /// Ends every process `plugin` started: its handles go with its instance + /// (it stopped, was disabled, or was reclaimed while idle). + pub fn kill_processes(&self, plugin: &str) { + let mine: Vec> = { + let mut processes = self.processes.lock(); + let keys: Vec<(String, u64)> = processes.keys().filter(|(p, _)| p == plugin).cloned().collect(); + keys.iter().filter_map(|k| processes.remove(k)).collect() + }; + for proc in mine { + proc.kill(); + } + } + /// The user cancelled a task in the UI. pub fn cancel_task(&self, id: u64) -> bool { let mut tasks = self.tasks.lock(); @@ -684,6 +950,23 @@ impl PluginApi { } } } + /// Where `cmd` runs: the project root it names, else the plugin's data + /// directory (created as needed). + fn spawn_dir(&self, cwd: Option<&Path>) -> PathBuf { + let dir = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id())); + let _ = std::fs::create_dir_all(&dir); + dir + } + + /// The process handle `id`, if this plugin started it and it is still open. + fn process(&self, id: u64) -> Result, HostError> { + self.shared + .processes + .lock() + .get(&(self.id().to_owned(), id)) + .cloned() + .ok_or_else(|| HostError::NotFound(format!("no process {id}: it was closed, or belongs to another plugin"))) + } } /// The changes `selection` commits, diffed repository by repository. @@ -1124,24 +1407,24 @@ impl HostApi for PluginApi { fn run_process( &self, - program: &str, - args: Vec, + cmd: ProcessCommand, cwd: Option<&Path>, stdin: Option, cancel: &CancellationToken, timeout: Duration, ) -> Result { - let binary = self.approved_program(program)?; - let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id())); - let _ = std::fs::create_dir_all(&cwd); - let mut cmd = std::process::Command::new(&binary); - ide_core::process::prepare(&mut cmd); - let mut child = cmd - .args(&args) + let binary = self.approved_program(&cmd.program)?; + let cwd = self.spawn_dir(cwd); + let mut command = StdCommand::new(&binary); + ide_core::process::prepare(&mut command); + append_process_env(&mut command, &cmd.env)?; + ide_core::process::own_group(&mut command); + let mut child = command + .args(&cmd.args) .current_dir(&cwd) - .stdin(if stdin.is_some() { std::process::Stdio::piped() } else { std::process::Stdio::null() }) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) + .stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) .spawn() .map_err(|e| HostError::Failed(format!("{}: {e}", binary.display())))?; if let (Some(input), Some(mut pipe)) = (stdin, child.stdin.take()) { @@ -1150,30 +1433,36 @@ impl HostApi for PluginApi { let _ = pipe.write_all(input.as_bytes()); }); } - let read = |pipe: Option>| { + let read = |pipe: Option>| { std::thread::spawn(move || { - use std::io::Read; let mut out = Vec::new(); if let Some(p) = pipe { - let _ = p.take(MAX_OUTPUT as u64).read_to_end(&mut out); + let mut p = p; + let _ = p.by_ref().take(MAX_OUTPUT as u64).read_to_end(&mut out); + let _ = std::io::copy(&mut p, &mut std::io::sink()); } - String::from_utf8_lossy(&out).into_owned() + let mut text = String::from_utf8_lossy(&out).into_owned(); + let mut end = text.len().min(MAX_OUTPUT); + while !text.is_char_boundary(end) { + end -= 1; + } + text.truncate(end); + text }) }; - let stdout = read(child.stdout.take().map(|p| Box::new(p) as Box)); - let stderr = read(child.stderr.take().map(|p| Box::new(p) as Box)); + let stdout = read(child.stdout.take().map(|p| Box::new(p) as Box)); + let stderr = read(child.stderr.take().map(|p| Box::new(p) as Box)); let deadline = Instant::now() + timeout; let status = loop { - if let Some(status) = child.try_wait().map_err(|e| HostError::Failed(e.to_string()))? { + if let Some(status) = try_wait_process(&mut child).map_err(|e| HostError::Failed(e.to_string()))? { break status; } if cancel.is_cancelled() || Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); + ide_core::process::kill_tree(&mut child); return Err(if cancel.is_cancelled() { HostError::Cancelled } else { - HostError::Failed(format!("{program} timed out after {timeout:?}")) + HostError::Failed(format!("{} timed out after {timeout:?}", cmd.program)) }); } std::thread::sleep(Duration::from_millis(10)); @@ -1204,6 +1493,169 @@ impl HostApi for PluginApi { self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged); } } + fn start_process(&self, cmd: ProcessCommand, cwd: Option<&Path>, client: Option<&str>) -> Result { + let binary = self.approved_program(&cmd.program)?; + let cwd = self.spawn_dir(cwd); + if client.is_some_and(|c| self.shared.closed_clients.lock().contains(c)) { + return Err(HostError::Cancelled); + } + { + let processes = self.shared.processes.lock(); + let running = processes.keys().filter(|(plugin, _)| plugin == self.id()).count(); + if running >= MAX_PROCESSES { + return Err(HostError::Invalid(format!( + "at most {MAX_PROCESSES} processes may run at once: release one first" + ))); + } + } + let mut command = StdCommand::new(&binary); + ide_core::process::prepare(&mut command); + append_process_env(&mut command, &cmd.env)?; + ide_core::process::own_group(&mut command); + let mut child = command + .args(&cmd.args) + .current_dir(&cwd) + // Always piped: `write` may come later, and a plugin that never + // writes closes it (or loses it when it releases the handle). + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| HostError::Failed(format!("{}: {e}", binary.display())))?; + let (stdout, stderr) = (child.stdout.take(), child.stderr.take()); + let proc = Arc::new(Proc { + client: client.map(str::to_owned), + state: Mutex::new(ProcState { + stdin: child.stdin.take(), + child, + stdout: Vec::new(), + stderr: Vec::new(), + status: None, + readers: 0, + }), + }); + if let Some(pipe) = stdout { + proc.read(pipe, Stream::Out); + } + if let Some(pipe) = stderr { + proc.read(pipe, Stream::Err); + } + let closed = self.shared.closed_clients.lock(); + if client.is_some_and(|c| closed.contains(c)) { + drop(closed); + proc.kill(); + return Err(HostError::Cancelled); + } + let id = self.shared.next_process.fetch_add(1, Ordering::Relaxed); + self.shared.processes.lock().insert((self.id().to_owned(), id), proc); + drop(closed); + Ok(id) + } + + fn poll_process(&self, id: u64, max: usize) -> Result { + let proc = self.process(id)?; + proc.reap()?; + Ok(proc.chunk(max)) + } + + fn write_process( + &self, + id: u64, + text: &str, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result<(), HostError> { + let proc = self.process(id)?; + proc.reap()?; + let mut pipe = { + let mut state = proc.state.lock(); + if state.status.is_some() { + return Err(HostError::Failed("the process already ended".into())); + } + state.stdin.take().ok_or_else(|| HostError::Failed("its stdin is closed".into()))? + }; + let input = text.to_owned(); + let writer = proc.clone(); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + use std::io::Write; + let result = pipe.write_all(input.as_bytes()).and_then(|()| pipe.flush()); + let mut state = writer.state.lock(); + if state.status.is_none() { + state.stdin = Some(pipe); + } + let _ = tx.send(result); + }); + let deadline = Instant::now() + timeout; + loop { + if cancel.is_cancelled() || Instant::now() >= deadline { + proc.kill(); + return Err(if cancel.is_cancelled() { + HostError::Cancelled + } else { + HostError::Failed("stdin write timed out".into()) + }); + } + match rx.recv_timeout(Duration::from_millis(10)) { + Ok(result) => return result.map_err(|e| HostError::Failed(format!("stdin: {e}"))), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(_) => return Err(HostError::Failed("stdin writer stopped".into())), + } + } + } + + fn close_process_stdin(&self, id: u64) -> Result<(), HostError> { + let proc = self.process(id)?; + proc.reap()?; + let mut state = proc.state.lock(); + if state.status.is_some() { + return Err(HostError::Failed("the process already ended".into())); + } + state.stdin = None; + Ok(()) + } + + fn release_process(&self, id: u64) { + let proc = self.shared.processes.lock().remove(&(self.id().to_owned(), id)); + if let Some(proc) = proc { + proc.kill(); + } + } + + fn kill_process(&self, id: u64) -> Result<(), HostError> { + self.process(id)?.kill(); + Ok(()) + } + + fn wait_process( + &self, + id: u64, + max: usize, + timeout: Duration, + cancel: &CancellationToken, + ) -> Result { + let proc = self.process(id)?; + let deadline = Instant::now() + timeout; + loop { + proc.reap()?; + let (ended, readers) = { + let state = proc.state.lock(); + (state.status.is_some(), state.readers) + }; + // Its output is only complete once its pipes reached the end too. + if ended && readers == 0 { + break; + } + if cancel.is_cancelled() { + return Err(HostError::Cancelled); + } + if Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + Ok(proc.chunk(max)) + } fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64 { let id = self.shared.next_task.fetch_add(1, Ordering::Relaxed); @@ -1645,6 +2097,182 @@ mod tests { ); } + #[test] + fn process_environment_only_adds_variables() { + let tmp = tempfile::tempdir().unwrap(); + let binary = ide_core::process::find_executable( + "git", + &ide_core::process::user_path().or_else(|| std::env::var_os("PATH")).unwrap(), + ) + .expect("git on PATH"); + let pins: Arc>> = Arc::default(); + pins.write().insert(("acme.lint".into(), "process:git".into()), ProgramPin::approve(&binary).unwrap()); + let api = api(tmp.path(), pins); + let inherited = std::env::vars_os() + .find(|(key, _)| key != "PATH" && key.to_str().is_some_and(|key| !key.is_empty() && !key.contains('='))) + .unwrap() + .0 + .into_string() + .unwrap(); + for key in ["PATH".to_owned(), inherited] { + let cmd = ProcessCommand { + program: "git".into(), + env: vec![(key.clone(), "replacement".into())], + ..Default::default() + }; + let err = api + .run_process(cmd.clone(), None, None, &CancellationToken::new(), Duration::from_secs(5)) + .unwrap_err(); + assert!(matches!(err, HostError::Invalid(ref message) if message.contains(&key)), "{err}"); + let err = api.start_process(cmd, None, None).unwrap_err(); + assert!(matches!(err, HostError::Invalid(ref message) if message.contains(&key)), "{err}"); + } + } + + #[test] + fn process_environment_uses_prepared_values_and_windows_key_rules() { + let mut cmd = StdCommand::new("unused"); + ide_core::process::prepare(&mut cmd); + cmd.env("OPENIDEA_PREPARED_ENV", "login-shell"); + let err = append_process_env(&mut cmd, &[("OPENIDEA_PREPARED_ENV".into(), "override".into())]).unwrap_err(); + assert!(err.to_string().contains("OPENIDEA_PREPARED_ENV")); + for name in ["", "BAD=KEY", "BAD\0KEY"] { + assert!(append_process_env(&mut cmd, &[(name.into(), "value".into())]).is_err()); + } + for value in ["bad\0value".to_owned(), "x".repeat(4097)] { + assert!(append_process_env(&mut cmd, &[("OPENIDEA_NEW_ENV".into(), value)]).is_err()); + } + assert!( + append_process_env( + &mut cmd, + &[("OPENIDEA_NEW_ENV".into(), "a".into()), ("OPENIDEA_NEW_ENV".into(), "b".into())] + ) + .is_err() + ); + assert_eq!(env_name_eq("PATH".as_ref(), "Path".as_ref()), cfg!(windows)); + #[cfg(windows)] + { + assert!(append_process_env(&mut cmd, &[("pAtH".into(), "override".into())]).is_err()); + assert!(env_name_eq("Ä_KEY".as_ref(), "ä_key".as_ref())); + } + cmd.env_remove("OPENIDEA_REMOVED_ENV"); + append_process_env(&mut cmd, &[("OPENIDEA_REMOVED_ENV".into(), "added".into())]).unwrap(); + assert_eq!(cmd.get_envs().find(|(k, _)| *k == "OPENIDEA_REMOVED_ENV").unwrap().1, Some("added".as_ref())); + } + + #[test] + fn process_output_preserves_partial_utf8_and_bounds_replacement_text() { + let mut bytes = vec![0xe4, 0xb8]; + assert_eq!(take_text(&mut bytes, 1, false), ""); + bytes.extend_from_slice(&[0xad, b'x']); + assert_eq!(take_text(&mut bytes, 1, false), "中"); + assert_eq!(take_text(&mut bytes, 0, false), "x"); + let mut bytes = vec![0xff; MAX_PENDING]; + let text = take_text(&mut bytes, MAX_PENDING, true); + assert!(text.len() <= MAX_PENDING); + assert!(!bytes.is_empty()); + assert_eq!(take_text(&mut vec![0xe4, 0xb8], 8, true), "�"); + } + + #[cfg(unix)] + fn shell_api(dir: &Path) -> PluginApi { + let pins: Arc>> = Arc::default(); + pins.write() + .insert(("acme.lint".into(), "process:sh".into()), ProgramPin::approve(Path::new("/bin/sh")).unwrap()); + api(dir, pins) + } + + #[cfg(unix)] + fn shell(script: &str) -> ProcessCommand { + ProcessCommand { program: "sh".into(), args: vec!["-c".into(), script.into()], env: Vec::new() } + } + + #[cfg(unix)] + #[test] + fn process_handles_are_bounded_scoped_and_released() { + let tmp = tempfile::tempdir().unwrap(); + let api = shell_api(tmp.path()); + let mut ids = Vec::new(); + for _ in 0..MAX_PROCESSES { + ids.push(api.start_process(shell("exec cat"), None, Some("main")).unwrap()); + } + assert!(matches!(api.start_process(shell("exec cat"), None, None), Err(HostError::Invalid(_)))); + let proc = api.process(ids[0]).unwrap(); + api.release_process(ids[0]); + assert!(proc.state.lock().child.try_wait().unwrap().is_some()); + assert!(api.process(ids[0]).is_err()); + let other = api.start_process(shell("exec cat"), None, Some("other")).unwrap(); + api.shared.forget_client("main"); + assert!(api.process(ids[1]).is_err()); + assert!(api.process(other).is_ok()); + assert!(matches!(api.start_process(shell("exec cat"), None, Some("main")), Err(HostError::Cancelled))); + api.shared.kill_processes(api.id()); + assert!(api.shared.processes.lock().is_empty()); + } + + #[cfg(unix)] + #[test] + fn process_stdin_timeout_and_stop_do_not_deadlock() { + let tmp = tempfile::tempdir().unwrap(); + let api = Arc::new(shell_api(tmp.path())); + for stop in [false, true] { + let id = api.start_process(shell("exec sleep 60"), None, Some("main")).unwrap(); + let proc = api.process(id).unwrap(); + let writer = api.clone(); + let thread = std::thread::spawn(move || { + writer.write_process(id, &"x".repeat(1 << 20), &CancellationToken::new(), Duration::from_millis(150)) + }); + if stop { + std::thread::sleep(Duration::from_millis(30)); + api.shared.kill_processes(api.id()); + } + assert!(thread.join().unwrap().is_err()); + assert!(proc.state.lock().child.try_wait().unwrap().is_some()); + api.release_process(id); + } + } + + #[cfg(unix)] + #[test] + fn process_parent_exit_closes_descendant_pipes() { + let tmp = tempfile::tempdir().unwrap(); + let api = shell_api(tmp.path()); + let token = CancellationToken::new(); + let before = Instant::now(); + let out = + api.run_process(shell("sleep 60 & echo done"), None, None, &token, Duration::from_millis(300)).unwrap(); + assert_eq!(out.status, Some(0)); + assert_eq!(out.stdout.trim(), "done"); + assert!(before.elapsed() < Duration::from_secs(5)); + let id = api.start_process(shell("sleep 60 & echo done"), None, None).unwrap(); + let out = api.wait_process(id, MAX_PENDING, Duration::from_millis(300), &token).unwrap(); + assert_eq!(out.status, Some(0)); + assert_eq!(out.stdout.trim(), "done"); + assert_eq!(api.process(id).unwrap().state.lock().readers, 0); + api.release_process(id); + } + + #[cfg(unix)] + #[test] + fn process_output_is_drained_and_retention_is_bounded() { + let tmp = tempfile::tempdir().unwrap(); + let api = shell_api(tmp.path()); + let token = CancellationToken::new(); + let cmd = shell("head -c 5242880 /dev/zero; head -c 5242880 /dev/zero >&2"); + let out = api.run_process(cmd.clone(), None, None, &token, Duration::from_secs(5)).unwrap(); + assert_eq!(out.status, Some(0)); + assert_eq!((out.stdout.len(), out.stderr.len()), (MAX_OUTPUT, MAX_OUTPUT)); + let id = api.start_process(cmd, None, None).unwrap(); + let out = api.wait_process(id, MAX_PENDING, Duration::from_secs(5), &token).unwrap(); + assert_eq!(out.status, Some(0)); + assert_eq!((out.stdout.len(), out.stderr.len()), (MAX_PENDING, MAX_PENDING)); + let state = api.process(id).unwrap(); + let state = state.state.lock(); + assert!(state.stdout.is_empty() && state.stderr.is_empty()); + drop(state); + api.release_process(id); + } + #[test] fn programs_run_only_as_approved() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 8396d6d86..5d9b2dd60 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -1769,6 +1769,7 @@ impl PluginHost { for a in actors { a.stop(true); } + self.shared.finish_process_shutdowns(); } /// Periodic upkeep (every few seconds): reports crashes nobody waited @@ -2165,6 +2166,8 @@ impl PluginHost { *runtime = None; } actor.stop(false); + // Its handles go with its instance; an idle reclaim is one. + self.shared.kill_processes(plugin.id()); let mut meta = plugin.meta.lock(); if meta.status == PluginStatus::Active { meta.status = PluginStatus::Inactive; diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index d2b7393db..295effc30 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -551,6 +551,103 @@ fn fs_write_reaches_only_the_granted_paths() { assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}"); } +/// A program kept running behind a handle: the plugin writes its stdin, closes +/// it, and waits for the exit status and what the program printed. A one-off run +/// passes the environment it names. +#[test] +fn a_plugin_keeps_a_process_running_behind_a_handle() { + let f = fixture(Limits::default()); + f.install(&misbehave_pkg()); + f.host.set_capability("misbehave", "process:git", false).unwrap(); + assert!(f.run("misbehave", "envrun", Some("legacy")).unwrap_err().contains("process:git")); + for command in ["envrun", "envstart", "child"] { + let err = f.run("misbehave", command, None).unwrap_err(); + assert!(err.contains("process:git"), "{command}: {err}"); + } + // The fixture declares `process:git` as optional: it needs a program that + // reads stdin and reports its environment. Machines running these tests have + // git; without one, there is nothing to run and nothing to check. + if f.host.set_capability("misbehave", "process:git", true).is_err() { + return; + } + + let out = f.run("misbehave", "child", None).expect("the handle works").expect("a result"); + let (hash, status) = out.split_once(' ').unwrap_or_else(|| panic!("{out}")); + assert_eq!(hash.len(), 40, "a sha1 of what was written: {out}"); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit()), "{out}"); + assert_eq!(status, "Some(0)", "it ended by itself: {out}"); + + assert!(f.run("misbehave", "envrun", Some("legacy")).unwrap().unwrap().starts_with("git version")); + let ident = f.run("misbehave", "envrun", None).expect("a run with extra environment").expect("a result"); + assert_eq!(ident.trim(), "added by the plugin"); + assert_eq!(f.run("misbehave", "envstart", None).unwrap().unwrap().trim(), "added by the plugin"); + for command in ["envdeny-run", "envdeny-start"] { + let inherited = std::env::vars().find(|(key, _)| key != "PATH" && !key.contains('=')).unwrap().0; + for key in ["PATH", inherited.as_str(), "", "BAD=KEY", "BAD\0KEY"] { + let err = f.run("misbehave", command, Some(key)).unwrap_err(); + assert!(err.contains("environment variable"), "{command} {key:?}: {err}"); + } + } + // Dropping a resource returns the slot; the limit is simultaneous handles. + for _ in 0..10 { + f.run("misbehave", "childkeep", None).unwrap(); + assert_eq!(f.run("misbehave", "childpoll", None).unwrap().as_deref(), Some("None")); + f.run("misbehave", "childdrop", None).unwrap(); + } + for _ in 0..8 { + f.run("misbehave", "childkeep", None).unwrap(); + } + assert!(f.run("misbehave", "childkeep", None).unwrap_err().contains("at most 8")); + f.run("misbehave", "childdrop", None).unwrap(); + f.run("misbehave", "childkeep", None).unwrap(); + f.host.forget_client("main"); + assert!(f.run("misbehave", "childpoll", None).unwrap_err().contains("no process")); + assert!(f.run("misbehave", "childkeep", None).is_err(), "late starts are cancelled"); + f.host.reopen_client("main"); + f.run("misbehave", "childdrop", None).unwrap(); + + let info = f.host.info_of("misbehave").unwrap(); + assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}"); +} + +#[cfg(unix)] +#[test] +fn plugin_processes_end_with_their_owner() { + for mode in ["disable", "unload", "shutdown", "reclaim", "trap", "window"] { + let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() }); + f.install(&misbehave_pkg()); + f.host.set_capability("misbehave", "process:git", true).unwrap(); + let marker = format!("--path={}/process-{mode}", f.project.display()); + let alive = || { + let ps = std::process::Command::new("ps").args(["-axo", "command="]).output().unwrap(); + assert!(ps.status.success()); + String::from_utf8_lossy(&ps.stdout).lines().any(|line| line.contains(&marker)) + }; + f.run("misbehave", "childkeep", marker.strip_prefix("--path=")).unwrap(); + assert!(alive(), "{mode}: the process started"); + match mode { + "disable" => f.host.set_enabled("misbehave", false).unwrap(), + "unload" => f.host.uninstall("misbehave").unwrap(), + "shutdown" => f.host.shutdown(), + "reclaim" => { + std::thread::sleep(Duration::from_millis(75)); + f.host.tick(); + assert_eq!(f.host.instances(), 0); + } + "trap" => { + assert!(f.run("misbehave", "panic", None).is_err()); + } + "window" => f.host.forget_client("main"), + _ => unreachable!(), + } + let deadline = Instant::now() + Duration::from_secs(5); + while alive() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!(!alive(), "{mode}: the process must end with its owner"); + } +} + /// A missing capability is an error the plugin can handle, not a trap. #[test] fn ungranted_capabilities_are_denied_not_trapped() { @@ -827,7 +924,7 @@ fn idle_instances_are_reclaimed_and_schedules_reactivate() { assert_eq!(f.host.instances(), 0); let info = f.host.info_of("misbehave").unwrap(); assert_eq!((info.status, info.stats.reclaims), (PluginStatus::Inactive, 1)); - assert_eq!(f.command_titles("misbehave").len(), 14, "contributions stay"); + assert_eq!(f.command_titles("misbehave").len(), 23, "contributions stay"); assert_eq!(f.run("misbehave", "echo", Some("y")).unwrap().as_deref(), Some("y")); assert_eq!(f.host.info_of("misbehave").unwrap().stats.activations, 2); diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 38496f0bf..8206c78d8 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -8,8 +8,9 @@ use crate::types::{ PluginHover, PluginInlayHint, ProviderDocument, SemanticToken, }; use crate::types::{ - EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt, - RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, WorkbenchEvent, + EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessChunk, + ProcessCommand, ProcessOutput, Prompt, RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, + WorkbenchEvent, }; use crate::types::{ FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, TableSort, View, ViewBody, ViewButton, @@ -167,11 +168,11 @@ pub trait HostApi: Send + Sync { /// Performs `request` (its host is granted), filling in secret placeholders. fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result; fn secret_is_set(&self, name: &str) -> bool; - /// Runs the approved file of `process:`. + /// Runs the approved file of `process:` with `cmd.env` added to + /// the inherited environment, and waits. fn run_process( &self, - program: &str, - args: Vec, + cmd: ProcessCommand, cwd: Option<&Path>, stdin: Option, cancel: &CancellationToken, @@ -182,6 +183,35 @@ pub trait HostApi: Send + Sync { /// Delivers `kind` (`file-opened`, `file-saved`) only for paths matching `globs`. fn subscribe_paths(&self, kind: &str, globs: Vec) -> Result<(), HostError>; fn unsubscribe(&self, kind: &str); + /// Starts `cmd` and returns the handle to it without waiting. + fn start_process(&self, cmd: ProcessCommand, cwd: Option<&Path>, client: Option<&str>) -> Result; + /// What process `id` printed since the last call, each stream at most + /// `max` bytes (the rest waits for the next call), and its status once it + /// ended. + fn poll_process(&self, id: u64, max: usize) -> Result; + /// Writes `text` to the stdin of process `id`. + fn write_process( + &self, + id: u64, + text: &str, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result<(), HostError>; + /// Closes the stdin of process `id`. + fn close_process_stdin(&self, id: u64) -> Result<(), HostError>; + /// Ends process `id`; not an error if it already ended. + fn kill_process(&self, id: u64) -> Result<(), HostError>; + /// Forgets the handle and ends its process if it still runs. + fn release_process(&self, id: u64); + /// Waits up to `timeout` for process `id` to end (`ZERO` only checks) and + /// returns what it printed meanwhile with its status once it ended. + fn wait_process( + &self, + id: u64, + max: usize, + timeout: Duration, + cancel: &CancellationToken, + ) -> Result; /// Starts a progress indicator; cancelling it from the UI cancels `cancel`. fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64; fn task_report(&self, id: u64, done: u32, total: Option, message: Option); diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index d12365203..5e518edbb 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -352,6 +352,24 @@ pub struct ProcessOutput { pub stderr: String, } +/// What to run: the approved file of `process:`, its arguments and +/// the environment to add to the inherited one (WIT `process.command`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ProcessCommand { + pub program: String, + pub args: Vec, + pub env: Vec<(String, String)>, +} + +/// What a kept-running program printed since the last read, and its exit +/// status once it ended (WIT `process.chunk`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ProcessChunk { + pub stdout: String, + pub stderr: String, + pub status: Option, +} + /// Where `net:` requests may go: the URL's host when it is `https`, or /// `http` to the loopback interface. pub fn net_host(url: &str) -> Result { diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index 607558473..d1c69a1fa 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -5,7 +5,8 @@ use crate::runtime::{MAX_TITLE, StoreData, clip}; use crate::sandbox; use crate::types::{ - EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, PickItem, Prompt, StatusItem, net_host, + EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, PickItem, ProcessChunk, ProcessCommand, + Prompt, StatusItem, net_host, }; use ide_core::ipc_path::to_ipc; use ide_extension::Capability; @@ -23,6 +24,7 @@ pub(crate) mod bindings { "openidea:plugin/editor.text-editor": super::EditorRes, "openidea:plugin/invocation.invocation": super::InvocationRes, "openidea:plugin/tasks.task": super::TaskRes, + "openidea:plugin/process.child": super::ChildRes, }, }); } @@ -124,6 +126,19 @@ pub struct ProjectRes { root: PathBuf, } +/// A program a plugin started behind a handle (`process.start`); dropping the +/// handle ends it if it still runs. +pub struct ChildRes { + id: u64, + host: std::sync::Arc, +} + +impl Drop for ChildRes { + fn drop(&mut self) { + self.host.release_process(self.id); + } +} + /// The invoking window's focused editor. pub struct EditorRes { ctx: EditorContext, @@ -143,6 +158,15 @@ const MAX_FIND: u32 = 10_000; const MAX_STATUS_TEXT: usize = 128; const MAX_PICK_ITEMS: usize = 5000; const MAX_PICK_KEY: usize = 256; +const MAX_ARGS: usize = 256; +const MAX_ARG_BYTES: usize = 4096; +const MAX_ENV: usize = 64; +const MAX_ENV_NAME: usize = 256; +const MAX_ENV_VALUE: usize = 4096; +/// Most output one `poll` / `wait` hands out, per stream. +const MAX_CHUNK: usize = 1 << 20; +/// Most text one `write` puts into a process's stdin. +const MAX_STDIN_BYTES: usize = 1 << 20; type Fallible = wasmtime::Result>; @@ -933,6 +957,45 @@ impl secrets::Host for StoreData { } } +impl StoreData { + /// The `command` of `process.run-command` / `process.start`, checked: the + /// program is the one the user approved, the sizes stay in the limits. + fn command(&self, cmd: process::Command) -> Result { + self.require(Capability::Process(cmd.program.clone()))?; + if cmd.args.len() > MAX_ARGS || cmd.args.iter().any(|a| a.len() > MAX_ARG_BYTES) { + return Err(wit::Error::Invalid(format!("at most {MAX_ARGS} arguments of at most {MAX_ARG_BYTES} bytes"))); + } + if cmd.env.len() > MAX_ENV { + return Err(wit::Error::Invalid(format!("at most {MAX_ENV} environment variables"))); + } + for (name, value) in &cmd.env { + if name.is_empty() || name.len() > MAX_ENV_NAME || name.contains(['=', '\0']) { + return Err(wit::Error::Invalid(format!("{name:?} is not an environment variable name"))); + } + if value.len() > MAX_ENV_VALUE || value.contains('\0') { + return Err(wit::Error::Invalid(format!("the value of {name:?} is too long or holds a NUL"))); + } + } + Ok(ProcessCommand { program: cmd.program, args: cmd.args, env: cmd.env }) + } + + /// The project a call runs in, when it named one. + fn cwd_of(&self, cwd: Option>) -> wasmtime::Result> { + Ok(match &cwd { + Some(p) => Some(self.table.get(p)?.root.clone()), + None => None, + }) + } +} + +fn to_chunk(chunk: ProcessChunk) -> process::Chunk { + process::Chunk { stdout: chunk.stdout, stderr: chunk.stderr, status: chunk.status } +} + +fn to_output(output: crate::types::ProcessOutput) -> process::Output { + process::Output { status: output.status, stdout: output.stdout, stderr: output.stderr } +} + impl process::Host for StoreData { fn run( &mut self, @@ -941,20 +1004,81 @@ impl process::Host for StoreData { cwd: Option>, stdin: Option, ) -> Fallible { - if let Err(e) = self.require(Capability::Process(program.clone())) { - return Ok(Err(e)); - } - let cwd = match &cwd { - Some(p) => Some(self.table.get(p)?.root.clone()), - None => None, + let cmd = match self.command(process::Command { program, args, env: Vec::new() }) { + Ok(cmd) => cmd, + Err(e) => return Ok(Err(e)), }; + let cwd = self.cwd_of(cwd)?; let token = self.cancel.token(); let timeout = self.remaining(); - Ok(self - .host - .run_process(&program, args, cwd.as_deref(), stdin, &token, timeout) - .map(|o| process::Output { status: o.status, stdout: o.stdout, stderr: o.stderr }) - .map_err(to_wit)) + Ok(self.host.run_process(cmd, cwd.as_deref(), stdin, &token, timeout).map(to_output).map_err(to_wit)) + } + + fn run_command( + &mut self, + cmd: process::Command, + cwd: Option>, + stdin: Option, + ) -> Fallible { + let cmd = match self.command(cmd) { + Ok(cmd) => cmd, + Err(e) => return Ok(Err(e)), + }; + let cwd = self.cwd_of(cwd)?; + let token = self.cancel.token(); + let timeout = self.remaining(); + Ok(self.host.run_process(cmd, cwd.as_deref(), stdin, &token, timeout).map(to_output).map_err(to_wit)) + } + + fn start(&mut self, cmd: process::Command, cwd: Option>) -> Fallible> { + let cmd = match self.command(cmd) { + Ok(cmd) => cmd, + Err(e) => return Ok(Err(e)), + }; + let cwd = self.cwd_of(cwd)?; + let id = match self.host.start_process(cmd, cwd.as_deref(), self.invocation.client.as_deref()) { + Ok(id) => id, + Err(e) => return Ok(Err(to_wit(e))), + }; + self.push(ChildRes { id, host: self.host.clone() }).map(Ok) + } +} + +impl process::HostChild for StoreData { + fn poll(&mut self, this: Resource, max_bytes: u32) -> Fallible { + let id = self.table.get(&this)?.id; + let max = (max_bytes as usize).min(MAX_CHUNK); + Ok(self.host.poll_process(id, max).map(to_chunk).map_err(to_wit)) + } + + fn wait(&mut self, this: Resource, timeout_ms: u32) -> Fallible { + let id = self.table.get(&this)?.id; + let timeout = Duration::from_millis(u64::from(timeout_ms)).min(self.remaining()); + let token = self.cancel.token(); + Ok(self.host.wait_process(id, MAX_CHUNK, timeout, &token).map(to_chunk).map_err(to_wit)) + } + + fn write(&mut self, this: Resource, text: String) -> Fallible<()> { + if text.len() > MAX_STDIN_BYTES { + return Ok(Err(wit::Error::Invalid(format!("at most {MAX_STDIN_BYTES} bytes at a time")))); + } + let id = self.table.get(&this)?.id; + Ok(self.host.write_process(id, &text, &self.cancel.token(), self.remaining()).map_err(to_wit)) + } + + fn close_stdin(&mut self, this: Resource) -> Fallible<()> { + let id = self.table.get(&this)?.id; + Ok(self.host.close_process_stdin(id).map_err(to_wit)) + } + + fn kill(&mut self, this: Resource) -> Fallible<()> { + let id = self.table.get(&this)?.id; + Ok(self.host.kill_process(id).map_err(to_wit)) + } + + fn drop(&mut self, rep: Resource) -> wasmtime::Result<()> { + self.table.delete(rep)?; + Ok(()) } } diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm index 49e137553..30247799c 100644 Binary files a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources index aabcea499..003aeb1b8 100644 --- a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -1 +1 @@ -4a49382f47d12b2921869044e0da4f25ece0ec6e7dea048e25f37f55e1345d75 +13db3ba9045429fef5973e83075a51a315076408a03ec3c1b837c0229365fcbe diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm index 441cca97d..b09c15c53 100644 Binary files a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources index 3bfe310f1..99bb14143 100644 --- a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources @@ -1 +1 @@ -892b7ca523ef251eb626aece060acd54258afaf8204fe02713b9e57b5ca9a67e +6c2a59bf732acebd74ad4d05824be3aafbd501964be01205ca1e0d5a6135d113 diff --git a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm index 22d911225..84917b1f0 100644 Binary files a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources index 262bf91db..2b148fec1 100644 --- a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources @@ -1 +1 @@ -93f7501bb780898346fbf0614af254949dd17dd7c705581befc5e485422e1866 +183bf50a6ee479128440ef4bd86939f24b036b699a24842a6efd1d3f43c6cd10 diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm index 98c7aed3c..f5fc8e5d9 100644 Binary files a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources index a89462c27..a49e9bfbe 100644 --- a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources @@ -1 +1 @@ -6981c76c96e2e7ab086be65847010bb22cb012c259cdec075b2285253c73f637 +97f7f6fe965e95510d5556feb40329c312e5430e631479be6c9646046a888478 diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index 9f94b36de..aa239bbee 100644 Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index 49b2b081f..d71a97246 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -07c6f0ce91539ad93b8bbd4535f4288cce1334f6c44e4820268663688623e104 +f73de3fdfe5b343f26ff4dd1ae150c975273ce398d518f6f2100b0baa8074ffc diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm index 901f8f4f9..37642818a 100644 Binary files a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index 42b5b3577..be93a9417 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -00293224c09af5a08ea8862e4fd6263ea36f500750752b428f8e6ff61747a524 +a985162e44066c6bf6c93d01af1b01d40d305c6be43072d4f33d4f1f19a4dee0 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm index 5c3d36db5..f70d375de 100644 Binary files a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index a4d584396..03c382e9a 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -53f907d6f0415cf7fd09d09bf13aef1954339462eeb44e322a53ee44af9655c2 +5063a997e11258386e88ba129d42262f5050bfba41c8ea069152b2dfba007fa7 diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm index 3ff02f704..a55184ae9 100644 Binary files a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources index 3290bbb05..951d0bc92 100644 --- a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources @@ -1 +1 @@ -ad8fb651c09a49be8254bfe5fbaf7463f80e23ec712038e78141e3e32abb93eb +9a3d097559f6685e82024bd84cb0396875762b7136636fdc9bd11cb41875373a diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 07cb72bd3..640b15a49 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index e265a35ae..d2a7ba675 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -7fde1c70e9e17693144c7229e53dac200f18c281e0bc83fffc21f3d18671d1be +6ca0c03e31e0cd67192bb86c93b1feb294fec79cf432ceb57b9cdc3e42eb6b02 diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm index d6136a572..ea3a85795 100644 Binary files a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 43a3ab2fd..fb352bce8 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -47623924eee80c6d1aa9dcc22825ea70b92e1b1a41ee5741f20d4d20cdf49eb3 +0ec5c31f6b9b221b0b7be58f73d5c94703e8c14ada610d8ad12f84f63bf5fe97 diff --git a/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml b/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml index 603b77c3d..66d85d8ce 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml +++ b/crates/ide-plugin/tests/fixtures/misbehave/plugin.toml @@ -8,6 +8,7 @@ activation = ["on-command:*"] [capabilities] required = ["project.read"] +optional = ["process:git"] [[contributes.commands]] id = "echo" @@ -64,3 +65,39 @@ title = "subscribe" [[contributes.commands]] id = "diagnose" title = "diagnose" + +[[contributes.commands]] +id = "child" +title = "child" + +[[contributes.commands]] +id = "envrun" +title = "envrun" + +[[contributes.commands]] +id = "envstart" +title = "envstart" + +[[contributes.commands]] +id = "envdeny-run" +title = "envdeny-run" + +[[contributes.commands]] +id = "envdeny-start" +title = "envdeny-start" + +[[contributes.commands]] +id = "childkeep" +title = "childkeep" + +[[contributes.commands]] +id = "childpoll" +title = "childpoll" + +[[contributes.commands]] +id = "childdrop" +title = "childdrop" + +[[contributes.commands]] +id = "childtrap" +title = "childtrap" diff --git a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs index 843d83d4b..e274a24f6 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs @@ -20,10 +20,14 @@ use exports::openidea::plugin::providers::{ }; use exports::openidea::plugin::syntax_provider::{self, Fold, Symbol, Token}; use exports::openidea::plugin::{commands, event_handler, lifecycle}; -use openidea::plugin::{invocation::Invocation, types}; +use openidea::plugin::{invocation::Invocation, process, types}; struct Misbehave; +thread_local! { + static CHILDREN: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + fn failed(message: &str) -> types::Error { types::Error::Failed(message.into()) } @@ -92,6 +96,73 @@ impl commands::Guest for Misbehave { let project = ctx.project().ok_or_else(|| failed("no project is open"))?; project.write_file(&arg.unwrap_or_default(), "written inside the project\n").map(|()| None) } + + // API 1.3 `process.start`: a program kept running behind a handle. + "child" => { + let cmd = process::Command { + program: "git".into(), + args: vec!["hash-object".into(), "--stdin".into()], + env: Vec::new(), + }; + let child = process::start(&cmd, None)?; + child.write("hello\n")?; + child.close_stdin()?; + let chunk = child.wait(30_000)?; + child.kill()?; + Ok(Some(format!("{} {:?}", chunk.stdout.trim(), chunk.status))) + } + "childkeep" | "childtrap" => { + let cmd = process::Command { + program: "git".into(), + args: vec![ + "hash-object".into(), + "--stdin".into(), + format!("--path={}", arg.unwrap_or_else(|| "openidea-process-2027".into())), + ], + env: Vec::new(), + }; + let child = process::start(&cmd, None)?; + CHILDREN.with(|children| children.borrow_mut().push(child)); + if id == "childtrap" { + panic!("trap with a live child"); + } + Ok(Some("running".into())) + } + "childpoll" => CHILDREN.with(|children| { + let children = children.borrow(); + let child = children.last().ok_or_else(|| failed("no child"))?; + Ok(Some(format!("{:?}", child.poll(100)?.status))) + }), + "childdrop" => { + CHILDREN.with(|children| children.borrow_mut().clear()); + Ok(None) + } + "envrun" | "envstart" | "envdeny-run" | "envdeny-start" => { + if arg.as_deref() == Some("legacy") { + return Ok(Some(process::run("git", &["--version".into()], None, None)?.stdout)); + } + let env = if id.starts_with("envdeny") { + vec![(arg.unwrap_or_else(|| "PATH".into()), "replacement".into())] + } else { + vec![("OPENIDEA_PROCESS_TEST_VALUE".into(), "added by the plugin".into())] + }; + let cmd = process::Command { + program: "git".into(), + args: vec![ + "--config-env=openidea.test=OPENIDEA_PROCESS_TEST_VALUE".into(), + "config".into(), + "--get".into(), + "openidea.test".into(), + ], + env, + }; + if id.ends_with("start") { + let child = process::start(&cmd, None)?; + Ok(Some(child.wait(30_000)?.stdout)) + } else { + Ok(Some(process::run_command(&cmd, None, None)?.stdout)) + } + } // `editor.write` is not granted: an error, not a trap. "insert" => { let editor = ctx.editor().ok_or_else(|| failed("no editor"))?; diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm index 40456de8d..38d968683 100644 Binary files a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources index 168763eef..237f52d5c 100644 --- a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources @@ -1 +1 @@ -174df83a18c8cc350df3a1fd7869fd832fd45a3f535d2795ad82814f8136454c +848356d2e0fbaef341436ea70a4698915cce705d3ccea4dd4e50900b12c97d33 diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm index 940be52a9..27c78ab8b 100644 Binary files a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index 155ac2426..bfe0396ca 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -94c6ed6a516973e8ab90a0c550ea700f795eb61f1941c514c0d033db09321a87 +07001ff22cd33d5ae6f96b584707e66d31f2f93423cf72a993e6f11c9f0ec319 diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm index d42b64cee..9c2511c36 100644 Binary files a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources index a8db144e6..8ffef6179 100644 --- a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources @@ -1 +1 @@ -34a1df80de1db3a04363f81b8f3501cb59dc0aee9a0754912b84a34c76ea77d0 +ae9cb4a106aa8fd46662dba07acd0daba2927b7fe53e544cd95348954505bc36 diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm index fe0740917..fc5b069fe 100644 Binary files a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources index 240f79053..8830ecac1 100644 --- a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources @@ -1 +1 @@ -c6698547bcda49a08bacd6d0876af74ae901d4c32e3c32b014a7679fd96f32c0 +8a79f2899d88773d7c33175136960727b5ad0aefc5ce5eb363fd20dc4990c60e diff --git a/plugins/README.md b/plugins/README.md index c351f66f9..bb1854036 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -442,6 +442,8 @@ then run `openidea-plugin check`. | `host::publish_diagnostics(path, items)` | `diagnostics.publish` | Replaces this plugin's problems for the file (editor squiggles and the Problems view); an empty list clears them. | | `host::fetch(request)` → `{status, headers, body}` | `net.fetch` | See _Network_. | | `Project::run_process(program, args, stdin)` → `{code, stdout, stderr}` | `process.run` | Only the pinned program of a `process:` grant; runs in the project root of a trusted project; 4 MiB of output; killed when the call is cancelled or times out. | +| `host::run_command(cmd, project, stdin)` → `{code, stdout, stderr}` | `process.run-command` | API 1.3: same limits as `run_process`, with append-only environment variables; needs the pinned `process:` grant. | +| `host::start(cmd, project)` → `Child` | `process.start` | API 1.3: `poll(max_bytes)`, `write(text)`, `close_stdin()`, `wait(timeout_ms)`, `kill()`; at most 8 handles; dropping one kills its process. | | `host::refresh_view(view)` | `views.refresh` | The view (or custom editors) render again; call it when the content changed. | | `host::reveal_view(view)` | `views.reveal` | Brings one of your declared `[[contributes.views]]` to the front in the calling window. | | `host::post_view_message(view, text)` | `views.post-message` | To the script of the view's `html` body. | @@ -449,6 +451,16 @@ then run `openidea-plugin check`. | `host::open_location(path, line, column)` | `views.open-location` | Opens a file inside the project in an editor of the invoking window, the caret at `line`/`column` (0-based, UTF-16): a view's links to code. Needs `project.read`. | | `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. | +Both process APIs only add environment variables. A key already in the prepared child +environment (including the login-shell `PATH`) fails with an error naming the key; +variables are never replaced or removed. Names compare case-insensitively on Windows. +At most 64 entries are allowed: nonempty names up to 256 bytes without `=` or NUL, +and values up to 4 KiB without NUL. The approved file still decides what runs. + +Unpolled child output keeps the last 1 MiB per stream; each read is capped at 1 MiB +and preserves complete characters. Disabling, unloading, reclaiming or stopping a +plugin ends its children, as does closing the window that started them. + `run-command` receives an optional string argument: `executeAction("plugin..", "arg")` from another feature, or nothing when run from Search Everywhere / a shortcut. `ctx.selected_paths()` lists the files and folders a Project view or editor tab menu ran the command on (the argument is the first of them); it is empty otherwise. No capability is needed. diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index 23c245083..45092d655 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index d8d365a4b..3f2de9839 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -5325c5c915e66d12cc47afc55b29e18e531a3f533e69c51933413f4f77a74438 +7872480dee517db617120a9327d3bbbcc007d31bbf9e6c999ce202f38f0ed478 diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index 67ce3f892..5d8d7d446 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index 644823b61..2e7d19917 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -d23b9b067928d6e7648a4989b09e201cade971a67ab769b4d583a274f4e071ac +31f1d88c7e88553eed145fd170485c10e19967eccbf2021ec71fca862d05e952 diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 7fb1195c2..f631fc1d8 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index e291976cd..69914ed1b 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -a49ab90f826a214d7de7058164f1fd886d9b762482c94953ee07495833742f92 +6a2db880c49b15d7ce5ba7fda69ac8d1cac04f0412d42b6922550594ef94a7c5 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index 25469c2f4..bfe8a222e 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index b24ebe75c..a5f6a09d1 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -29f7f3bd8b35edf079fdac3c4cc67c5b5ffd57c999064a9ee55793f4f22b6c6b +9299f10c517ee5582312f0f14871e21d36050ee8d621df31d7e6a4126c4ea37e diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index d10ee67e3..bc64f8f58 100644 Binary files a/plugins/official/graphviz/dist/plugin.wasm and b/plugins/official/graphviz/dist/plugin.wasm differ diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index 872be9822..19c15d609 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -a008cd053e851f86be07576b21c0a0a763cb8e239e7bba03127de0d7c5ab3a4b +1f86f15f5e864ec9a91cb31379047eafd4ae8c07ffacb5e1c85955ee07873342 diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index 6e8cf42bf..7de6bde14 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index eda0ec3d2..9c0e26954 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -a598e79d3e15b7ce4c5fef6b0af56d7d17e572bc1736806bbfa93349126dfb33 +540e60e34b752eb201ef9cacea44066b4f59a4e36c3d1a468bf208223569a628 diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index b6f0a21d2..840128652 100644 Binary files a/plugins/official/license-header/dist/plugin.wasm and b/plugins/official/license-header/dist/plugin.wasm differ diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index c13c22baa..d2e5393cc 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -481910e4fe978ad5060cc70dd9d66dda029f4f73a0e8cd172b78df85dfa6dcd1 +106f4a0b300fdb98fd0bd8f8bc1b3ce2c692a8a7e0107353905d7edbb7c7ce4d diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index e3b53f44a..a6aa869df 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 3f3755266..c26644797 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -a667fe84ceda6c32f4e74e86b39c39f9c0c7654e07ff911a294dae44a3e15c23 +eba583c669da994dba24a8a16546c9d2cb50fb46db53207207809b20882680e8 diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index 421c25f6b..566e1ebb8 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index bb6160660..09ac175c4 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -a59169bbd1e95017f28f92c9b8796059f0804c9361f1ec39d9ff2ec3fc7ca3ab +d1385eb9aa4d78bbcbdf98565fdf3b550050388af9248814f5c994b54d605708 diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index 115761e01..e6210f2ad 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index fbc066fed..44490bdd2 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -fc5e1ed12c0238b7d9bc0621e41beb89bd62191fe0f30b8c66554207cde9cdf7 +8f4c3af7f1f0f6e2001ce161d89e2b83ef700480d6fd5517f46e90437f74639e diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 6247cdcc1..e67da1f75 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index 936eca41a..5771622ca 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -343c6d46b9bf07f8631f16dbc737301a02a6b229b17eb0a455207509823680e1 +2504128882832c282d627946668f10603fe4b165dca8f12a49131934d1a5c09d diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index 45f493694..ca708f2e2 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -449,6 +449,48 @@ mod imp { .map_err(error) } + pub type ChildRes = wit::process::Child; + + pub fn run_command( + cmd: &crate::host::Command, + cwd: Option<&ProjectRes>, + stdin: Option<&str>, + ) -> Result { + let c = wit::process::Command { program: cmd.program.clone(), args: cmd.args.clone(), env: cmd.env.clone() }; + wit::process::run_command(&c, cwd, stdin) + .map(|o| ProcessOutput { status: o.status, stdout: o.stdout, stderr: o.stderr }) + .map_err(error) + } + + pub fn start(cmd: &crate::host::Command, cwd: Option<&ProjectRes>) -> Result { + let c = wit::process::Command { program: cmd.program.clone(), args: cmd.args.clone(), env: cmd.env.clone() }; + wit::process::start(&c, cwd).map_err(error) + } + + pub fn child_poll(child: &ChildRes, max: u32) -> Result { + child.poll(max).map(chunk_in).map_err(error) + } + + pub fn child_write(child: &ChildRes, text: &str) -> Result<(), Error> { + child.write(text).map_err(error) + } + + pub fn child_close_stdin(child: &ChildRes) -> Result<(), Error> { + child.close_stdin().map_err(error) + } + + pub fn child_kill(child: &ChildRes) -> Result<(), Error> { + child.kill().map_err(error) + } + + pub fn child_wait(child: &ChildRes, timeout_ms: u32) -> Result { + child.wait(timeout_ms).map(chunk_in).map_err(error) + } + + fn chunk_in(c: wit::process::Chunk) -> crate::host::Chunk { + crate::host::Chunk { stdout: c.stdout, stderr: c.stderr, status: c.status } + } + pub fn range_from(r: wit::types::Range) -> Range { range_in(r) } @@ -664,6 +706,104 @@ pub fn run_process(program: &str, args: &[String], stdin: Option<&str>) -> Resul imp::run_process(program, args, None, stdin) } +/// A program with its arguments and the environment added to the inherited one. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Command { + pub program: String, + pub args: Vec, + /// Variables added to the inherited environment: at most 64, names at most + /// 256 bytes, values at most 4 KiB, no NUL or '=' in names, no NUL in values. + /// Existing names in the prepared child environment (including login-shell + /// PATH) fail with an error naming the key. Windows names compare without + /// case. Variables are never replaced or removed. + pub env: Vec<(String, String)>, +} + +impl Command { + /// Selects the program approved by a `process:` grant. + pub fn new(program: &str) -> Self { + Self { program: program.to_owned(), args: Vec::new(), env: Vec::new() } + } + + /// Appends one argument. + pub fn arg(mut self, arg: &str) -> Self { + self.args.push(arg.to_owned()); + self + } + + /// Appends arguments in order. + pub fn args(mut self, args: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + self.args.extend(args.into_iter().map(|a| a.as_ref().to_owned())); + self + } + + /// Adds a variable; an inherited or duplicate name is an error at spawn. + pub fn env(mut self, name: &str, value: &str) -> Self { + self.env.push((name.to_owned(), value.to_owned())); + self + } +} + +/// What a kept-running program printed since the last read, and its exit status +/// once it ended. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Chunk { + pub stdout: String, + pub stderr: String, + pub status: Option, +} + +/// A program kept running behind a handle ([`start`]). Dropping it ends the +/// program, as the host does when the plugin instance goes away. +pub struct Child { + inner: imp::ChildRes, +} + +impl Child { + /// What it printed since the last read, up to 1 MiB per stream, or one + /// whole character when `max_bytes` is smaller; + /// `status` is `Some` once it ended. + pub fn poll(&self, max_bytes: u32) -> Result { + imp::child_poll(&self.inner, max_bytes) + } + + /// Writes `text` to its stdin. + pub fn write(&self, text: &str) -> Result<(), Error> { + imp::child_write(&self.inner, text) + } + + /// Closes its stdin, so a program reading it sees the end. + pub fn close_stdin(&self) -> Result<(), Error> { + imp::child_close_stdin(&self.inner) + } + + /// Ends it; `status` becomes known right away (-1 when a signal ended it). + pub fn kill(&self) -> Result<(), Error> { + imp::child_kill(&self.inner) + } + + /// Waits up to `timeout_ms` for it to end (0 only checks), then reads it. + pub fn wait(&self, timeout_ms: u32) -> Result { + imp::child_wait(&self.inner, timeout_ms) + } +} + +/// Runs a [`Command`] to its end (API 1.3). Its environment only adds new +/// variables; an existing key fails with an error naming it. +pub fn run_command(cmd: &Command, project: Option<&Project>, stdin: Option<&str>) -> Result { + imp::run_command(cmd, project.map(|p| &p.inner), stdin) +} + +/// Starts a [`Command`] and keeps it running behind a [`Child`] (at most 8 per +/// plugin instance, API 1.3). Closing the invoking window ends its processes. +pub fn start(cmd: &Command, project: Option<&Project>) -> Result { + imp::start(cmd, project.map(|p| &p.inner)).map(|inner| Child { inner }) +} + /// Projects open in any window. Needs `project.read`. pub fn projects() -> Result, Error> { Ok(imp::projects()?.into_iter().map(|inner| Project { inner }).collect()) diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index 3195dd501..61730e48d 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -44,10 +44,29 @@ type ProcessHandler = Box) -> Result, + /// Variables added to the inherited environment. + pub env: Vec<(String, String)>, /// The project root it ran in (`None`: the data directory). pub cwd: Option, } +/// A program a plugin started and keeps running behind a handle. +#[derive(Debug)] +#[allow(dead_code)] // recorded so a test can see what a handle was started with +struct ChildState { + program: String, + args: Vec, + env: Vec<(String, String)>, + cwd: Option, + /// What `write` put into its stdin. + stdin: String, + stdin_closed: bool, + /// What it printed and the plugin has not read yet. + stdout: String, + stderr: String, + status: Option, +} + struct Editor { path: String, language: String, @@ -95,6 +114,9 @@ struct State { requests: Vec, processes: BTreeMap, runs: Vec, + /// The programs `start`ed, by handle id. + children: BTreeMap, + next_child: u64, refreshed: Vec, view_messages: Vec<(String, String)>, terminals: Vec, @@ -111,6 +133,9 @@ pub struct MockTerminal { pub args: Vec, } +/// At most 8 programs a plugin may keep running, as the host allows. +const MAX_CHILDREN: usize = 8; + thread_local! { static CURRENT: RefCell>>> = const { RefCell::new(None) }; } @@ -1185,7 +1210,12 @@ pub mod current { ) -> Result { with(|s| { require(s, &format!("process:{program}"))?; - s.runs.push(ProcessRun { program: program.into(), args: args.to_vec(), cwd: cwd.map(|p| p.root.clone()) }); + s.runs.push(ProcessRun { + program: program.into(), + args: args.to_vec(), + env: Vec::new(), + cwd: cwd.map(|p| p.root.clone()), + }); match s.processes.get(program) { Some(handler) => handler(args, stdin), None => Err(Error::NotFound(format!("{program} (MockHost::on_process)"))), @@ -1193,6 +1223,174 @@ pub mod current { }) } + pub struct ChildRes { + id: u64, + owner: Rc>, + } + + impl Drop for ChildRes { + fn drop(&mut self) { + self.owner.borrow_mut().children.remove(&self.id); + } + } + + fn command_env(cmd: &crate::host::Command) -> Result<(), Error> { + if cmd.env.len() > 64 { + return Err(Error::Invalid("at most 64 environment variables".into())); + } + let mut names: Vec<&str> = Vec::new(); + for (key, value) in &cmd.env { + if key.is_empty() + || key.len() > 256 + || key.contains(['=', '\0']) + || value.len() > 4096 + || value.contains('\0') + { + return Err(Error::Invalid(format!("invalid environment variable {key:?}"))); + } + if names.iter().any(|name| if cfg!(windows) { name.eq_ignore_ascii_case(key) } else { *name == key }) { + return Err(Error::Invalid(format!("environment variable {key:?} already exists"))); + } + names.push(key); + } + Ok(()) + } + + /// Runs a [`Command`]: like `run_process`, with its environment in front of + /// what the program printed (the mock machine has none of its own, so this + /// is how a test sees the variables arrived). + pub fn run_command( + cmd: &crate::host::Command, + cwd: Option<&ProjectRes>, + stdin: Option<&str>, + ) -> Result { + with(|s| { + require(s, &format!("process:{}", cmd.program))?; + command_env(cmd)?; + s.runs.push(ProcessRun { + program: cmd.program.clone(), + args: cmd.args.clone(), + env: cmd.env.clone(), + cwd: cwd.map(|p| p.root.clone()), + }); + let Some(handler) = s.processes.get(&cmd.program) else { + return Err(Error::NotFound(format!("{} (MockHost::on_process)", cmd.program))); + }; + let mut out = handler(&cmd.args, stdin)?; + let env: String = cmd.env.iter().map(|(k, v)| format!("{k}={v}\n")).collect(); + out.stdout.insert_str(0, &env); + Ok(out) + }) + } + + /// Starts a [`Command`] and returns its handle (at most 8, as the host allows). + pub fn start(cmd: &crate::host::Command, cwd: Option<&ProjectRes>) -> Result { + with(|s| { + require(s, &format!("process:{}", cmd.program))?; + command_env(cmd)?; + if s.children.len() >= MAX_CHILDREN { + return Err(Error::Invalid(format!( + "{}: too many processes (at most {MAX_CHILDREN} at a time)", + cmd.program + ))); + } + s.next_child += 1; + let id = s.next_child; + let child = ChildState { + program: cmd.program.clone(), + args: cmd.args.clone(), + env: cmd.env.clone(), + cwd: cwd.map(|p| p.root.clone()), + stdin: String::new(), + stdin_closed: false, + stdout: format!("started: {}\n", cmd.program), + stderr: String::new(), + status: None, + }; + s.children.insert(id, child); + let owner = CURRENT.with(|c| c.borrow().as_ref().unwrap().clone()); + Ok(ChildRes { id, owner }) + }) + } + + fn child_at(s: &mut State, id: u64) -> Result<&mut ChildState, Error> { + s.children.get_mut(&id).ok_or_else(|| Error::NotFound(format!("{id} (no such process handle)"))) + } + + /// Up to `max` bytes from the front of `buf`, cut at a character boundary. + fn take(buf: &mut String, max: u32) -> String { + let mut cut = (max.max(1) as usize).min(1 << 20).min(buf.len()); + while !buf.is_char_boundary(cut) { + cut -= 1; + } + if cut == 0 && !buf.is_empty() { + cut = buf.chars().next().unwrap().len_utf8(); + } + buf.drain(..cut).collect() + } + + pub fn child_poll(child: &ChildRes, max_bytes: u32) -> Result { + let id = child.id; + with(|s| { + let c = child_at(s, id)?; + Ok(crate::host::Chunk { + stdout: take(&mut c.stdout, max_bytes), + stderr: take(&mut c.stderr, max_bytes), + status: c.status, + }) + }) + } + + pub fn child_write(child: &ChildRes, text: &str) -> Result<(), Error> { + let id = child.id; + with(|s| { + let c = child_at(s, id)?; + if c.status.is_some() { + return Err(Error::Invalid(format!("{id}: the process has ended"))); + } + if c.stdin_closed { + return Err(Error::Invalid(format!("{id}: the input is closed"))); + } + c.stdin.push_str(text); + c.stdout.push_str(&format!("echo: {text}")); + Ok(()) + }) + } + + pub fn child_close_stdin(child: &ChildRes) -> Result<(), Error> { + let id = child.id; + with(|s| { + child_at(s, id)?.stdin_closed = true; + Ok(()) + }) + } + + pub fn child_kill(child: &ChildRes) -> Result<(), Error> { + let id = child.id; + with(|s| { + let c = child_at(s, id)?; + if c.status.is_none() { + c.status = Some(-1); + } + Ok(()) + }) + } + + pub fn child_wait(child: &ChildRes, timeout_ms: u32) -> Result { + let id = child.id; + with(|s| { + let c = child_at(s, id)?; + if timeout_ms > 0 && c.status.is_none() { + c.status = Some(0); + } + Ok(crate::host::Chunk { + stdout: std::mem::take(&mut c.stdout), + stderr: std::mem::take(&mut c.stderr), + status: c.status, + }) + }) + } + pub fn begin_task(title: &str, _cancellable: bool) -> TaskRes { with(|s| { s.tasks.push(MockTask { title: title.into(), ..MockTask::default() }); @@ -1410,6 +1608,25 @@ mod tests { assert_eq!(host::copy_to_clipboard("x"), Err(Error::Denied("clipboard".into()))); } + #[test] + fn child_resources_return_slots_and_zero_timeout_only_checks() { + let _mock = MockHost::install().with_capabilities(&["process:git"]); + let command = host::Command::new("git").env("NEW_KEY", "new"); + for _ in 0..10 { + let child = host::start(&command, None).unwrap(); + assert_eq!(child.wait(0).unwrap().status, None); + child.write("中").unwrap(); + assert_eq!(child.poll(0).unwrap().stdout, "e"); + child.kill().unwrap(); + assert_eq!(child.poll(100).unwrap().status, Some(-1)); + } + let children: Vec<_> = (0..8).map(|_| host::start(&command, None).unwrap()).collect(); + assert!(matches!(host::start(&command, None), Err(Error::Invalid(_)))); + drop(children); + assert!(host::start(&command, None).is_ok()); + assert!(matches!(host::start(&command.clone().env("NEW_KEY", "override"), None), Err(Error::Invalid(_)))); + } + #[test] fn network_secrets_processes_and_files() { let mock = MockHost::install(); diff --git a/sdk/rust/openidea-plugin/wit/io.wit b/sdk/rust/openidea-plugin/wit/io.wit index 97111a625..cffd883be 100644 --- a/sdk/rust/openidea-plugin/wit/io.wit +++ b/sdk/rust/openidea-plugin/wit/io.wit @@ -75,8 +75,10 @@ interface secrets { is-set: func(name: string) -> bool; } -/// One-off runs of programs granted with `process:` (the file the -/// user approved, checked before every run). +/// Runs of programs granted with `process:` (the file the user +/// approved, checked before every run): once and awaited with `run`, once +/// with an environment with `run-command`, or kept running behind a `child` +/// handle started with `start`. interface process { use types.{error}; use workspace.{project}; @@ -89,8 +91,56 @@ interface process { stderr: string, } + /// What to run (API 1.3). `env` only adds variables: an existing name in + /// the prepared child environment (including the login-shell PATH) is an + /// error naming that key; nothing is replaced or removed. Names compare + /// case-insensitively on Windows. At most 64 entries, nonempty names of + /// at most 256 bytes without '=' or NUL, values at most 4 KiB without NUL. + record command { + program: string, + args: list, + env: list>, + } + + /// What a kept-running program printed since the last call, and its exit + /// code once it ended (`none` while it still runs). + record chunk { + /// Up to 1 MiB per stream; unpolled output keeps the last 1 MiB. + /// `poll` takes up to `max-bytes`, or one whole character when that + /// limit is smaller. Incomplete characters wait for the next read. + stdout: string, + stderr: string, + status: option, + } + + /// A program left running. Dropping the handle kills it if it still + /// runs, so a plugin cannot leak one; a plugin has at most 8 at a time + /// (the ninth `start` is `invalid`). They also end with the plugin: + /// stopping, disabling or reclaiming it kills what it started. + resource child { + /// What it printed since the last call, and its status once it ends. + poll: func(max-bytes: u32) -> result; + /// Waits up to `timeout-ms` for it to end (0 only checks), then + /// reports what it printed since the last call. + wait: func(timeout-ms: u32) -> result; + /// Writes to its stdin; fails once it ended or stdin was closed. + write: func(text: string) -> result<_, error>; + /// Closes stdin; fails once the program ended. + close-stdin: func() -> result<_, error>; + /// Kills it and reaps it, so its status is available afterwards. + kill: func() -> result<_, error>; + } + /// Runs `program` with `args` in `cwd`'s root (else the plugin's data /// directory) and waits; cancelled with the task or call. Call it inside /// a task for anything slow. run: func(program: string, args: list, cwd: option>, stdin: option) -> result; + + /// The same as `run`, with append-only environment variables (API 1.3). + run-command: func(cmd: command, cwd: option>, stdin: option) -> result; + + /// Starts `cmd` with piped stdin and stdout/stderr, and returns the + /// handle to it without waiting (API 1.3). Closing the invoking window + /// also ends its processes. + start: func(cmd: command, cwd: option>) -> result; } diff --git a/wit/io.wit b/wit/io.wit index 97111a625..cffd883be 100644 --- a/wit/io.wit +++ b/wit/io.wit @@ -75,8 +75,10 @@ interface secrets { is-set: func(name: string) -> bool; } -/// One-off runs of programs granted with `process:` (the file the -/// user approved, checked before every run). +/// Runs of programs granted with `process:` (the file the user +/// approved, checked before every run): once and awaited with `run`, once +/// with an environment with `run-command`, or kept running behind a `child` +/// handle started with `start`. interface process { use types.{error}; use workspace.{project}; @@ -89,8 +91,56 @@ interface process { stderr: string, } + /// What to run (API 1.3). `env` only adds variables: an existing name in + /// the prepared child environment (including the login-shell PATH) is an + /// error naming that key; nothing is replaced or removed. Names compare + /// case-insensitively on Windows. At most 64 entries, nonempty names of + /// at most 256 bytes without '=' or NUL, values at most 4 KiB without NUL. + record command { + program: string, + args: list, + env: list>, + } + + /// What a kept-running program printed since the last call, and its exit + /// code once it ended (`none` while it still runs). + record chunk { + /// Up to 1 MiB per stream; unpolled output keeps the last 1 MiB. + /// `poll` takes up to `max-bytes`, or one whole character when that + /// limit is smaller. Incomplete characters wait for the next read. + stdout: string, + stderr: string, + status: option, + } + + /// A program left running. Dropping the handle kills it if it still + /// runs, so a plugin cannot leak one; a plugin has at most 8 at a time + /// (the ninth `start` is `invalid`). They also end with the plugin: + /// stopping, disabling or reclaiming it kills what it started. + resource child { + /// What it printed since the last call, and its status once it ends. + poll: func(max-bytes: u32) -> result; + /// Waits up to `timeout-ms` for it to end (0 only checks), then + /// reports what it printed since the last call. + wait: func(timeout-ms: u32) -> result; + /// Writes to its stdin; fails once it ended or stdin was closed. + write: func(text: string) -> result<_, error>; + /// Closes stdin; fails once the program ended. + close-stdin: func() -> result<_, error>; + /// Kills it and reaps it, so its status is available afterwards. + kill: func() -> result<_, error>; + } + /// Runs `program` with `args` in `cwd`'s root (else the plugin's data /// directory) and waits; cancelled with the task or call. Call it inside /// a task for anything slow. run: func(program: string, args: list, cwd: option>, stdin: option) -> result; + + /// The same as `run`, with append-only environment variables (API 1.3). + run-command: func(cmd: command, cwd: option>, stdin: option) -> result; + + /// Starts `cmd` with piped stdin and stdout/stderr, and returns the + /// handle to it without waiting (API 1.3). Closing the invoking window + /// also ends its processes. + start: func(cmd: command, cwd: option>) -> result; }