дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
369 рядки
14 KiB
Rust
369 рядки
14 KiB
Rust
//! Run configurations on a run target: the project is synchronised to the
|
|
//! target's folder, then the command runs there over SSH with local paths and
|
|
//! interpreters swapped for their remote counterparts.
|
|
|
|
use ide_core::remote_settings::RunTarget;
|
|
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, RunId, SharedSink};
|
|
use ide_run::{OutputChunk, RunConfig, RunInfo};
|
|
use ide_ssh::sftp::join;
|
|
use ide_ssh::{Output, RemoteProcess, Session, Sig};
|
|
use parking_lot::Mutex;
|
|
use std::collections::BTreeMap;
|
|
use std::future::Future;
|
|
use std::path::Path;
|
|
use std::pin::Pin;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
type OutputFn = Arc<dyn Fn(OutputChunk) + Send + Sync>;
|
|
/// Uploads local changes before the run; reports progress lines.
|
|
pub type SyncFn = Box<dyn FnOnce(OutputFn) -> Pin<Box<dyn Future<Output = IdeResult<()>> + Send>> + Send>;
|
|
|
|
/// The remote program, arguments, folder and environment of a configuration.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RemoteCommand {
|
|
pub program: String,
|
|
pub args: Vec<String>,
|
|
pub cwd: String,
|
|
pub env: BTreeMap<String, String>,
|
|
/// A whole shell command line (configurations with a command and no arguments).
|
|
pub shell_line: Option<String>,
|
|
}
|
|
|
|
impl RemoteCommand {
|
|
pub fn line(&self) -> String {
|
|
match &self.shell_line {
|
|
Some(line) => {
|
|
let exports: String = self
|
|
.env
|
|
.iter()
|
|
.map(|(k, v)| format!("export {}; ", ide_ssh::shell::quote(&format!("{k}={v}"))))
|
|
.collect();
|
|
format!("cd {} && {exports}{line}", ide_ssh::shell::quote(&self.cwd))
|
|
}
|
|
None => ide_ssh::shell::command_line(Some(&self.cwd), &self.env, &self.program, &self.args),
|
|
}
|
|
}
|
|
|
|
/// The echoed command line.
|
|
pub fn display(&self) -> String {
|
|
match &self.shell_line {
|
|
Some(line) => line.clone(),
|
|
None => std::iter::once(self.program.as_str())
|
|
.chain(self.args.iter().map(String::as_str))
|
|
.map(ide_ssh::shell::quote)
|
|
.collect::<Vec<_>>()
|
|
.join(" "),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `s` with the path `root` replaced by `remote` where it appears as a whole path
|
|
/// (followed by a separator or the end of a path), not as the prefix of a longer name.
|
|
/// The rest of a Windows path after the root takes `/` separators too.
|
|
fn replace_root(s: &str, root: &str, remote: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
let mut rest = s;
|
|
while let Some(i) = rest.find(root) {
|
|
let mut after = &rest[i + root.len()..];
|
|
let whole = after.chars().next().is_none_or(|c| !(c.is_alphanumeric() || matches!(c, '_' | '-' | '.')));
|
|
out.push_str(&rest[..i]);
|
|
if whole {
|
|
out.push_str(remote);
|
|
if root.contains('\\') {
|
|
let end = after
|
|
.find(|c: char| c.is_whitespace() || matches!(c, ';' | ',' | '"' | '\''))
|
|
.unwrap_or(after.len());
|
|
out.push_str(&after[..end].replace('\\', "/"));
|
|
after = &after[end..];
|
|
}
|
|
} else {
|
|
out.push_str(root);
|
|
}
|
|
rest = after;
|
|
}
|
|
out.push_str(rest);
|
|
out
|
|
}
|
|
|
|
fn file_name(program: &str) -> &str {
|
|
program.rsplit(['/', '\\']).next().unwrap_or(program)
|
|
}
|
|
|
|
/// The target's interpreter for a local interpreter executable, if it is one.
|
|
fn interpreter<'a>(program: &str, target: &'a RunTarget) -> Option<&'a str> {
|
|
let name = file_name(program).trim_end_matches(".exe").to_ascii_lowercase();
|
|
let pick = |configured: &'a str, default: &'a str| {
|
|
Some(if configured.trim().is_empty() { default } else { configured.trim() })
|
|
};
|
|
if name == "python" || name.starts_with("python3") || name == "python2" {
|
|
pick(&target.python, "python3")
|
|
} else if name == "node" {
|
|
pick(&target.node, "node")
|
|
} else if name == "java" {
|
|
pick(&target.java, "java")
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Builds the remote command of `config`; `root` (the project) maps onto `remote_root`.
|
|
pub fn remote_command(config: &RunConfig, target: &RunTarget, root: &Path, remote_root: &str) -> RemoteCommand {
|
|
let root_str = root.to_string_lossy().into_owned();
|
|
let map = |s: &str| -> String {
|
|
if root_str.is_empty() {
|
|
return s.to_owned();
|
|
}
|
|
replace_root(s, &root_str, remote_root.trim_end_matches('/')).replace('\\', "/")
|
|
};
|
|
let map_text = |s: &str| -> String {
|
|
if root_str.is_empty() { s.to_owned() } else { replace_root(s, &root_str, remote_root.trim_end_matches('/')) }
|
|
};
|
|
let cwd = match config.cwd.as_ref().map(|p| p.to_path_buf()) {
|
|
Some(dir) if dir.is_absolute() => match dir.strip_prefix(root) {
|
|
Ok(rel) => join(remote_root, &crate::mapping::posix(rel)),
|
|
Err(_) => remote_root.to_owned(),
|
|
},
|
|
Some(dir) if !dir.as_os_str().is_empty() => join(remote_root, &crate::mapping::posix(&dir)),
|
|
_ => remote_root.to_owned(),
|
|
};
|
|
let env = config.env.iter().map(|(k, v)| (k.clone(), map_text(v))).collect();
|
|
let command = config.command.trim();
|
|
if config.args.is_empty() && command.contains(char::is_whitespace) {
|
|
return RemoteCommand {
|
|
program: String::new(),
|
|
args: Vec::new(),
|
|
cwd,
|
|
env,
|
|
shell_line: Some(map_text(command)),
|
|
};
|
|
}
|
|
let program = match interpreter(command, target) {
|
|
Some(remote) => remote.to_owned(),
|
|
None if Path::new(command).is_absolute() && Path::new(command).starts_with(root) => map(command),
|
|
None if Path::new(command).is_absolute() => file_name(command).to_owned(),
|
|
None => command.to_owned(),
|
|
};
|
|
let args = config.args.iter().map(|a| map_text(a)).collect();
|
|
RemoteCommand { program, args, cwd, env, shell_line: None }
|
|
}
|
|
|
|
struct RemoteRun {
|
|
info: Mutex<RunInfo>,
|
|
process: Mutex<Option<Arc<RemoteProcess>>>,
|
|
cancel: CancellationToken,
|
|
stopping: AtomicBool,
|
|
/// The runtime it started on: stdin / stop requests may come from threads without one.
|
|
runtime: tokio::runtime::Handle,
|
|
}
|
|
|
|
/// Runs on run targets, alongside the local `ide_run::ProcessManager`.
|
|
#[derive(Default)]
|
|
pub struct RemoteRuns {
|
|
runs: Arc<Mutex<BTreeMap<RunId, Arc<RemoteRun>>>>,
|
|
}
|
|
|
|
pub struct RemoteStart {
|
|
pub session: Arc<Session>,
|
|
pub target: RunTarget,
|
|
pub root: std::path::PathBuf,
|
|
/// The target's project folder, resolved on the host.
|
|
pub remote_root: String,
|
|
/// Uploads local changes first (`sync_before_run`).
|
|
pub sync: Option<SyncFn>,
|
|
}
|
|
|
|
impl RemoteRuns {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn contains(&self, id: RunId) -> bool {
|
|
self.runs.lock().contains_key(&id)
|
|
}
|
|
|
|
pub fn list(&self) -> Vec<RunInfo> {
|
|
self.runs.lock().values().map(|r| r.info.lock().clone()).collect()
|
|
}
|
|
|
|
/// Starts `config` on the target; returns at once (synchronisation and the
|
|
/// remote process follow in the background, reported as output).
|
|
pub fn start(
|
|
&self,
|
|
start: RemoteStart,
|
|
config: &RunConfig,
|
|
sink: SharedSink,
|
|
on_output: Box<dyn Fn(OutputChunk) + Send + Sync>,
|
|
) -> RunInfo {
|
|
let id = RunId::next();
|
|
let pty = config.extras.emulate_terminal;
|
|
let info =
|
|
RunInfo { id, config_id: config.id.clone(), name: config.name.clone(), pid: None, running: true, pty };
|
|
let run = Arc::new(RemoteRun {
|
|
info: Mutex::new(info.clone()),
|
|
process: Mutex::new(None),
|
|
cancel: CancellationToken::new(),
|
|
stopping: AtomicBool::new(false),
|
|
runtime: tokio::runtime::Handle::current(),
|
|
});
|
|
self.runs.lock().insert(id, run.clone());
|
|
let on_output: OutputFn = Arc::from(on_output);
|
|
let command = remote_command(config, &start.target, &start.root, &start.remote_root);
|
|
tokio::spawn(async move {
|
|
let code = run_remote(&run, start, command, pty, on_output.clone()).await;
|
|
let message = match &code {
|
|
Ok(Some(c)) => format!("Process finished with exit code {c}"),
|
|
Ok(None) if run.stopping.load(Ordering::Acquire) => "Process finished (stopped)".to_owned(),
|
|
Ok(None) => "Process finished".to_owned(),
|
|
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => "Process finished (stopped)".to_owned(),
|
|
Err(e) => format!("Cannot run on the target: {}", e.message),
|
|
};
|
|
on_output(OutputChunk::System { text: format!("\n{message}\n") });
|
|
run.info.lock().running = false;
|
|
run.process.lock().take();
|
|
let code = code.ok().flatten().and_then(|c| i32::try_from(c).ok());
|
|
sink.emit(IdeEvent::RunExited { run: id, code });
|
|
});
|
|
info
|
|
}
|
|
|
|
pub fn write_stdin(&self, id: RunId, data: &str) -> IdeResult<()> {
|
|
let (process, rt) = self.process(id)?;
|
|
let data = data.as_bytes().to_vec();
|
|
rt.spawn(async move { process.write(&data).await });
|
|
Ok(())
|
|
}
|
|
|
|
pub fn close_stdin(&self, id: RunId) -> IdeResult<()> {
|
|
let (process, rt) = self.process(id)?;
|
|
rt.spawn(async move { process.eof().await });
|
|
Ok(())
|
|
}
|
|
|
|
pub fn resize(&self, id: RunId, cols: u16, rows: u16) -> IdeResult<()> {
|
|
let (process, rt) = self.process(id)?;
|
|
rt.spawn(async move { process.resize(cols, rows).await });
|
|
Ok(())
|
|
}
|
|
|
|
/// `INT`, then `KILL` (closing the channel) after `grace`.
|
|
pub fn stop(&self, id: RunId, grace: std::time::Duration) -> IdeResult<()> {
|
|
let run =
|
|
self.runs.lock().get(&id).cloned().ok_or_else(|| IdeError::not_found(format!("no such run: {id}")))?;
|
|
run.stopping.store(true, Ordering::Release);
|
|
run.cancel.cancel();
|
|
let Some(process) = run.process.lock().clone() else { return Ok(()) };
|
|
let rt = run.runtime.clone();
|
|
rt.spawn(async move {
|
|
let _ = process.signal(Sig::INT).await;
|
|
tokio::time::sleep(grace).await;
|
|
if run.info.lock().running {
|
|
let _ = process.signal(Sig::KILL).await;
|
|
}
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
fn process(&self, id: RunId) -> IdeResult<(Arc<RemoteProcess>, tokio::runtime::Handle)> {
|
|
let run =
|
|
self.runs.lock().get(&id).cloned().ok_or_else(|| IdeError::not_found(format!("no such run: {id}")))?;
|
|
let process = run.process.lock().clone();
|
|
let process = process.ok_or_else(|| IdeError::unavailable("the remote process has not started yet"))?;
|
|
Ok((process, run.runtime.clone()))
|
|
}
|
|
}
|
|
|
|
async fn run_remote(
|
|
run: &RemoteRun,
|
|
start: RemoteStart,
|
|
command: RemoteCommand,
|
|
pty: bool,
|
|
on_output: OutputFn,
|
|
) -> IdeResult<Option<u32>> {
|
|
on_output(OutputChunk::System { text: format!("[{}] {}\n", start.target.name, start.session.label()) });
|
|
if let Some(sync) = start.sync {
|
|
tokio::select! {
|
|
r = sync(on_output.clone()) => r?,
|
|
_ = run.cancel.cancelled() => return Err(IdeError::cancelled()),
|
|
}
|
|
}
|
|
if run.cancel.is_cancelled() {
|
|
return Err(IdeError::cancelled());
|
|
}
|
|
on_output(OutputChunk::System { text: format!("{}\n", command.display()) });
|
|
let out = on_output.clone();
|
|
let process = start
|
|
.session
|
|
.spawn(
|
|
&command.line(),
|
|
pty.then_some((120, 30)),
|
|
Box::new(move |chunk| match chunk {
|
|
Output::Stdout(b) => out(OutputChunk::Stdout { text: String::from_utf8_lossy(b).into_owned() }),
|
|
Output::Stderr(b) => out(OutputChunk::Stderr { text: String::from_utf8_lossy(b).into_owned() }),
|
|
}),
|
|
)
|
|
.await?;
|
|
let process = Arc::new(process);
|
|
*run.process.lock() = Some(process.clone());
|
|
Ok(process.wait().await)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[cfg(unix)]
|
|
fn config(command: &str, args: &[&str], cwd: Option<&str>) -> RunConfig {
|
|
RunConfig {
|
|
id: "c".into(),
|
|
name: "c".into(),
|
|
kind: "python".into(),
|
|
command: command.into(),
|
|
args: args.iter().map(|s| s.to_string()).collect(),
|
|
cwd: cwd.map(|c| ide_core::IpcPath::from(std::path::PathBuf::from(c))),
|
|
env: BTreeMap::from([("DATA".to_owned(), "/work/proj/data".to_owned())]),
|
|
detected: false,
|
|
is_test: false,
|
|
debug_adapter: None,
|
|
allow_parallel: false,
|
|
extras: Default::default(),
|
|
}
|
|
}
|
|
|
|
// Unix paths are not absolute on Windows (`tests/remote.rs` runs there).
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn maps_interpreters_paths_and_folders() {
|
|
let target = RunTarget { python: "/opt/py/bin/python".into(), ..Default::default() };
|
|
let root = Path::new("/work/proj");
|
|
let c = remote_command(
|
|
&config("/work/proj/.venv/bin/python", &["-u", "/work/proj/src/main.py"], Some("/work/proj/src")),
|
|
&target,
|
|
root,
|
|
"/home/me/proj",
|
|
);
|
|
assert_eq!(c.program, "/opt/py/bin/python");
|
|
assert_eq!(c.args, ["-u", "/home/me/proj/src/main.py"]);
|
|
assert_eq!(c.cwd, "/home/me/proj/src");
|
|
assert_eq!(c.env["DATA"], "/home/me/proj/data");
|
|
assert_eq!(
|
|
c.line(),
|
|
"cd /home/me/proj/src && exec env DATA=/home/me/proj/data /opt/py/bin/python -u /home/me/proj/src/main.py"
|
|
);
|
|
|
|
let c = remote_command(&config("npm run dev", &[], None), &target, root, "/home/me/proj");
|
|
assert_eq!(c.line(), "cd /home/me/proj && export DATA=/home/me/proj/data; npm run dev");
|
|
let c = remote_command(&config("/usr/local/bin/node", &["app.js"], None), &RunTarget::default(), root, "/srv");
|
|
assert_eq!((c.program.as_str(), c.cwd.as_str()), ("node", "/srv"));
|
|
let c = remote_command(&config("/usr/bin/make", &["test"], None), &RunTarget::default(), root, "/srv");
|
|
assert_eq!(c.program, "make");
|
|
}
|
|
|
|
#[test]
|
|
fn maps_the_root_only_as_a_whole_path() {
|
|
assert_eq!(replace_root("/work/proj2/x:/work/proj/y", "/work/proj", "/srv"), "/work/proj2/x:/srv/y");
|
|
assert_eq!(replace_root("/work/proj", "/work/proj", "/srv"), "/srv");
|
|
assert_eq!(replace_root(r"C:\p\src/a.py C:\p2", r"C:\p", "/srv"), r"/srv/src/a.py C:\p2");
|
|
}
|
|
}
|