feat(remote): Reload the environment and apply devcontainer settings on the remote server (#183)

The remote server read the login shell's PATH once and cached it, had no project-level environment, and changed environment variables only took effect after reinstalling or a manual restart.

- Every openidea-server start re-reads the remote login shell's full environment (`$SHELL -ilc env -0`), with the cached PATH only as a fallback when the shell doesn't answer.
- A project's `.devcontainer/devcontainer.json` (or `.devcontainer.json`) applies on remote hosts: `remoteEnv` (with `${localEnv|containerEnv|env:X[:default]}`, workspace variables and `null` to unset) reaches every child process through one environment overlay (terminals, run configurations, language servers, debuggers, build tools, git), and `postStartCommand` (string, array or parallel object) runs once per server start in the project root as a cancellable background task; a failure raises a notification with the exit code and the last output lines without blocking the project.
- Reload Environment (`remote.reloadEnvironment`, File menu and Find Action) re-resolves the login shell and `remoteEnv` without restarting the server, starting from the process's original environment so prepended PATH entries don't pile up; it lists the changed variables, notes that running processes keep their environment, and offers to restart language servers.
- The JSONC reader moves to `ide_core::jsonc` and is shared with ide-run.

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
This commit is contained in:
l0ng-ai
2026-10-29 09:28:18 +08:00
committed by GitHub
parent b6a371559a
commit 1f9be73174
28 changed files with 1527 additions and 123 deletions
+90
View File
@@ -0,0 +1,90 @@
//! JSON with comments (`devcontainer.json`, `deno.jsonc`, `tsconfig.json`): turned
//! into plain JSON for `serde_json`.
/// Strips `//` and `/* */` comments and trailing commas so JSONC parses as JSON.
pub fn strip(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut chars = src.chars().peekable();
let mut in_str = false;
while let Some(c) = chars.next() {
if in_str {
out.push(c);
if c == '\\' {
if let Some(n) = chars.next() {
out.push(n);
}
} else if c == '"' {
in_str = false;
}
continue;
}
match c {
'"' => {
in_str = true;
out.push(c);
}
'/' if chars.peek() == Some(&'/') => {
for n in chars.by_ref() {
if n == '\n' {
out.push('\n');
break;
}
}
}
'/' if chars.peek() == Some(&'*') => {
chars.next();
let mut prev = '\0';
for n in chars.by_ref() {
if prev == '*' && n == '/' {
break;
}
prev = n;
}
}
_ => out.push(c),
}
}
// Remove trailing commas before `}` / `]` (outside strings).
let mut cleaned = String::with_capacity(out.len());
let bytes: Vec<char> = out.chars().collect();
let mut in_str = false;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if in_str {
cleaned.push(c);
if c == '\\' && i + 1 < bytes.len() {
cleaned.push(bytes[i + 1]);
i += 1;
} else if c == '"' {
in_str = false;
}
} else if c == '"' {
in_str = true;
cleaned.push(c);
} else if c == ',' {
let next = bytes[i + 1..].iter().find(|ch| !ch.is_whitespace());
if !matches!(next, Some('}') | Some(']')) {
cleaned.push(c);
}
} else {
cleaned.push(c);
}
i += 1;
}
cleaned
}
#[cfg(test)]
mod tests {
use super::strip;
#[test]
fn drops_comments_and_trailing_commas_outside_strings() {
let src = "{\n // line\n \"a\": [1, 2,], /* block */\n \"url\": \"https://x.dev//y /* no */\",\n \"q\": \"say \\\"hi\\\",\",\n}\n";
let v: serde_json::Value = serde_json::from_str(&strip(src)).unwrap();
assert_eq!(v["a"], serde_json::json!([1, 2]));
assert_eq!(v["url"], "https://x.dev//y /* no */");
assert_eq!(v["q"], "say \"hi\",");
}
}
+1
View File
@@ -11,6 +11,7 @@ pub mod ids;
pub mod ignored;
pub mod inspection_settings;
pub mod ipc_path;
pub mod jsonc;
pub mod process;
pub mod project_dir;
pub mod remote_settings;
+107 -25
View File
@@ -2,7 +2,7 @@
use std::ffi::{OsStr, OsString};
use std::process::Command;
use std::sync::OnceLock;
use std::sync::{Arc, RwLock};
/// Windows `CREATE_NO_WINDOW` creation flag, for call sites that combine it
/// with other flags (and then call [`host_env`] themselves). Prefer [`prepare`] otherwise.
@@ -13,7 +13,7 @@ pub const CREATE_NO_WINDOW: u32 = 0x0800_0000;
///
/// - Windows: release builds use the GUI subsystem, so without `CREATE_NO_WINDOW` every
/// console child flashes a `cmd` window.
/// - Linux AppImage: see [`host_env`].
/// - Linux AppImage, reloaded and project variables: see [`host_env`].
///
/// Variables the call site has already set are kept; call it before `env_clear`.
pub fn prepare(cmd: &mut Command) -> &mut Command {
@@ -30,10 +30,11 @@ pub fn prepare(cmd: &mut Command) -> &mut Command {
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
/// inherits them loads the image's (older) libraries and plugins next to its own, e.g.
/// `curl: symbol lookup error ... nghttp2_...`. The IDE process keeps its environment
/// (WebKit and GTK still need it). Variables the call site has already set are kept.
/// A no-op outside an AppImage.
/// (WebKit and GTK still need it). Then applies the [`set_env_overlay`] variables.
/// Variables the call site has already set are kept. A no-op outside an AppImage
/// without an overlay.
pub fn host_env(cmd: &mut Command) -> &mut Command {
let fixes = appimage_fixes();
let fixes = child_env_changes();
if fixes.is_empty() {
return cmd;
}
@@ -49,30 +50,54 @@ pub fn host_env(cmd: &mut Command) -> &mut Command {
/// The [`host_env`] changes as `(name, value)` pairs, `None` meaning "remove", for spawners
/// that are not a `std::process::Command` (a PTY `CommandBuilder`). Apply before the call
/// site's own variables. Empty outside an AppImage.
/// site's own variables. Empty outside an AppImage without an overlay.
pub fn host_env_fixes() -> Vec<(OsString, Option<OsString>)> {
appimage_fixes()
child_env_changes()
}
/// This process's environment as a child should inherit it (see [`host_env`]), for call
/// sites that build a child's environment from the IDE's own instead of inheriting it.
pub fn host_vars() -> impl Iterator<Item = (OsString, OsString)> {
let fixes = appimage_fixes();
std::env::vars_os().filter_map(move |(k, v)| match fixes.iter().find(|(f, _)| *f == k) {
Some((_, fixed)) => fixed.clone().map(|v| (k, v)),
None => Some((k, v)),
})
apply_changes(std::env::vars_os().collect(), &child_env_changes()).into_iter()
}
/// [`std::env::var_os`] as a child should see it (see [`host_env`]).
pub fn host_var_os(key: impl AsRef<OsStr>) -> Option<OsString> {
let key = key.as_ref();
match appimage_fixes().into_iter().find(|(k, _)| k == key) {
match child_env_changes().into_iter().find(|(k, _)| k == key) {
Some((_, fixed)) => fixed,
None => std::env::var_os(key),
}
}
/// `vars` with `changes` applied (`None` removes), new names appended.
fn apply_changes(
mut vars: Vec<(OsString, OsString)>,
changes: &[(OsString, Option<OsString>)],
) -> Vec<(OsString, OsString)> {
for (key, value) in changes {
let at = vars.iter().position(|(k, _)| k == key);
match (at, value) {
(Some(i), Some(v)) => vars[i].1 = v.clone(),
(Some(i), None) => {
vars.remove(i);
}
(None, Some(v)) => vars.push((key.clone(), v.clone())),
(None, None) => {}
}
}
vars
}
/// The AppImage fixes, then the overlay (which wins for a name both change); one entry per name.
fn child_env_changes() -> Vec<(OsString, Option<OsString>)> {
let mut changes = appimage_fixes();
let overlay = env_overlay();
changes.retain(|(k, _)| !overlay.iter().any(|(o, _)| o == k));
changes.extend(overlay.iter().cloned());
changes
}
/// Read on every call, not cached: startup rewrites `PATH` (login shell, managed tools).
fn appimage_fixes() -> Vec<(OsString, Option<OsString>)> {
if !cfg!(target_os = "linux") || std::env::var_os("APPDIR").is_none() {
@@ -120,23 +145,49 @@ fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<Os
fixes
}
static SHELL_ENV: OnceLock<Vec<(OsString, OsString)>> = OnceLock::new();
type Vars = Arc<Vec<(OsString, OsString)>>;
type Changes = Arc<Vec<(OsString, Option<OsString>)>>;
/// Records the login shell's environment, read once at startup off the main thread.
static SHELL_ENV: RwLock<Option<Vars>> = RwLock::new(None);
static OVERLAY: RwLock<Option<Changes>> = RwLock::new(None);
/// Records the login shell's environment: read at startup off the main thread, and again
/// by Reload Environment.
pub fn set_shell_env(vars: Vec<(OsString, OsString)>) {
let _ = SHELL_ENV.set(vars);
*SHELL_ENV.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(vars));
}
/// The login shell's environment as last read, all of it (`None` until it has been read).
pub fn login_env() -> Option<Vars> {
SHELL_ENV.read().unwrap_or_else(|e| e.into_inner()).clone()
}
/// The login shell's variables this process lacks: an app started from Finder/Dock gets only
/// PATH from the shell, not what rc files export (API keys and provider switches, SDK homes...).
/// Apply before a call site's own variables so those win. Empty until the shell has been read.
pub fn shell_env() -> impl Iterator<Item = (&'static OsStr, &'static OsStr)> {
SHELL_ENV
.get()
.into_iter()
.flatten()
.filter(|(k, _)| std::env::var_os(k).is_none())
.map(|(k, v)| (k.as_os_str(), v.as_os_str()))
/// Names the overlay sets are left out: [`host_env`] gives them their value.
pub fn shell_env() -> impl Iterator<Item = (OsString, OsString)> {
let overlay = env_overlay();
let vars = login_env().unwrap_or_default();
let lacking: Vec<_> = vars
.iter()
.filter(|(k, _)| std::env::var_os(k).is_none() && !overlay.iter().any(|(o, _)| o == k))
.cloned()
.collect();
lacking.into_iter()
}
/// Variables every child process gets on top of this process's environment (`None`: removed)
/// unless its call site sets them: an environment re-read after startup (this process's own
/// can't change safely once threads run) and the project's (`remoteEnv`). Replaces the
/// previous overlay; children already running keep what they started with.
pub fn set_env_overlay(changes: Vec<(OsString, Option<OsString>)>) {
*OVERLAY.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(changes));
}
/// The current [`set_env_overlay`] variables.
pub fn env_overlay() -> Changes {
OVERLAY.read().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_default()
}
#[cfg(test)]
@@ -144,10 +195,41 @@ mod tests {
use super::*;
#[test]
fn shell_env_adds_only_what_the_process_lacks() {
fn shell_env_adds_only_what_the_process_lacks_and_the_overlay_wins() {
set_shell_env(vec![("OPENIDEA_SHELL_ONLY_VAR".into(), "1".into()), ("PATH".into(), "/from/shell".into())]);
let vars: Vec<_> = shell_env().collect();
assert_eq!(vars, [(OsStr::new("OPENIDEA_SHELL_ONLY_VAR"), OsStr::new("1"))]);
assert_eq!(vars, [(OsString::from("OPENIDEA_SHELL_ONLY_VAR"), OsString::from("1"))]);
// The overlay (process-wide too, hence the same test).
set_shell_env(vec![("OPENIDEA_OVERLAY_A".into(), "shell".into()), ("OPENIDEA_OVERLAY_B".into(), "b".into())]);
set_env_overlay(vec![
("OPENIDEA_OVERLAY_A".into(), Some("project".into())),
("PATH".into(), Some("/reloaded".into())),
("HOME".into(), None),
]);
let mut cmd = Command::new("true");
cmd.env("PATH", "/custom");
host_env(&mut cmd);
let get = |k: &str| cmd.get_envs().find(|(n, _)| *n == k).map(|(_, v)| v.map(OsStr::to_owned));
assert_eq!(get("OPENIDEA_OVERLAY_A"), Some(Some("project".into())));
assert_eq!(get("PATH"), Some(Some("/custom".into())), "the call site's value wins");
assert_eq!(get("HOME"), Some(None));
let shell: Vec<_> = shell_env().map(|(k, _)| k).collect();
assert_eq!(shell, [OsString::from("OPENIDEA_OVERLAY_B")]);
assert_eq!(host_var_os("PATH"), Some("/reloaded".into()));
assert!(host_vars().all(|(k, _)| k != "HOME"));
assert!(host_vars().any(|(k, v)| k == "OPENIDEA_OVERLAY_A" && v == "project"));
assert_eq!(host_env_fixes().len(), env_overlay().len());
set_env_overlay(Vec::new());
}
#[test]
fn changes_apply_in_order() {
let vars = apply_changes(
env(&[("A", "1"), ("B", "2")]),
&[("A".into(), Some("3".into())), ("B".into(), None), ("C".into(), Some("4".into())), ("D".into(), None)],
);
assert_eq!(vars, env(&[("A", "3"), ("C", "4")]));
}
fn env(pairs: &[(&str, &str)]) -> Vec<(OsString, OsString)> {
+4 -6
View File
@@ -24,16 +24,13 @@ fn main() {
.init();
let home = dirs::home_dir().unwrap_or_default();
let base = std::env::var_os("OPENIDEA_SERVER_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".openidea"));
// An SSH exec channel gets a minimal PATH: take the login shell's, as the
// desktop app does, plus the IDE-managed tools. Before any other thread starts.
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path("openidea-server");
// An SSH exec channel gets a minimal environment: take the login shell's, read
// afresh on every start, plus the IDE-managed tools. Before any other thread starts.
ide_host::shell_env::inherit_login_shell_env("openidea-server");
let data_dir = base.join("data");
ide_tools::extend_process_path(&data_dir);
ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data_dir));
ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data_dir));
if let Some(refresh) = refresh_shell_path {
refresh();
}
let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build().expect("tokio runtime");
let code = runtime.block_on(run(base));
std::process::exit(code);
@@ -64,6 +61,7 @@ async fn run(base: PathBuf) -> i32 {
sink_for: Box::new(move |window| rpc::event_sink(sink_peer.clone(), Some(window.to_owned()))),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: true,
}));
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
rpc::serve(host.clone(), Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await;
+484
View File
@@ -0,0 +1,484 @@
//! The environment child processes get beyond this process's own: the login shell's as
//! re-read by Reload Environment, and (remote server) the project's
//! `devcontainer.json` `remoteEnv`, plus its `postStartCommand` once per server start.
//!
//! Both reach every child through `ide_core::process::set_env_overlay`: terminals, runs,
//! language servers, debug adapters, build tools and git all spawn through
//! `ide_core::process::prepare` / `host_env`.
use ide_core::event::NotifyLevel;
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink, TaskId};
use ide_workspace::devcontainer::{self, LifecycleCommand};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{BTreeSet, HashSet};
use std::ffi::{OsStr, OsString};
use std::io::{BufRead, BufReader, Read};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::time::Duration;
type Changes = Vec<(OsString, Option<OsString>)>;
/// What Reload Environment changed.
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct EnvReloaded {
/// Names of the variables new child processes now get differently (added, changed or removed).
pub changed: Vec<String>,
/// The `devcontainer.json` whose `remoteEnv` applies, if any.
pub devcontainer: Option<String>,
/// Whether the login shell was read (it isn't on Windows).
pub shell_read: bool,
}
#[derive(Default)]
struct State {
/// The login shell's environment when this process started (before the first reload).
baseline: Option<Vec<(OsString, OsString)>>,
/// The login shell's changes since this process started (Reload Environment).
login: Changes,
/// The open project's `remoteEnv`.
project: Changes,
/// Project roots whose `postStartCommand` ran in this process.
post_started: HashSet<PathBuf>,
}
pub struct Environment {
/// Whether the open project's `devcontainer.json` applies (the remote server: one project per process).
project_env: bool,
data_dir: PathBuf,
state: Mutex<State>,
/// Stops running `postStartCommand`s when the host shuts down.
cancel: CancellationToken,
}
impl Environment {
pub(crate) fn new(project_env: bool, data_dir: PathBuf) -> Self {
Self { project_env, data_dir, state: Mutex::default(), cancel: CancellationToken::new() }
}
pub(crate) fn shutdown(&self) {
self.cancel.cancel();
}
/// A project opened at `root`: applies its `remoteEnv` and returns its `postStartCommand`
/// when it hasn't run in this process yet. Problems with the file are notified, not errors.
pub(crate) fn project_opened(&self, root: &Path, sink: &SharedSink) -> Option<LifecycleCommand> {
if !self.project_env {
return None;
}
let config = self.load_project(root, sink);
let post_start = config.and_then(|c| c.post_start);
let first = self.state.lock().post_started.insert(root.to_path_buf());
post_start.filter(|_| first)
}
pub(crate) fn project_closed(&self) {
if self.project_env {
self.state.lock().project.clear();
self.publish();
}
}
/// Reads `root`'s `devcontainer.json` and applies its `remoteEnv` (none: clears it).
fn load_project(&self, root: &Path, sink: &SharedSink) -> Option<devcontainer::DevContainer> {
let config = match devcontainer::load(root) {
Ok(c) => c,
Err(e) => {
sink.emit(IdeEvent::Notify {
level: NotifyLevel::Warning,
title: "devcontainer.json was not applied".into(),
body: Some(e),
});
None
}
};
let login = self.state.lock().login.clone();
let project = config.as_ref().map(|c| project_changes(c, root, &login)).unwrap_or_default();
self.state.lock().project = project;
self.publish();
config
}
fn publish(&self) {
let state = self.state.lock();
ide_core::process::set_env_overlay(merge(&state.login, &state.project));
}
/// Reload Environment: reads the login shell again and re-applies the project's
/// `remoteEnv` (`root`). Blocking (it runs the shell); children already running keep
/// their environment.
pub fn reload(&self, root: Option<&Path>, sink: &SharedSink) -> IdeResult<EnvReloaded> {
let before: Vec<_> = ide_core::process::host_vars().collect();
let fresh = crate::shell_env::read_login_env_now().map_err(IdeError::unavailable)?;
let shell_read = fresh.is_some();
if let Some(fresh) = fresh {
let old = {
let mut state = self.state.lock();
let baseline = state
.baseline
.get_or_insert_with(|| ide_core::process::login_env().map(|v| v.to_vec()).unwrap_or_default());
baseline.clone()
};
let login = login_changes(&|k| std::env::var_os(k), &old, &fresh, &|path| {
ide_tools::path_with_tools(&self.data_dir, path)
});
ide_core::process::set_shell_env(fresh);
self.state.lock().login = login;
}
let devcontainer = match root.filter(|_| self.project_env) {
Some(root) => self.load_project(root, sink).map(|c| c.path.to_string_lossy().into_owned()),
None => {
self.publish();
None
}
};
let after: Vec<_> = ide_core::process::host_vars().collect();
Ok(EnvReloaded { changed: changed_names(&before, &after), devcontainer, shell_read })
}
/// Runs `cmd` (a `postStartCommand`) in `root` on its own thread, as a background task;
/// a failure is a notification.
pub(crate) fn spawn_post_start(&self, cmd: LifecycleCommand, root: PathBuf, sink: SharedSink) {
let cancel = self.cancel.child_token();
let spawned = std::thread::Builder::new().name("openidea-post-start".into()).spawn(move || {
run_post_start(&cmd, &root, &sink, &cancel);
});
if let Err(e) = spawned {
tracing::warn!("postStartCommand: {e}");
}
}
}
/// `project` after `login`; one entry per name, the later one winning.
fn merge(login: &Changes, project: &Changes) -> Changes {
let mut out: Changes = login.iter().filter(|(k, _)| !project.iter().any(|(p, _)| p == k)).cloned().collect();
out.extend(project.iter().cloned());
out
}
/// What children need on top of this process's environment (`process`) to see the login
/// shell's `fresh` one: changed and new variables, and those `old` (the shell at startup)
/// had that it no longer sets, when this process took them from it. `PATH` gets the
/// managed tool folders (`with_tools`) as at startup.
fn login_changes(
process: &dyn Fn(&OsStr) -> Option<OsString>,
old: &[(OsString, OsString)],
fresh: &[(OsString, OsString)],
with_tools: &dyn Fn(&OsStr) -> OsString,
) -> Changes {
let effective = |k: &OsStr, v: &OsStr| if k == "PATH" { with_tools(v) } else { v.to_owned() };
let mut out = Changes::new();
for (k, v) in fresh {
let v = effective(k, v);
if process(k).as_ref() != Some(&v) {
out.push((k.clone(), Some(v)));
}
}
for (k, v) in old {
if !fresh.iter().any(|(f, _)| f == k) && process(k) == Some(effective(k, v)) {
out.push((k.clone(), None));
}
}
out
}
/// `remoteEnv` resolved against the environment before it (this process's plus `login`).
fn project_changes(config: &devcontainer::DevContainer, root: &Path, login: &Changes) -> Changes {
let lookup = |name: &str| -> Option<String> {
match login.iter().find(|(k, _)| k == name) {
Some((_, v)) => v.as_ref().map(|v| v.to_string_lossy().into_owned()),
None => std::env::var_os(name).map(|v| v.to_string_lossy().into_owned()),
}
};
devcontainer::resolve_env(config, root, &lookup).into_iter().map(|(k, v)| (k.into(), v.map(Into::into))).collect()
}
fn changed_names(before: &[(OsString, OsString)], after: &[(OsString, OsString)]) -> Vec<String> {
let get = |vars: &[(OsString, OsString)], k: &OsString| vars.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
let names: BTreeSet<&OsString> = before.iter().chain(after).map(|(k, _)| k).collect();
names.into_iter().filter(|k| get(before, k) != get(after, k)).map(|k| k.to_string_lossy().into_owned()).collect()
}
/// Output lines kept for the failure notification.
const TAIL_LINES: usize = 20;
const TITLE: &str = "postStartCommand";
/// Runs a `postStartCommand` to the end (each of an object's commands in parallel),
/// reporting progress as a background task and a failure as an error notification.
fn run_post_start(cmd: &LifecycleCommand, root: &Path, sink: &SharedSink, cancel: &CancellationToken) {
let task = TaskId::next();
let _cancel_handle = ide_core::task::on_cancel(task, {
let cancel = cancel.clone();
move || cancel.cancel()
});
let progress = |message: Option<String>, done: bool| {
sink.emit(IdeEvent::Task { id: task, title: TITLE.into(), message, fraction: None, done, cancellable: !done });
};
progress(Some("Starting…".into()), false);
let tail = Arc::new(Mutex::new(Vec::<String>::new()));
let on_line = |name: Option<&str>, line: String| {
tracing::info!(target: "post_start", "{}{line}", name.map(|n| format!("[{n}] ")).unwrap_or_default());
progress(Some(line.clone()), false);
let mut tail = tail.lock();
tail.push(match name {
Some(n) => format!("[{n}] {line}"),
None => line,
});
let excess = tail.len().saturating_sub(TAIL_LINES);
tail.drain(..excess);
};
let failures: Vec<String> = std::thread::scope(|scope| {
let runs: Vec<_> = cmd
.commands()
.into_iter()
.map(|(name, c)| {
let on_line = &on_line;
scope.spawn(move || {
let argv = c.argv().unwrap_or_default();
run_one(&argv, root, cancel, &|line| on_line(name, line))
.err()
.map(|e| name.map_or_else(|| e.clone(), |n| format!("{n}: {e}")))
})
})
.collect();
runs.into_iter().filter_map(|r| r.join().unwrap_or_else(|_| Some("panicked".into()))).collect()
});
progress(None, true);
if failures.is_empty() || cancel.is_cancelled() {
return;
}
let tail = tail.lock().join("\n");
let body = if tail.is_empty() { failures.join("\n") } else { format!("{}\n\n{tail}", failures.join("\n")) };
sink.emit(IdeEvent::Notify {
level: NotifyLevel::Error,
title: "postStartCommand failed".into(),
body: Some(body),
});
}
/// Runs `argv` in `root` with the children's environment, its stdout and stderr line by line
/// to `on_line`; `Err` says how it failed.
fn run_one(argv: &[String], root: &Path, cancel: &CancellationToken, on_line: &dyn Fn(String)) -> Result<(), String> {
let (program, args) = argv.split_first().ok_or("empty command")?;
let mut child = ide_core::process::prepare(&mut Command::new(program))
.args(args)
.current_dir(root)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("cannot start {program}: {e}"))?;
let (tx, rx) = std::sync::mpsc::channel::<String>();
let pipes: [Option<Box<dyn Read + Send>>; 2] = [
child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>),
child.stderr.take().map(|p| Box::new(p) as Box<dyn Read + Send>),
];
for pipe in pipes.into_iter().flatten() {
let tx = tx.clone();
std::thread::spawn(move || {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
if tx.send(line).is_err() {
break;
}
}
});
}
drop(tx);
let status = loop {
match rx.recv_timeout(Duration::from_millis(50)) {
Ok(line) => on_line(line),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break child.wait(),
}
if cancel.is_cancelled() {
let _ = child.kill();
let _ = child.wait();
return Err("cancelled".into());
}
// A process it started in the background may keep the pipes open: the command is done.
if let Ok(Some(status)) = child.try_wait() {
let deadline = std::time::Instant::now() + Duration::from_secs(1);
while let Ok(line) = rx.recv_timeout(Duration::from_millis(100)) {
on_line(line);
if std::time::Instant::now() > deadline {
break;
}
}
break Ok(status);
}
}
.map_err(|e| e.to_string())?;
match status.code() {
Some(0) => Ok(()),
Some(code) => Err(format!("exited with code {code}")),
None => Err("terminated by a signal".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::EventSink;
fn vars(pairs: &[(&str, &str)]) -> Vec<(OsString, OsString)> {
pairs.iter().map(|(k, v)| (OsString::from(k), OsString::from(v))).collect()
}
fn change(k: &str, v: Option<&str>) -> (OsString, Option<OsString>) {
(k.into(), v.map(Into::into))
}
#[test]
fn reload_changes_are_relative_to_this_process() {
// This process took the login shell's environment at startup (the server), PATH with tools.
let process = vars(&[("PATH", "/tools:/a"), ("KEEP", "1"), ("EDITED", "old"), ("DROPPED", "x"), ("SSH", "s")]);
let old = vars(&[("PATH", "/a"), ("KEEP", "1"), ("EDITED", "old"), ("DROPPED", "x")]);
let fresh = vars(&[("PATH", "/b:/a"), ("KEEP", "1"), ("EDITED", "new"), ("ADDED", "y")]);
let lookup = |k: &OsStr| process.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
let with_tools = |p: &OsStr| {
let mut s = OsString::from("/tools:");
s.push(p);
s
};
let got = login_changes(&lookup, &old, &fresh, &with_tools);
assert_eq!(
got,
[
change("PATH", Some("/tools:/b:/a")),
change("EDITED", Some("new")),
change("ADDED", Some("y")),
change("DROPPED", None),
]
);
// Nothing changed: nothing to apply.
assert!(login_changes(&lookup, &old, &old, &with_tools).is_empty());
}
#[test]
fn variables_the_process_had_before_the_shell_stay() {
// The desktop app: the shell had `FROM_LAUNCHD` with another value than the process.
let process = vars(&[("FROM_LAUNCHD", "process")]);
let lookup = |k: &OsStr| process.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
let got = login_changes(&lookup, &vars(&[("FROM_LAUNCHD", "shell")]), &[], &|p| p.to_owned());
assert!(got.is_empty(), "{got:?}");
}
#[test]
fn project_variables_win_and_see_the_reloaded_login_values() {
let login = vec![change("OPENIDEA_TEST_TOOLS", Some("/reloaded")), change("A", Some("login"))];
let config = devcontainer::parse(
r#"{ "remoteEnv": { "A": "project", "B": "${containerEnv:OPENIDEA_TEST_TOOLS}/bin" } }"#,
)
.unwrap();
let project = project_changes(&config, Path::new("/p"), &login);
assert_eq!(project, [change("A", Some("project")), change("B", Some("/reloaded/bin"))]);
let merged = merge(&login, &project);
assert_eq!(
merged,
[
change("OPENIDEA_TEST_TOOLS", Some("/reloaded")),
change("A", Some("project")),
change("B", Some("/reloaded/bin"))
]
);
}
#[test]
fn changed_names_cover_added_edited_and_removed() {
let before = vars(&[("A", "1"), ("B", "2"), ("C", "3")]);
let after = vars(&[("A", "1"), ("B", "two"), ("D", "4")]);
assert_eq!(changed_names(&before, &after), ["B", "C", "D"]);
}
#[derive(Default)]
struct Events(Mutex<Vec<IdeEvent>>);
impl EventSink for Events {
fn emit(&self, e: IdeEvent) {
self.0.lock().push(e);
}
}
fn run(cmd: LifecycleCommand, root: &Path) -> Vec<IdeEvent> {
let events = Arc::new(Events::default());
let sink: SharedSink = events.clone();
run_post_start(&cmd, root, &sink, &CancellationToken::new());
events.0.lock().clone()
}
fn failure(events: &[IdeEvent]) -> Option<String> {
events.iter().find_map(|e| match e {
IdeEvent::Notify { level: NotifyLevel::Error, body, .. } => body.clone(),
_ => None,
})
}
#[cfg(unix)]
#[test]
fn post_start_forms_run_in_the_project_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let events = run(LifecycleCommand::Shell("pwd > shell.txt; echo done".into()), root);
assert!(failure(&events).is_none(), "{events:?}");
let pwd = std::fs::read_to_string(root.join("shell.txt")).unwrap();
assert_eq!(dunce::canonicalize(pwd.trim()).unwrap(), dunce::canonicalize(root).unwrap());
let messages: Vec<_> = events
.iter()
.filter_map(|e| match e {
IdeEvent::Task { message, done, .. } => Some((message.clone(), *done)),
_ => None,
})
.collect();
assert!(messages.contains(&(Some("done".into()), false)), "{messages:?}");
assert_eq!(messages.last(), Some(&(None, true)));
let exec = LifecycleCommand::Exec(vec!["touch".into(), "exec file.txt".into()]);
assert!(failure(&run(exec, root)).is_none());
assert!(root.join("exec file.txt").is_file(), "array form: no shell splitting");
let parallel = LifecycleCommand::Parallel(vec![
("a".into(), LifecycleCommand::Shell("touch a.txt".into())),
("b".into(), LifecycleCommand::Exec(vec!["touch".into(), "b.txt".into()])),
]);
assert!(failure(&run(parallel, root)).is_none());
assert!(root.join("a.txt").is_file() && root.join("b.txt").is_file());
}
#[cfg(unix)]
#[test]
fn a_failing_post_start_is_notified_with_its_output() {
let tmp = tempfile::tempdir().unwrap();
let events = run(LifecycleCommand::Shell("echo starting; echo broken >&2; exit 3".into()), tmp.path());
let body = failure(&events).expect("a failure notification");
assert!(body.contains("exited with code 3"), "{body}");
assert!(body.contains("starting") && body.contains("broken"), "{body}");
assert!(matches!(events.last(), Some(IdeEvent::Notify { .. })));
let parallel = LifecycleCommand::Parallel(vec![
("ok".into(), LifecycleCommand::Shell("true".into())),
("missing".into(), LifecycleCommand::Exec(vec!["openidea-no-such-program".into()])),
]);
let body = failure(&run(parallel, tmp.path())).unwrap();
assert!(body.starts_with("missing: cannot start openidea-no-such-program"), "{body}");
}
#[cfg(unix)]
#[test]
fn cancelling_stops_a_running_post_start() {
let tmp = tempfile::tempdir().unwrap();
let cancel = CancellationToken::new();
let started = std::time::Instant::now();
let c = cancel.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
c.cancel();
});
let result = run_one(&["sleep".into(), "30".into()], tmp.path(), &cancel, &|_| {});
assert_eq!(result, Err("cancelled".into()));
assert!(started.elapsed() < Duration::from_secs(10));
}
}
+13 -1
View File
@@ -3,6 +3,7 @@
//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server`
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
pub mod environment;
pub mod ops;
mod project;
mod requests;
@@ -34,6 +35,9 @@ pub struct HostConfig {
/// The window that owns what `window` starts (e.g. a settings window's parent).
pub owner: fn(&str) -> &str,
pub runtime: tokio::runtime::Handle,
/// Apply the open project's `devcontainer.json` (`remoteEnv`, `postStartCommand`) to
/// this process's children: the remote server, which has one project.
pub project_environment: bool,
}
pub struct Host {
@@ -50,6 +54,8 @@ pub struct Host {
pub ssh: Arc<ide_ssh::SshManager>,
/// SFTP deployment, remote file editing and runs on run targets.
pub remote: Arc<ide_remote::RemoteService>,
/// What child processes get beyond this process's environment (Reload Environment, `remoteEnv`).
pub environment: environment::Environment,
pub(crate) sink_for: SinkFactory,
owner: fn(&str) -> &str,
pub runtime: tokio::runtime::Handle,
@@ -60,13 +66,15 @@ pub struct Host {
impl Host {
pub fn new(config: HostConfig) -> Self {
let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime } = config;
let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime, project_environment } =
config;
let global_settings = SettingsStore::load(&config_dir, None);
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
Self {
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
ssh,
environment: environment::Environment::new(project_environment, data_dir.clone()),
global_settings,
// Sessions, runs and terminals report to the window that started them (`window_sink`).
terminals: TerminalManager::new(),
@@ -119,6 +127,9 @@ impl Host {
pub fn close_project(&self, window: &str) {
if let Some(project) = self.projects.write().remove(window) {
if !project.light {
self.environment.project_closed();
}
project.agents.shutdown();
let lsp = project.lsp.clone();
self.runtime.spawn(async move { lsp.shutdown_all().await });
@@ -128,6 +139,7 @@ impl Host {
/// Stops what this host started: language servers, agents, runs and terminals.
/// For a process that exits without dropping the host (the remote server).
pub async fn shutdown(&self) {
self.environment.shutdown();
self.processes.kill_all();
self.terminals.kill_all();
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
+20
View File
@@ -0,0 +1,20 @@
//! Reload Environment: new child processes get the login shell's current environment
//! (and, on the remote server, the project's `remoteEnv`) without a restart.
use crate::Host;
use crate::environment::EnvReloaded;
use crate::rpc::Dispatcher;
use ide_core::IdeResult;
use std::sync::Arc;
/// Re-reads the login shell (and the window's project `remoteEnv` where it applies);
/// running terminals and processes keep their environment.
pub async fn reload(host: &Arc<Host>, window: &str) -> IdeResult<EnvReloaded> {
let root = host.project(window).ok().filter(|p| !p.light).map(|p| p.root.clone());
let sink = host.window_sink(window);
let h = host.clone();
host.blocking(move || h.environment.reload(root.as_deref(), &sink)).await
}
pub(crate) fn register(d: &mut Dispatcher) {
d.add("env_reload", |c| async move { reload(&c.host, &c.window).await });
}
+2
View File
@@ -9,6 +9,7 @@ pub mod bytes;
pub mod coverage;
pub mod debug;
pub mod doc;
pub mod env;
pub mod fs;
pub mod history;
pub mod java;
@@ -75,6 +76,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
doc::register(d);
search::register(d);
run::register(d);
env::register(d);
}
impl Host {
+5
View File
@@ -75,6 +75,8 @@ impl Host {
}
self.close_project(window);
let sink: SharedSink = (self.sink_for)(window);
// Before anything the project starts: its children get the project's environment.
let post_start = self.environment.project_opened(&root, &sink);
let settings = SettingsStore::load(&self.config_dir, Some(&root));
let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root)));
let excludes = exclude_globs(&settings.effective(), &detected_roots.read());
@@ -311,6 +313,9 @@ impl Host {
}));
}
self.insert_project(window, project.clone());
if let Some(cmd) = post_start {
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
}
Ok(project)
}
+153 -9
View File
@@ -3,12 +3,16 @@
//! on macOS/Linux we take PATH from the login shell at startup.
//!
//! Spawning an interactive login shell costs ~1 s with typical rc files, so the
//! value is cached: a warm start uses the cached PATH immediately and refreshes
//! the cache in the background for the next launch.
//! desktop app caches the value: a warm start uses the cached PATH immediately
//! and refreshes the cache in the background for the next launch.
//!
//! The rest of the shell's environment (API keys, provider switches, SDK homes)
//! goes to child processes through `ide_core::process::shell_env`. It stays in
//! memory, never in the cache: it may hold secrets.
//!
//! The remote server reads the whole environment afresh on every start
//! ([`inherit_login_shell_env`]), and Reload Environment reads it again while
//! running ([`read_login_env_now`], applied by [`crate::environment`]).
#[cfg(unix)]
use std::ffi::{OsStr, OsString};
@@ -22,14 +26,15 @@ pub fn inherit_login_shell_path(identifier: &str) -> Option<impl FnOnce() + use<
if std::env::var_os("OPENIDEA_SKIP_SHELL_ENV").is_some() {
return None;
}
let cache = dirs::cache_dir().map(|d| d.join(identifier).join("login-path"));
record_startup_env();
let cache = path_cache(identifier);
let cached = cache.as_ref().and_then(|c| std::fs::read_to_string(c).ok()).filter(|p| !p.trim().is_empty());
let refresh = cached.is_some();
let path = match cached {
Some(path) => path,
None => {
// Slow shell rc files must not delay the first start indefinitely.
let env = read_login_env(Duration::from_secs(3))?;
let env = read_login_env(&login_shell(), Duration::from_secs(3))?;
let fresh = login_path(&env)?;
ide_core::process::set_shell_env(env);
if let Some(cache) = &cache {
@@ -43,7 +48,7 @@ pub fn inherit_login_shell_path(identifier: &str) -> Option<impl FnOnce() + use<
let cache = cache.filter(|_| refresh)?;
Some(move || {
std::thread::spawn(move || {
let Some(env) = read_login_env(Duration::from_secs(10)) else { return };
let Some(env) = read_login_env(&login_shell(), Duration::from_secs(10)) else { return };
if let Some(fresh) = login_path(&env) {
write_cache(&cache, &fresh);
}
@@ -52,6 +57,82 @@ pub fn inherit_login_shell_path(identifier: &str) -> Option<impl FnOnce() + use<
})
}
/// This process's environment before it took anything from the login shell: what a
/// shell read later starts from.
#[cfg(unix)]
static STARTUP_ENV: std::sync::OnceLock<Vec<(OsString, OsString)>> = std::sync::OnceLock::new();
#[cfg(unix)]
fn record_startup_env() {
let _ = STARTUP_ENV.set(std::env::vars_os().collect());
}
/// How long the server waits for the login shell before it falls back to the cached PATH.
#[cfg(unix)]
const SERVER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// The remote server's start: this process takes the login shell's whole environment,
/// read now (an SSH exec channel gets a minimal one and no rc files), so every child
/// inherits it and a changed rc file applies on the next start. Falls back to the PATH
/// cached by the last start when the shell can't be read in time.
///
/// Call at startup before any other thread reads the environment.
#[cfg(unix)]
pub fn inherit_login_shell_env(identifier: &str) {
if std::env::var_os("OPENIDEA_SKIP_SHELL_ENV").is_some() {
return;
}
record_startup_env();
let cache = path_cache(identifier);
match read_login_env(&login_shell(), SERVER_READ_TIMEOUT) {
Some(env) => {
for (k, v) in &env {
// SAFETY: documented to run at startup before any other thread reads the environment.
unsafe { std::env::set_var(k, v) };
}
if let (Some(cache), Some(path)) = (&cache, login_path(&env)) {
write_cache(cache, &path);
}
ide_core::process::set_shell_env(env);
}
None => {
let cached = cache.and_then(|c| std::fs::read_to_string(c).ok()).filter(|p| !p.trim().is_empty());
if let Some(path) = cached {
// SAFETY: as above.
unsafe { std::env::set_var("PATH", path.trim()) };
}
}
}
}
/// The login shell's environment, read now (Reload Environment). `Ok(None)` where the
/// IDE doesn't read it (Windows, `OPENIDEA_SKIP_SHELL_ENV`).
#[cfg(unix)]
pub fn read_login_env_now() -> Result<Option<Vec<(OsString, OsString)>>, String> {
if std::env::var_os("OPENIDEA_SKIP_SHELL_ENV").is_some() {
return Ok(None);
}
let shell = login_shell();
read_login_env(&shell, SERVER_READ_TIMEOUT)
.map(Some)
.ok_or_else(|| format!("could not read the environment of the login shell ({shell})"))
}
#[cfg(not(unix))]
pub fn read_login_env_now() -> Result<Option<Vec<(std::ffi::OsString, std::ffi::OsString)>>, String> {
Ok(None)
}
#[cfg(unix)]
fn login_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".into())
}
#[cfg(unix)]
fn path_cache(identifier: &str) -> Option<std::path::PathBuf> {
dirs::cache_dir().map(|d| d.join(identifier).join("login-path"))
}
#[cfg(unix)]
fn write_cache(cache: &std::path::Path, path: &str) {
if let Some(dir) = cache.parent() {
@@ -69,17 +150,35 @@ fn login_path(env: &[(OsString, OsString)]) -> Option<String> {
#[cfg(unix)]
const SHELL_OWN: &[&str] = &["PWD", "OLDPWD", "SHLVL", "_"];
/// The login shell's environment; PATH included, the shell's own bookkeeping left out.
/// The environment of `shell` started as an interactive login shell; PATH included, the
/// shell's own bookkeeping left out.
#[cfg(unix)]
fn read_login_env(timeout: std::time::Duration) -> Option<Vec<(OsString, OsString)>> {
pub(crate) fn read_login_env(shell: &str, timeout: std::time::Duration) -> Option<Vec<(OsString, OsString)>> {
use std::io::Read;
use std::os::unix::ffi::OsStrExt;
use std::process::{Command, Stdio};
use std::time::Instant;
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".into());
const MARK: &str = "__OPENIDEA_ENV__";
let mut child = ide_core::process::prepare(&mut Command::new(&shell))
let mut cmd = Command::new(shell);
// As at startup: what this process took from the shell (and what a reload added) must
// not go in again, or rc files that append to PATH would append twice and a variable
// they no longer export would come back.
if let Some(startup) = STARTUP_ENV.get() {
let now: Vec<OsString> = std::env::vars_os().map(|(k, _)| k).collect();
let overlay = ide_core::process::env_overlay();
for k in now.iter().chain(overlay.iter().map(|(k, _)| k)) {
let before = startup.iter().find(|(n, _)| n == k).map(|(_, v)| v);
if overlay.iter().any(|(o, _)| o == k) || std::env::var_os(k).as_ref() != before {
match before {
Some(v) => cmd.env(k, v),
None => cmd.env_remove(k),
};
}
}
}
ide_core::process::prepare(&mut cmd);
let mut child = cmd
.args(["-ilc", &format!("printf '{MARK}'; /usr/bin/env -0; printf '{MARK}'")])
.stdin(Stdio::null())
.stdout(Stdio::piped())
@@ -123,3 +222,48 @@ fn read_login_env(timeout: std::time::Duration) -> Option<Vec<(OsString, OsStrin
pub fn inherit_login_shell_path(_identifier: &str) -> Option<fn()> {
None
}
#[cfg(not(unix))]
pub fn inherit_login_shell_env(_identifier: &str) {}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
use std::time::Duration;
/// A stand-in login shell: runs the IDE's `-ilc` script after exporting what `rc` holds.
fn fake_shell(dir: &std::path::Path) -> (String, std::path::PathBuf) {
let rc = dir.join("rc");
let shell = dir.join("shell");
std::fs::write(&shell, format!("#!/bin/sh\n. {}\nshift\nexec /bin/sh -c \"$1\"\n", rc.display())).unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
(shell.to_string_lossy().into_owned(), rc)
}
#[test]
fn every_read_sees_the_current_rc_file() {
let tmp = tempfile::tempdir().unwrap();
let (shell, rc) = fake_shell(tmp.path());
let get = |env: &[(OsString, OsString)], k: &str| {
env.iter().find(|(n, _)| n == k).map(|(_, v)| v.to_string_lossy().into_owned())
};
std::fs::write(&rc, "export OPENIDEA_TEST_RC=one\nexport PATH=/one/bin:$PATH\n").unwrap();
let first = read_login_env(&shell, Duration::from_secs(10)).unwrap();
assert_eq!(get(&first, "OPENIDEA_TEST_RC").as_deref(), Some("one"));
assert!(get(&first, "PATH").unwrap().starts_with("/one/bin:"));
assert!(get(&first, "PWD").is_none() && get(&first, "SHLVL").is_none());
std::fs::write(&rc, "export OPENIDEA_TEST_RC='two words'\n").unwrap();
let second = read_login_env(&shell, Duration::from_secs(10)).unwrap();
assert_eq!(get(&second, "OPENIDEA_TEST_RC").as_deref(), Some("two words"));
}
#[test]
fn a_shell_that_hangs_times_out() {
let tmp = tempfile::tempdir().unwrap();
let (shell, rc) = fake_shell(tmp.path());
std::fs::write(&rc, "sleep 5\n").unwrap();
assert!(read_login_env(&shell, Duration::from_millis(200)).is_none());
}
}
+1
View File
@@ -14,6 +14,7 @@ fn host(base: &std::path::Path) -> Arc<Host> {
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
+178
View File
@@ -0,0 +1,178 @@
//! The remote server's environment: the login shell read afresh on every start, the
//! project's `devcontainer.json` (`remoteEnv`, `postStartCommand`) and Reload Environment.
//! One test in its own binary: it changes this process's environment.
#![cfg(unix)]
use ide_core::event::NotifyLevel;
use ide_core::{EventSink, IdeEvent, SharedSink};
use ide_host::{Host, HostConfig};
use parking_lot::Mutex;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use std::time::{Duration, Instant};
#[derive(Default)]
struct Events(Mutex<Vec<IdeEvent>>);
impl EventSink for Events {
fn emit(&self, e: IdeEvent) {
self.0.lock().push(e);
}
}
/// What a child process started now sees for `name`.
fn child_var(name: &str) -> String {
let out = ide_core::process::prepare(&mut Command::new("/bin/sh"))
.args(["-c", &format!("printf %s \"${name}\"")])
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap()
}
fn wait_for(what: &str, mut done: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(20);
while !done() {
assert!(Instant::now() < deadline, "timed out waiting for {what}");
std::thread::sleep(Duration::from_millis(20));
}
}
fn write(path: &Path, text: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
#[test]
fn server_environment_from_login_shell_and_devcontainer() {
let tmp = tempfile::tempdir().unwrap();
let tmp = dunce::canonicalize(tmp.path()).unwrap();
// A stand-in login shell that sources `rc`, as an rc file would be.
let rc = tmp.join("rc");
let shell = tmp.join("shell");
write(&shell, &format!("#!/bin/sh\n. {}\nshift\nexec /bin/sh -c \"$1\"\n", rc.display()));
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
// SAFETY: no other thread runs yet.
unsafe {
std::env::set_var("SHELL", &shell);
// The PATH cache goes under the temporary home.
std::env::set_var("HOME", tmp.join("home"));
std::env::set_var("XDG_CACHE_HOME", tmp.join("home/.cache"));
std::env::remove_var("OPENIDEA_SKIP_SHELL_ENV");
}
// Two server starts: each reads the login shell again (not the cached PATH).
write(&rc, "export OPENIDEA_RC_VAR=one\nexport PATH=/start-one/bin:/usr/bin:/bin\n");
ide_host::shell_env::inherit_login_shell_env("openidea-server-test");
assert_eq!(std::env::var("OPENIDEA_RC_VAR").unwrap(), "one");
write(&rc, "export OPENIDEA_RC_VAR=two\nexport PATH=/start-two/bin:/usr/bin:/bin\n");
ide_host::shell_env::inherit_login_shell_env("openidea-server-test");
assert_eq!(std::env::var("OPENIDEA_RC_VAR").unwrap(), "two");
assert!(std::env::var("PATH").unwrap().starts_with("/start-two/bin:"), "{:?}", std::env::var("PATH"));
let base = tmp.join("server");
ide_tools::extend_process_path(&base.join("data"));
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
let events = Arc::new(Events::default());
let sink_events = events.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| sink_events.clone() as SharedSink),
owner: |w| w,
runtime: runtime.handle().clone(),
project_environment: true,
}));
assert!(ide_host::rpc::Dispatcher::standard().commands().contains(&"env_reload"));
let project = tmp.join("demo");
write(
&project.join(".devcontainer/devcontainer.json"),
r#"{
// remoteEnv sees the login shell's variables
"remoteEnv": {
"FROM_PROJECT": "${containerEnv:OPENIDEA_RC_VAR}-${localWorkspaceFolderBasename}",
"PATH": "/project/bin:${containerEnv:PATH}",
},
"postStartCommand": ["/bin/sh", "-c", "echo \"$FROM_PROJECT\" >> started.txt; echo post-start-output"],
}"#,
);
runtime.block_on(ide_host::ops::project::open(&host, "main", &project)).unwrap();
assert_eq!(child_var("FROM_PROJECT"), "two-demo");
assert!(child_var("PATH").starts_with("/project/bin:"), "{}", child_var("PATH"));
assert_eq!(child_var("OPENIDEA_RC_VAR"), "two");
// PTY spawners (terminals, runs in a terminal) take the same variables.
assert!(ide_core::process::host_env_fixes().iter().any(|(k, v)| k == "FROM_PROJECT" && v.is_some()));
let started = project.join("started.txt");
wait_for("postStartCommand", || std::fs::read_to_string(&started).is_ok_and(|s| s.ends_with('\n')));
assert_eq!(std::fs::read_to_string(&started).unwrap(), "two-demo\n");
wait_for("the task to end", || events.0.lock().iter().any(|e| matches!(e, IdeEvent::Task { done: true, .. })));
assert!(events.0.lock().iter().any(
|e| matches!(e, IdeEvent::Task { message: Some(m), title, .. } if m == "post-start-output" && title == "postStartCommand")
));
// Reload Environment: the edited rc file and the project file apply to new children.
write(&rc, "export OPENIDEA_RC_VAR=three\nexport OPENIDEA_RC_NEW=new\nexport PATH=/start-two/bin:/usr/bin:/bin\n");
let reloaded = runtime.block_on(ide_host::ops::env::reload(&host, "main")).unwrap();
assert!(reloaded.shell_read);
for name in ["FROM_PROJECT", "OPENIDEA_RC_NEW", "OPENIDEA_RC_VAR"] {
assert!(reloaded.changed.iter().any(|c| c == name), "{name} in {:?}", reloaded.changed);
}
assert!(!reloaded.changed.iter().any(|c| c == "PATH"), "{:?}", reloaded.changed);
assert!(reloaded.devcontainer.as_deref().is_some_and(|p| p.ends_with("devcontainer.json")));
assert_eq!(child_var("OPENIDEA_RC_VAR"), "three");
assert_eq!(child_var("OPENIDEA_RC_NEW"), "new");
assert_eq!(child_var("FROM_PROJECT"), "three-demo");
// This process keeps its own environment; only children change.
assert_eq!(std::env::var("OPENIDEA_RC_VAR").unwrap(), "two");
// A variable the rc file stops exporting goes away for new children, whether this
// process started with it or a reload added it.
write(&rc, "export PATH=/start-two/bin:/usr/bin:/bin\n");
let reloaded = runtime.block_on(ide_host::ops::env::reload(&host, "main")).unwrap();
for name in ["FROM_PROJECT", "OPENIDEA_RC_NEW", "OPENIDEA_RC_VAR"] {
assert!(reloaded.changed.iter().any(|c| c == name), "{name} in {:?}", reloaded.changed);
}
assert_eq!(child_var("OPENIDEA_RC_NEW"), "");
assert_eq!(child_var("OPENIDEA_RC_VAR"), "");
assert_eq!(child_var("FROM_PROJECT"), "-demo");
// The shell starts from the environment this process started with: an rc file that
// prepends to PATH does so once, however often it is reloaded.
write(&rc, "export PATH=/prepended/bin:$PATH\n");
let first = runtime.block_on(ide_host::ops::env::reload(&host, "main")).unwrap();
assert!(first.changed.iter().any(|c| c == "PATH"), "{:?}", first.changed);
let again = runtime.block_on(ide_host::ops::env::reload(&host, "main")).unwrap();
assert!(again.changed.is_empty(), "{:?}", again.changed);
assert_eq!(child_var("PATH").matches("/prepended/bin").count(), 1, "{}", child_var("PATH"));
// Reopening the project doesn't run postStartCommand again (once per server start).
host.close_project("main");
assert_eq!(child_var("FROM_PROJECT"), "", "remoteEnv leaves with the project");
runtime.block_on(ide_host::ops::project::open(&host, "main", &project)).unwrap();
std::thread::sleep(Duration::from_millis(500));
assert_eq!(std::fs::read_to_string(&started).unwrap(), "two-demo\n");
// A failing postStartCommand is a notification; the project opens regardless.
let failing = tmp.join("failing");
write(&failing.join(".devcontainer.json"), r#"{ "postStartCommand": "echo oops >&2; exit 7" }"#);
runtime.block_on(ide_host::ops::project::open(&host, "main", &failing)).unwrap();
wait_for("the failure notification", || {
events.0.lock().iter().any(|e| {
matches!(e, IdeEvent::Notify { level: NotifyLevel::Error, title, body: Some(b) }
if title == "postStartCommand failed" && b.contains("exited with code 7") && b.contains("oops"))
})
});
// A broken file is a warning, not an error.
let broken = tmp.join("broken");
write(&broken.join(".devcontainer.json"), "{ \"remoteEnv\": ");
runtime.block_on(ide_host::ops::project::open(&host, "main", &broken)).unwrap();
assert!(events.0.lock().iter().any(|e| matches!(e,
IdeEvent::Notify { level: NotifyLevel::Warning, title, .. } if title == "devcontainer.json was not applied")));
runtime.block_on(host.shutdown());
}
+1
View File
@@ -61,6 +61,7 @@ async fn serves_projects_files_documents_search_and_runs() {
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
+3 -75
View File
@@ -227,83 +227,11 @@ fn detect_node(root: &Path, out: &mut Vec<RunConfig>) {
// ---------------------------------------------------------------- deno
/// Strips `//` and `/* */` comments and trailing commas so JSONC parses as JSON.
pub(crate) fn strip_jsonc(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut chars = src.chars().peekable();
let mut in_str = false;
while let Some(c) = chars.next() {
if in_str {
out.push(c);
if c == '\\' {
if let Some(n) = chars.next() {
out.push(n);
}
} else if c == '"' {
in_str = false;
}
continue;
}
match c {
'"' => {
in_str = true;
out.push(c);
}
'/' if chars.peek() == Some(&'/') => {
for n in chars.by_ref() {
if n == '\n' {
out.push('\n');
break;
}
}
}
'/' if chars.peek() == Some(&'*') => {
chars.next();
let mut prev = '\0';
for n in chars.by_ref() {
if prev == '*' && n == '/' {
break;
}
prev = n;
}
}
_ => out.push(c),
}
}
// Remove trailing commas before `}` / `]` (outside strings).
let mut cleaned = String::with_capacity(out.len());
let bytes: Vec<char> = out.chars().collect();
let mut in_str = false;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if in_str {
cleaned.push(c);
if c == '\\' && i + 1 < bytes.len() {
cleaned.push(bytes[i + 1]);
i += 1;
} else if c == '"' {
in_str = false;
}
} else if c == '"' {
in_str = true;
cleaned.push(c);
} else if c == ',' {
let next = bytes[i + 1..].iter().find(|ch| !ch.is_whitespace());
if !matches!(next, Some('}') | Some(']')) {
cleaned.push(c);
}
} else {
cleaned.push(c);
}
i += 1;
}
cleaned
}
fn detect_deno(root: &Path, out: &mut Vec<RunConfig>) {
let Some(path) = first_existing(root, &["deno.json", "deno.jsonc"]) else { return };
let Some(json) = read(&path).and_then(|s| serde_json::from_str::<Json>(&strip_jsonc(&s)).ok()) else { return };
let Some(json) = read(&path).and_then(|s| serde_json::from_str::<Json>(&ide_core::jsonc::strip(&s)).ok()) else {
return;
};
let Some(tasks) = json.get("tasks").and_then(Json::as_object) else { return };
let mut names: Vec<&String> = tasks.keys().collect();
names.sort();
+13 -7
View File
@@ -149,18 +149,24 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option<P
/// debug adapters and terminals find managed tools. Call once at startup,
/// before other threads start (it mutates the process environment).
pub fn extend_process_path(app_data: &Path) {
let current = std::env::var_os("PATH").unwrap_or_default();
let extended = path_with_tools(app_data, &current);
if extended != current {
// SAFETY: documented to run at startup before any other thread reads the environment.
unsafe { std::env::set_var("PATH", extended) };
}
}
/// `path` (a `PATH` value) with [`tools_bin_dirs`] in front, those it lacks.
pub fn path_with_tools(app_data: &Path, path: &std::ffi::OsStr) -> OsString {
let mut dirs = tools_bin_dirs(app_data);
let current: Vec<PathBuf> =
std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default();
let current: Vec<PathBuf> = if path.is_empty() { Vec::new() } else { std::env::split_paths(path).collect() };
dirs.retain(|d| !current.contains(d));
if dirs.is_empty() {
return;
return path.to_owned();
}
dirs.extend(current);
if let Ok(joined) = std::env::join_paths(dirs) {
// SAFETY: documented to run at startup before any other thread reads the environment.
unsafe { std::env::set_var("PATH", joined) };
}
std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned())
}
/// `PATH` plus the usual per-user tool locations (GUI apps on macOS start with a minimal `PATH`).
+264
View File
@@ -0,0 +1,264 @@
//! The project's `.devcontainer/devcontainer.json` (or `.devcontainer.json`) as a
//! remote host reads it: `remoteEnv` for every child process of the IDE backend and
//! `postStartCommand` once per backend start. No container is built.
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Where the configuration may be, first match wins.
const LOCATIONS: [&str; 2] = [".devcontainer/devcontainer.json", ".devcontainer.json"];
#[derive(Debug, Default, Clone, PartialEq)]
pub struct DevContainer {
pub path: PathBuf,
/// Values as written (`None`: unset the variable), substitutions not applied.
pub remote_env: BTreeMap<String, Option<String>>,
pub post_start: Option<LifecycleCommand>,
}
/// A lifecycle command in its three forms.
#[derive(Debug, Clone, PartialEq)]
pub enum LifecycleCommand {
/// Run through `/bin/sh -c`.
Shell(String),
/// Run directly: program and arguments, no shell.
Exec(Vec<String>),
/// Named commands (each a `Shell` or an `Exec`) run in parallel; all must succeed.
Parallel(Vec<(String, LifecycleCommand)>),
}
impl LifecycleCommand {
fn parse(v: &Value) -> Result<Option<Self>, String> {
Ok(match v {
Value::Null => None,
Value::String(s) if s.trim().is_empty() => None,
Value::String(s) => Some(Self::Shell(s.clone())),
Value::Array(items) => {
let args: Vec<String> = items
.iter()
.map(|i| i.as_str().map(str::to_owned).ok_or("array items must be strings"))
.collect::<Result<_, _>>()?;
(!args.is_empty()).then_some(Self::Exec(args))
}
Value::Object(map) => {
let mut named = Vec::new();
for (name, v) in map {
match Self::parse(v)? {
Some(Self::Parallel(_)) => return Err(format!("{name}: expected a string or an array")),
Some(c) => named.push((name.clone(), c)),
None => {}
}
}
(!named.is_empty()).then_some(Self::Parallel(named))
}
_ => return Err("expected a string, an array or an object".into()),
})
}
/// The single commands to run (one, or each of a `Parallel`), with their names.
pub fn commands(&self) -> Vec<(Option<&str>, &LifecycleCommand)> {
match self {
Self::Parallel(named) => named.iter().map(|(n, c)| (Some(n.as_str()), c)).collect(),
c => vec![(None, c)],
}
}
/// Program and arguments of a `Shell` or `Exec` command (`Parallel`: `None`).
pub fn argv(&self) -> Option<Vec<String>> {
match self {
Self::Shell(s) => Some(vec!["/bin/sh".into(), "-c".into(), s.clone()]),
Self::Exec(args) => Some(args.clone()),
Self::Parallel(_) => None,
}
}
}
/// The configuration file of the project at `root`, if it has one.
pub fn find(root: &Path) -> Option<PathBuf> {
LOCATIONS.iter().map(|l| root.join(l)).find(|p| p.is_file())
}
/// Reads the project's configuration: `Ok(None)` without one, `Err` with a message when
/// it can't be read or parsed.
pub fn load(root: &Path) -> Result<Option<DevContainer>, String> {
let Some(path) = find(root) else { return Ok(None) };
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
parse(&text).map(|mut c| {
c.path = path;
Some(c)
})
}
/// Parses `devcontainer.json` text (JSON with comments and trailing commas).
pub fn parse(text: &str) -> Result<DevContainer, String> {
let json: Value = serde_json::from_str(&ide_core::jsonc::strip(text)).map_err(|e| e.to_string())?;
let Value::Object(top) = json else { return Err("expected an object".into()) };
let mut config = DevContainer::default();
if let Some(env) = top.get("remoteEnv") {
let Value::Object(env) = env else { return Err("remoteEnv: expected an object".into()) };
for (k, v) in env {
let value = match v {
Value::Null => None,
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => return Err(format!("remoteEnv.{k}: expected a string")),
};
config.remote_env.insert(k.clone(), value);
}
}
if let Some(cmd) = top.get("postStartCommand") {
config.post_start = LifecycleCommand::parse(cmd).map_err(|e| format!("postStartCommand: {e}"))?;
}
Ok(config)
}
/// Expands `${localEnv:NAME}`, `${containerEnv:NAME}` and `${env:NAME}` (all the remote
/// host's environment, through `env`; `${…:NAME:default}` when unset), and
/// `${localWorkspaceFolder}` / `${containerWorkspaceFolder}` (and their `…Basename`) to
/// the project root. Unknown references stay as written.
pub fn substitute(value: &str, root: &Path, env: &dyn Fn(&str) -> Option<String>) -> String {
let mut out = String::with_capacity(value.len());
let mut rest = value;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find('}') else {
out.push_str(&rest[start..]);
return out;
};
let inner = &after[..end];
match resolve(inner, root, env) {
Some(v) => out.push_str(&v),
None => out.push_str(&rest[start..start + 2 + end + 1]),
}
rest = &after[end + 1..];
}
out.push_str(rest);
out
}
fn resolve(inner: &str, root: &Path, env: &dyn Fn(&str) -> Option<String>) -> Option<String> {
let basename = || root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
match inner {
"localWorkspaceFolder" | "containerWorkspaceFolder" => return Some(root.to_string_lossy().into_owned()),
"localWorkspaceFolderBasename" | "containerWorkspaceFolderBasename" => return Some(basename()),
_ => {}
}
let (scope, spec) = inner.split_once(':')?;
if !matches!(scope, "localEnv" | "containerEnv" | "env") {
return None;
}
let (name, default) = match spec.split_once(':') {
Some((name, default)) => (name, Some(default)),
None => (spec, None),
};
Some(env(name).or_else(|| default.map(str::to_owned)).unwrap_or_default())
}
/// `remoteEnv` with substitutions applied against `env` (the environment before it), in
/// order: `None` unsets the variable.
pub fn resolve_env(
config: &DevContainer,
root: &Path,
env: &dyn Fn(&str) -> Option<String>,
) -> Vec<(String, Option<String>)> {
config.remote_env.iter().map(|(k, v)| (k.clone(), v.as_deref().map(|v| substitute(v, root, env)))).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn env(name: &str) -> Option<String> {
match name {
"PATH" => Some("/usr/bin:/bin".into()),
"HOME" => Some("/home/me".into()),
_ => None,
}
}
#[test]
fn parses_jsonc_with_remote_env_and_post_start() {
let c = parse(
r#"{
// A comment
"name": "demo",
"remoteEnv": {
"PATH": "${containerEnv:PATH}:/opt/tools/bin",
"EMPTY": null,
"PORT": 8080, /* numbers are text */
},
"postStartCommand": "make dev",
}"#,
)
.unwrap();
assert_eq!(c.remote_env.get("PATH").unwrap().as_deref(), Some("${containerEnv:PATH}:/opt/tools/bin"));
assert_eq!(c.remote_env.get("EMPTY"), Some(&None));
assert_eq!(c.remote_env.get("PORT").unwrap().as_deref(), Some("8080"));
assert_eq!(c.post_start, Some(LifecycleCommand::Shell("make dev".into())));
}
#[test]
fn post_start_command_forms() {
let parse_cmd = |json: &str| parse(&format!(r#"{{ "postStartCommand": {json} }}"#)).map(|c| c.post_start);
assert_eq!(parse_cmd(r#""echo hi""#).unwrap().unwrap().argv().unwrap(), ["/bin/sh", "-c", "echo hi"]);
let exec = parse_cmd(r#"["npm", "run", "watch"]"#).unwrap().unwrap();
assert_eq!(exec, LifecycleCommand::Exec(vec!["npm".into(), "run".into(), "watch".into()]));
assert_eq!(exec.argv().unwrap(), ["npm", "run", "watch"]);
let parallel =
parse_cmd(r#"{ "server": "npm start", "db": ["pg_ctl", "start"], "none": "" }"#).unwrap().unwrap();
let names: Vec<_> = parallel.commands().into_iter().map(|(n, _)| n.unwrap()).collect();
assert_eq!(names, ["db", "server"]);
assert!(parallel.argv().is_none());
assert_eq!(parse_cmd(r#""""#).unwrap(), None);
assert_eq!(parse_cmd("[]").unwrap(), None);
assert!(parse_cmd("42").unwrap_err().contains("postStartCommand"));
assert!(parse_cmd("[1]").is_err());
assert!(parse_cmd(r#"{ "nested": { "a": "b" } }"#).is_err());
}
#[test]
fn invalid_files_are_errors() {
assert!(parse("{ \"remoteEnv\": [] }").unwrap_err().contains("remoteEnv"));
assert!(parse("{ not json").is_err());
assert!(parse("[]").is_err());
}
#[test]
fn substitutes_remote_env_references() {
let root = Path::new("/work/demo");
let s = |v: &str| substitute(v, root, &env);
assert_eq!(s("${containerEnv:PATH}:/opt/bin"), "/usr/bin:/bin:/opt/bin");
assert_eq!(s("${localEnv:HOME}/.cache"), "/home/me/.cache");
assert_eq!(s("${env:HOME}"), "/home/me");
assert_eq!(s("${localEnv:MISSING}"), "");
assert_eq!(s("${localEnv:MISSING:fallback}"), "fallback");
assert_eq!(s("${containerWorkspaceFolder}/bin:${localWorkspaceFolderBasename}"), "/work/demo/bin:demo");
assert_eq!(s("${unknown} ${other:X} $HOME ${open"), "${unknown} ${other:X} $HOME ${open");
}
#[test]
fn resolves_against_the_environment_before_it() {
let c = parse(r#"{ "remoteEnv": { "PATH": "/opt/bin:${containerEnv:PATH}", "GONE": null } }"#).unwrap();
let resolved = resolve_env(&c, Path::new("/p"), &env);
assert_eq!(
resolved,
[("GONE".to_owned(), None), ("PATH".to_owned(), Some("/opt/bin:/usr/bin:/bin".to_owned()))]
);
}
#[test]
fn finds_either_location() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(load(tmp.path()).unwrap(), None);
std::fs::write(tmp.path().join(".devcontainer.json"), r#"{ "postStartCommand": "a" }"#).unwrap();
assert_eq!(load(tmp.path()).unwrap().unwrap().post_start, Some(LifecycleCommand::Shell("a".into())));
std::fs::create_dir(tmp.path().join(".devcontainer")).unwrap();
std::fs::write(tmp.path().join(".devcontainer/devcontainer.json"), r#"{ "postStartCommand": "b" }"#).unwrap();
let c = load(tmp.path()).unwrap().unwrap();
assert_eq!(c.post_start, Some(LifecycleCommand::Shell("b".into())));
assert!(c.path.ends_with(".devcontainer/devcontainer.json"));
}
}
+1
View File
@@ -1,6 +1,7 @@
//! The project model that ties documents, settings and history together.
pub mod caches;
pub mod devcontainer;
pub mod documents;
pub mod editorconfig;
pub mod file_defaults;
+14
View File
@@ -0,0 +1,14 @@
//! IPC commands of `ide_host::ops::env` (the shared implementation).
use crate::state::AppState;
use ide_core::IdeResult;
use ide_host::environment::EnvReloaded;
use tauri::{State, Window};
/// Reload Environment: new child processes get the login shell's current environment
/// (and the project's `remoteEnv` on a remote host); running ones keep theirs.
#[tauri::command]
#[specta::specta]
pub async fn env_reload(window: Window, state: State<'_, AppState>) -> IdeResult<EnvReloaded> {
ide_host::ops::env::reload(&state.host, window.label()).await
}
+2
View File
@@ -19,6 +19,7 @@ pub mod debug;
pub mod deploy;
pub mod doc;
pub mod docker;
pub mod env;
pub mod fs;
pub mod github;
pub mod history;
@@ -423,6 +424,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
crate::remote_dev::remote_current,
crate::remote_dev::remote_disconnect,
crate::remote_dev::remote_invoke,
env::env_reload,
db::db_test,
db::db_connected,
db::db_connect,
+1
View File
@@ -219,6 +219,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
SEP,
act("remote.connect", "Remote Development…"),
act("remote.disconnect", "Disconnect from Remote Host"),
act("remote.reloadEnvironment", "Reload Environment"),
SEP,
act("file.save", "Save All"),
act("file.reloadAllFromDisk", "Reload All from Disk"),
+1
View File
@@ -84,6 +84,7 @@ impl AppState {
}),
owner: Self::owner_window,
runtime: tauri::async_runtime::handle().inner().clone(),
project_environment: false,
}));
Ok(Self {
recent: RecentProjects::load(&config_dir),
+15
View File
@@ -928,6 +928,11 @@ export const commands = {
* `on_channel` tagged with the id.
*/
remoteInvoke: (cmd: string, args: string, onChannel: Channel<ChannelFrame>) => typedError<string, IdeError>(__TAURI_INVOKE("remote_invoke", { cmd, args, onChannel })),
/**
* Reload Environment: new child processes get the login shell's current environment
* (and the project's `remoteEnv` on a remote host); running ones keep theirs.
*/
envReload: () => typedError<EnvReloaded, IdeError>(__TAURI_INVOKE("env_reload")),
/** Test Connection: connects with unsaved settings and returns the server version. */
dbTest: (source: DataSource, password: string | null, sshSecret: string | null) => typedError<string, IdeError>(__TAURI_INVOKE("db_test", { source, password, sshSecret })),
dbConnected: () => typedError<string[], IdeError>(__TAURI_INVOKE("db_connected")),
@@ -2739,6 +2744,16 @@ export type EncodingSettings = {
export type EntryKind = "file" | "dir" | "symlink";
/** What Reload Environment changed. */
export type EnvReloaded = {
/** Names of the variables new child processes now get differently (added, changed or removed). */
changed: string[],
/** The `devcontainer.json` whose `remoteEnv` applies, if any. */
devcontainer: string | null,
/** Whether the login shell was read (it isn't on Windows). */
shellRead: boolean,
};
export type ErrorKind = "notFound" | "alreadyExists" | "permissionDenied" | "invalidInput" |
/** Document version mismatch; the UI must resync the full text. */
"outOfSync" | "cancelled" |
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { EnvReloaded, IdeError } from "@/bindings";
import type { NotificationService } from "@/workbench/contributions";
type Outcome = { status: "ok"; data: EnvReloaded } | { status: "error"; error: IdeError };
let outcome: Outcome;
const envReload = vi.fn(async () => outcome);
const lspServers = vi.fn(async () => ({ status: "ok" as const, data: [{ id: 1 }, { id: 2 }] }));
const lspRestart = vi.fn(async (_id: number) => ({ status: "ok" as const, data: null }));
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return { ...actual, commands: { ...actual.commands, envReload, lspServers, lspRestart } };
});
const { reloadEnvironment, reloadSummary } = await import("./environment");
const { provideNotificationService } = await import("@/workbench/contributions");
type Note = Parameters<NotificationService["notify"]>[0];
let notes: Note[];
beforeEach(() => {
notes = [];
provideNotificationService({ notify: (n) => void notes.push(n) });
vi.clearAllMocks();
});
const reloaded = (changed: string[]): EnvReloaded => ({ changed, devcontainer: null, shellRead: true });
describe("Reload Environment", () => {
it("says what changed and that running processes keep their environment", () => {
expect(reloadSummary(reloaded([]))).toBe(
"No variables changed. Terminals and processes already running keep their old environment; new ones get the new one.",
);
expect(reloadSummary(reloaded(["JAVA_HOME", "PATH"]))).toMatch(/^Changed: JAVA_HOME, PATH\. Terminals/);
const many = Array.from({ length: 10 }, (_, i) => `V${i}`);
expect(reloadSummary(reloaded(many))).toMatch(/^Changed: V0, V1, V2, V3, V4, V5, V6, V7 and 2 more\./);
});
it("offers to restart the language servers when something changed", async () => {
outcome = { status: "ok", data: reloaded(["PATH"]) };
await reloadEnvironment();
expect(envReload).toHaveBeenCalledOnce();
expect(notes).toHaveLength(1);
expect(notes[0]).toMatchObject({ level: "info", title: "Environment reloaded" });
const restart = notes[0]!.actions!.find((a) => a.label === "Restart Language Servers")!;
restart.run();
await vi.waitFor(() => expect(lspRestart).toHaveBeenCalledTimes(2));
expect(lspRestart.mock.calls.map((c) => c[0])).toEqual([1, 2]);
});
it("has nothing to restart when nothing changed", async () => {
outcome = { status: "ok", data: reloaded([]) };
await reloadEnvironment();
expect(notes[0]!.actions).toEqual([]);
});
it("reports a failure as an error", async () => {
outcome = {
status: "error",
error: { kind: "unavailable", message: "could not read the environment of the login shell (/bin/zsh)" },
} as Outcome;
await reloadEnvironment();
expect(notes[0]).toMatchObject({ level: "error", title: "Could not reload the environment" });
expect(String(notes[0]!.body)).toContain("login shell");
});
});
+64
View File
@@ -0,0 +1,64 @@
/**
* Reload Environment: new terminals, runs, language servers and tools get the login
* shell's current environment (and, on a remote host, the project's `devcontainer.json`
* `remoteEnv`) without restarting the backend. What is already running keeps its own.
*/
import { tx } from "@/platform/i18n";
import { call, commands, type EnvReloaded } from "@/platform/ipc";
import { notifications } from "@/workbench/contributions";
const KEY = "remote.reloadEnvironment";
/** Variable names listed in the notification before "and N more". */
const LISTED = 8;
/** The notification text for a reload result. */
export function reloadSummary(r: EnvReloaded): string {
const names = r.changed.slice(0, LISTED).join(", ");
const more = r.changed.length - LISTED;
const changed =
r.changed.length === 0
? tx("No variables changed.")
: more > 0
? tx`Changed: ${names} and ${more} more.`
: tx`Changed: ${names}.`;
const keep = tx("Terminals and processes already running keep their old environment; new ones get the new one.");
return [changed, keep].join(" ");
}
async function restartLanguageServers() {
try {
const servers = await call(commands.lspServers());
await Promise.all(servers.map((s) => call(commands.lspRestart(s.id))));
} catch (e) {
notifications().notify({
group: "lsp",
level: "error",
title: "Failed to restart language server",
body: e instanceof Error ? e.message : String(e),
});
}
}
export async function reloadEnvironment() {
let result: EnvReloaded;
try {
result = await call(commands.envReload());
} catch (e) {
notifications().notify({
key: KEY,
level: "error",
title: "Could not reload the environment",
body: e instanceof Error ? e.message : String(e),
});
return;
}
notifications().notify({
key: KEY,
level: "info",
title: "Environment reloaded",
body: reloadSummary(result),
actions: result.changed.length
? [{ label: "Restart Language Servers", run: () => void restartLanguageServers() }]
: [],
});
}
+11
View File
@@ -7,6 +7,7 @@ import { registerActions } from "@/platform/actions";
import { disconnectRemote } from "@/platform/remote";
import { registerOverlay } from "@/workbench/contributions";
import { ConnectHost } from "./ConnectDialog";
import { reloadEnvironment } from "./environment";
import { closeConnectDialog, openConnectDialog } from "./model";
export function activate(): () => void {
@@ -30,6 +31,16 @@ export function activate(): () => void {
when: "projectOpen",
run: () => disconnectRemote(),
},
{
id: "remote.reloadEnvironment",
title: "Reload Environment",
description:
"Reads the login shell's environment (and devcontainer.json remoteEnv on a remote host) again for new terminals and processes.",
category: "File",
keywords: ["env", "environment", "variables", "path", "shell", "remoteEnv", "devcontainer", "reload"],
when: "projectOpen",
run: () => reloadEnvironment(),
},
]),
];
return () => {
+1
View File
@@ -165,6 +165,7 @@
"menu.file.newProjectsSettings": "Settings for New Projects…",
"menu.file.toggleReadOnly": "Make File Read-only / Writable",
"menu.file.closeProject": "Close Project",
"menu.remote.reloadEnvironment": "Reload Environment",
"menu.file.new": "New…",
"menu.file.newScratchFile": "New Scratch File…",
"menu.file.openInNewWindow": "Open in New Window…",
+11
View File
@@ -4165,6 +4165,17 @@
"Remote Development…": "远程开发…",
"Opens a project folder on an SSH host; the IDE backend runs on that host.": "打开 SSH 主机上的项目文件夹;IDE 后端在该主机上运行。",
"Disconnect from Remote Host": "断开与远程主机的连接",
"Reload Environment": "重新加载环境",
"Reads the login shell's environment (and devcontainer.json remoteEnv on a remote host) again for new terminals and processes.": "为新的终端和进程重新读取登录 shell 的环境(远程主机上还包括 devcontainer.json 的 remoteEnv)。",
"No variables changed.": "没有变量发生变化。",
"Changed: {names} and {more} more.": "已更改:{names},以及另外 {more} 个。",
"Changed: {names}.": "已更改:{names}。",
"Terminals and processes already running keep their old environment; new ones get the new one.": "已在运行的终端和进程保留原有环境;新启动的将使用新环境。",
"Restart Language Servers": "重启语言服务器",
"Could not reload the environment": "无法重新加载环境",
"Environment reloaded": "环境已重新加载",
"devcontainer.json was not applied": "未应用 devcontainer.json",
"postStartCommand failed": "postStartCommand 失败",
"Opens a terminal tab connected to an SSH configuration (`arg`: its id; otherwise a chooser).": "打开连接到 SSH 配置的终端标签页(`arg`:配置 id;否则显示选择器)。",
"Connecting to the host…": "正在连接主机…",
"Checking the IDE backend on the host…": "正在检查主机上的 IDE 后端…",
+1
View File
@@ -165,6 +165,7 @@
"menu.file.newProjectsSettings": "新项目的设置…",
"menu.file.toggleReadOnly": "将文件设为只读/可写",
"menu.file.closeProject": "关闭项目",
"menu.remote.reloadEnvironment": "重新加载环境",
"menu.file.new": "新建…",
"menu.file.newScratchFile": "新建临时文件…",
"menu.file.openInNewWindow": "在新窗口中打开…",