mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
feat(plugins): let a plugin keep a process running behind a handle (#2027)
Closes #1826 API 1.3 adds `run-command` (one-shot with stdin, cwd and environment) and a handle-based `start` for a long-running process with incremental stdin/stdout/stderr, both gated by the existing `process:<program>` grants. A plugin's environment entries are append-only: a key the child would already inherit (including the login-shell PATH) is refused with an error naming it, as are empty or malformed keys. Processes run in their own process group and are killed with their descendants when the handle is dropped, the plugin is disabled, unloaded or reclaimed, or the owning window closes; at most eight handles per plugin. Output is bounded and UTF-8 safe, stdin writes honour cancellation, and validation failures return typed errors instead of trapping the plugin. Verified on macOS; Windows/Linux not run. Co-authored-by: chaoes <[email protected]>
This commit is contained in:
1 parent
e024c13db7
commit
3efec1c848
63 files changed
+1549
-72
No files matched your search
@@ -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<CommitAnswer>),
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
state: Mutex<ProcState>,
|
||||
}
|
||||
|
||||
struct ProcState {
|
||||
child: Child,
|
||||
stdin: Option<ChildStdin>,
|
||||
/// What it printed and the plugin hasn't read yet.
|
||||
stdout: Vec<u8>,
|
||||
stderr: Vec<u8>,
|
||||
/// Its exit status once it ended (a code, or -1 when a signal ended it).
|
||||
status: Option<i32>,
|
||||
/// 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<Self>, 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<u8>, 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<Option<std::process::ExitStatus>> {
|
||||
#[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<dyn PluginEventSink>,
|
||||
@@ -184,6 +402,10 @@ pub(super) struct Shared {
|
||||
pub data_dir: PathBuf,
|
||||
tasks: Mutex<HashMap<u64, TaskEntry>>,
|
||||
next_task: AtomicU64,
|
||||
/// (plugin, handle id) → the processes it started and has not released.
|
||||
processes: Mutex<HashMap<(String, u64), Arc<Proc>>>,
|
||||
next_process: AtomicU64,
|
||||
process_shutdowns: Mutex<Vec<std::thread::JoinHandle<()>>>,
|
||||
prompts: Mutex<HashMap<u64, PromptEntry>>,
|
||||
/// Plugins being stopped: their calls may not open new prompts (taken before `prompts`).
|
||||
prompts_blocked: Mutex<BTreeSet<String>>,
|
||||
@@ -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<String> = {
|
||||
let mut status = self.status.lock();
|
||||
let ids: Vec<String> = 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<Arc<Proc>> = {
|
||||
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<Arc<Proc>, 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<String>,
|
||||
cmd: ProcessCommand,
|
||||
cwd: Option<&Path>,
|
||||
stdin: Option<String>,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<ProcessOutput, HostError> {
|
||||
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<Box<dyn std::io::Read + Send>>| {
|
||||
let read = |pipe: Option<Box<dyn Read + Send>>| {
|
||||
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<dyn std::io::Read + Send>));
|
||||
let stderr = read(child.stderr.take().map(|p| Box::new(p) as Box<dyn std::io::Read + Send>));
|
||||
let stdout = read(child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
|
||||
let stderr = read(child.stderr.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
|
||||
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<u64, HostError> {
|
||||
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<ProcessChunk, HostError> {
|
||||
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<ProcessChunk, HostError> {
|
||||
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<RwLock<HashMap<(String, String), ProgramPin>>> = 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<RwLock<HashMap<(String, String), ProgramPin>>> = 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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<HttpResponse, HostError>;
|
||||
fn secret_is_set(&self, name: &str) -> bool;
|
||||
/// Runs the approved file of `process:<program>`.
|
||||
/// Runs the approved file of `process:<program>` with `cmd.env` added to
|
||||
/// the inherited environment, and waits.
|
||||
fn run_process(
|
||||
&self,
|
||||
program: &str,
|
||||
args: Vec<String>,
|
||||
cmd: ProcessCommand,
|
||||
cwd: Option<&Path>,
|
||||
stdin: Option<String>,
|
||||
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<String>) -> 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<u64, HostError>;
|
||||
/// 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<ProcessChunk, HostError>;
|
||||
/// 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<ProcessChunk, HostError>;
|
||||
/// 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<u32>, message: Option<String>);
|
||||
|
||||
@@ -352,6 +352,24 @@ pub struct ProcessOutput {
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
/// What to run: the approved file of `process:<program>`, 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<String>,
|
||||
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<i32>,
|
||||
}
|
||||
|
||||
/// 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<String, String> {
|
||||
|
||||
@@ -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<dyn crate::HostApi>,
|
||||
}
|
||||
|
||||
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<T> = wasmtime::Result<Result<T, wit::Error>>;
|
||||
|
||||
@@ -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<ProcessCommand, wit::Error> {
|
||||
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<Resource<ProjectRes>>) -> wasmtime::Result<Option<PathBuf>> {
|
||||
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<Resource<ProjectRes>>,
|
||||
stdin: Option<String>,
|
||||
) -> Fallible<process::Output> {
|
||||
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<Resource<ProjectRes>>,
|
||||
stdin: Option<String>,
|
||||
) -> Fallible<process::Output> {
|
||||
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<Resource<ProjectRes>>) -> Fallible<Resource<ChildRes>> {
|
||||
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<ChildRes>, max_bytes: u32) -> Fallible<process::Chunk> {
|
||||
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<ChildRes>, timeout_ms: u32) -> Fallible<process::Chunk> {
|
||||
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<ChildRes>, 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<ChildRes>) -> 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<ChildRes>) -> Fallible<()> {
|
||||
let id = self.table.get(&this)?.id;
|
||||
Ok(self.host.kill_process(id).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn drop(&mut self, rep: Resource<ChildRes>) -> wasmtime::Result<()> {
|
||||
self.table.delete(rep)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
4a49382f47d12b2921869044e0da4f25ece0ec6e7dea048e25f37f55e1345d75
|
||||
13db3ba9045429fef5973e83075a51a315076408a03ec3c1b837c0229365fcbe
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
892b7ca523ef251eb626aece060acd54258afaf8204fe02713b9e57b5ca9a67e
|
||||
6c2a59bf732acebd74ad4d05824be3aafbd501964be01205ca1e0d5a6135d113
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
93f7501bb780898346fbf0614af254949dd17dd7c705581befc5e485422e1866
|
||||
183bf50a6ee479128440ef4bd86939f24b036b699a24842a6efd1d3f43c6cd10
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
6981c76c96e2e7ab086be65847010bb22cb012c259cdec075b2285253c73f637
|
||||
97f7f6fe965e95510d5556feb40329c312e5430e631479be6c9646046a888478
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
07c6f0ce91539ad93b8bbd4535f4288cce1334f6c44e4820268663688623e104
|
||||
f73de3fdfe5b343f26ff4dd1ae150c975273ce398d518f6f2100b0baa8074ffc
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
00293224c09af5a08ea8862e4fd6263ea36f500750752b428f8e6ff61747a524
|
||||
a985162e44066c6bf6c93d01af1b01d40d305c6be43072d4f33d4f1f19a4dee0
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
53f907d6f0415cf7fd09d09bf13aef1954339462eeb44e322a53ee44af9655c2
|
||||
5063a997e11258386e88ba129d42262f5050bfba41c8ea069152b2dfba007fa7
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
ad8fb651c09a49be8254bfe5fbaf7463f80e23ec712038e78141e3e32abb93eb
|
||||
9a3d097559f6685e82024bd84cb0396875762b7136636fdc9bd11cb41875373a
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
7fde1c70e9e17693144c7229e53dac200f18c281e0bc83fffc21f3d18671d1be
|
||||
6ca0c03e31e0cd67192bb86c93b1feb294fec79cf432ceb57b9cdc3e42eb6b02
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
47623924eee80c6d1aa9dcc22825ea70b92e1b1a41ee5741f20d4d20cdf49eb3
|
||||
0ec5c31f6b9b221b0b7be58f73d5c94703e8c14ada610d8ad12f84f63bf5fe97
|
||||
@@ -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"
|
||||
+72
-1
@@ -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<Vec<process::Child>> = 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"))?;
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
174df83a18c8cc350df3a1fd7869fd832fd45a3f535d2795ad82814f8136454c
|
||||
848356d2e0fbaef341436ea70a4698915cce705d3ccea4dd4e50900b12c97d33
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
94c6ed6a516973e8ab90a0c550ea700f795eb61f1941c514c0d033db09321a87
|
||||
07001ff22cd33d5ae6f96b584707e66d31f2f93423cf72a993e6f11c9f0ec319
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
34a1df80de1db3a04363f81b8f3501cb59dc0aee9a0754912b84a34c76ea77d0
|
||||
ae9cb4a106aa8fd46662dba07acd0daba2927b7fe53e544cd95348954505bc36
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c6698547bcda49a08bacd6d0876af74ae901d4c32e3c32b014a7679fd96f32c0
|
||||
8a79f2899d88773d7c33175136960727b5ad0aefc5ce5eb363fd20dc4990c60e
|
||||
@@ -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:<program>` 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.<plugin id>.<command id>", "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.
|
||||
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
5325c5c915e66d12cc47afc55b29e18e531a3f533e69c51933413f4f77a74438
|
||||
7872480dee517db617120a9327d3bbbcc007d31bbf9e6c999ce202f38f0ed478
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
d23b9b067928d6e7648a4989b09e201cade971a67ab769b4d583a274f4e071ac
|
||||
31f1d88c7e88553eed145fd170485c10e19967eccbf2021ec71fca862d05e952
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a49ab90f826a214d7de7058164f1fd886d9b762482c94953ee07495833742f92
|
||||
6a2db880c49b15d7ce5ba7fda69ac8d1cac04f0412d42b6922550594ef94a7c5
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
29f7f3bd8b35edf079fdac3c4cc67c5b5ffd57c999064a9ee55793f4f22b6c6b
|
||||
9299f10c517ee5582312f0f14871e21d36050ee8d621df31d7e6a4126c4ea37e
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a008cd053e851f86be07576b21c0a0a763cb8e239e7bba03127de0d7c5ab3a4b
|
||||
1f86f15f5e864ec9a91cb31379047eafd4ae8c07ffacb5e1c85955ee07873342
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a598e79d3e15b7ce4c5fef6b0af56d7d17e572bc1736806bbfa93349126dfb33
|
||||
540e60e34b752eb201ef9cacea44066b4f59a4e36c3d1a468bf208223569a628
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
481910e4fe978ad5060cc70dd9d66dda029f4f73a0e8cd172b78df85dfa6dcd1
|
||||
106f4a0b300fdb98fd0bd8f8bc1b3ce2c692a8a7e0107353905d7edbb7c7ce4d
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a667fe84ceda6c32f4e74e86b39c39f9c0c7654e07ff911a294dae44a3e15c23
|
||||
eba583c669da994dba24a8a16546c9d2cb50fb46db53207207809b20882680e8
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a59169bbd1e95017f28f92c9b8796059f0804c9361f1ec39d9ff2ec3fc7ca3ab
|
||||
d1385eb9aa4d78bbcbdf98565fdf3b550050388af9248814f5c994b54d605708
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
fc5e1ed12c0238b7d9bc0621e41beb89bd62191fe0f30b8c66554207cde9cdf7
|
||||
8f4c3af7f1f0f6e2001ce161d89e2b83ef700480d6fd5517f46e90437f74639e
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
343c6d46b9bf07f8631f16dbc737301a02a6b229b17eb0a455207509823680e1
|
||||
2504128882832c282d627946668f10603fe4b165dca8f12a49131934d1a5c09d
|
||||
@@ -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<ProcessOutput, Error> {
|
||||
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<ChildRes, Error> {
|
||||
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<crate::host::Chunk, Error> {
|
||||
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<crate::host::Chunk, Error> {
|
||||
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<String>,
|
||||
/// 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:<program>` 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<I, S>(mut self, args: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
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<i32>,
|
||||
}
|
||||
|
||||
/// 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<Chunk, Error> {
|
||||
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<Chunk, Error> {
|
||||
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<ProcessOutput, Error> {
|
||||
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<Child, Error> {
|
||||
imp::start(cmd, project.map(|p| &p.inner)).map(|inner| Child { inner })
|
||||
}
|
||||
|
||||
/// Projects open in any window. Needs `project.read`.
|
||||
pub fn projects() -> Result<Vec<Project>, Error> {
|
||||
Ok(imp::projects()?.into_iter().map(|inner| Project { inner }).collect())
|
||||
|
||||
@@ -44,10 +44,29 @@ type ProcessHandler = Box<dyn Fn(&[String], Option<&str>) -> Result<ProcessOutpu
|
||||
pub struct ProcessRun {
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
/// Variables added to the inherited environment.
|
||||
pub env: Vec<(String, String)>,
|
||||
/// The project root it ran in (`None`: the data directory).
|
||||
pub cwd: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
env: Vec<(String, String)>,
|
||||
cwd: Option<String>,
|
||||
/// 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<i32>,
|
||||
}
|
||||
|
||||
struct Editor {
|
||||
path: String,
|
||||
language: String,
|
||||
@@ -95,6 +114,9 @@ struct State {
|
||||
requests: Vec<HttpRequest>,
|
||||
processes: BTreeMap<String, ProcessHandler>,
|
||||
runs: Vec<ProcessRun>,
|
||||
/// The programs `start`ed, by handle id.
|
||||
children: BTreeMap<u64, ChildState>,
|
||||
next_child: u64,
|
||||
refreshed: Vec<String>,
|
||||
view_messages: Vec<(String, String)>,
|
||||
terminals: Vec<MockTerminal>,
|
||||
@@ -111,6 +133,9 @@ pub struct MockTerminal {
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
/// At most 8 programs a plugin may keep running, as the host allows.
|
||||
const MAX_CHILDREN: usize = 8;
|
||||
|
||||
thread_local! {
|
||||
static CURRENT: RefCell<Option<Rc<RefCell<State>>>> = const { RefCell::new(None) };
|
||||
}
|
||||
@@ -1185,7 +1210,12 @@ pub mod current {
|
||||
) -> Result<ProcessOutput, Error> {
|
||||
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<RefCell<State>>,
|
||||
}
|
||||
|
||||
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<ProcessOutput, Error> {
|
||||
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<ChildRes, Error> {
|
||||
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<crate::host::Chunk, Error> {
|
||||
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<crate::host::Chunk, Error> {
|
||||
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();
|
||||
|
||||
@@ -75,8 +75,10 @@ interface secrets {
|
||||
is-set: func(name: string) -> bool;
|
||||
}
|
||||
|
||||
/// One-off runs of programs granted with `process:<program>` (the file the
|
||||
/// user approved, checked before every run).
|
||||
/// Runs of programs granted with `process:<program>` (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<string>,
|
||||
env: list<tuple<string, string>>,
|
||||
}
|
||||
|
||||
/// 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<s32>,
|
||||
}
|
||||
|
||||
/// 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<chunk, error>;
|
||||
/// 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<chunk, error>;
|
||||
/// 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<string>, cwd: option<borrow<project>>, stdin: option<string>) -> result<output, error>;
|
||||
|
||||
/// The same as `run`, with append-only environment variables (API 1.3).
|
||||
run-command: func(cmd: command, cwd: option<borrow<project>>, stdin: option<string>) -> result<output, error>;
|
||||
|
||||
/// 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<borrow<project>>) -> result<child, error>;
|
||||
}
|
||||
+52
-2
@@ -75,8 +75,10 @@ interface secrets {
|
||||
is-set: func(name: string) -> bool;
|
||||
}
|
||||
|
||||
/// One-off runs of programs granted with `process:<program>` (the file the
|
||||
/// user approved, checked before every run).
|
||||
/// Runs of programs granted with `process:<program>` (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<string>,
|
||||
env: list<tuple<string, string>>,
|
||||
}
|
||||
|
||||
/// 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<s32>,
|
||||
}
|
||||
|
||||
/// 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<chunk, error>;
|
||||
/// 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<chunk, error>;
|
||||
/// 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<string>, cwd: option<borrow<project>>, stdin: option<string>) -> result<output, error>;
|
||||
|
||||
/// The same as `run`, with append-only environment variables (API 1.3).
|
||||
run-command: func(cmd: command, cwd: option<borrow<project>>, stdin: option<string>) -> result<output, error>;
|
||||
|
||||
/// 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<borrow<project>>) -> result<child, error>;
|
||||
}
|
||||
Reference in new issue
Block a user