Wait to locate system-installed Node until the shell environment is loaded (#30416)

Release Notes:

- Fixed a race condition that sometimes prevented a system-installed
`node` binary from being detected.
- Fixed a bug where the `node.path` setting was not respected when
invoking npm.
This commit is contained in:
Max Brunsfeld 2025-05-09 12:24:28 -07:00 committed by GitHub
parent 9afc6f6f5c
commit 65b13968a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 68 additions and 58 deletions

View file

@ -1297,21 +1297,22 @@
"JSONC": ["**/.zed/**/*.json", "**/zed/**/*.json", "**/Zed/**/*.json", "**/.vscode/**/*.json"], "JSONC": ["**/.zed/**/*.json", "**/zed/**/*.json", "**/Zed/**/*.json", "**/.vscode/**/*.json"],
"Shell Script": [".env.*"] "Shell Script": [".env.*"]
}, },
// By default use a recent system version of node, or install our own. // Settings for which version of Node.js and NPM to use when installing
// You can override this to use a version of node that is not in $PATH with: // language servers and Copilot.
// { //
// "node": { // Note: changing this setting currently requires restarting Zed.
// "path": "/path/to/node" "node": {
// "npm_path": "/path/to/npm" (defaults to node_path/../npm) // By default, Zed will look for `node` and `npm` on your `$PATH`, and use the
// } // existing executables if their version is recent enough. Set this to `true`
// } // to prevent this, and force Zed to always download and install its own
// or to ensure Zed always downloads and installs an isolated version of node: // version of Node.
// { "ignore_system_version": false,
// "node": { // You can also specify alternative paths to Node and NPM. If you specify
// "ignore_system_version": true, // `path`, but not `npm_path`, Zed will assume that `npm` is located at
// } // `${path}/../npm`.
// NOTE: changing this setting currently requires restarting Zed. "path": null,
"node": {}, "npm_path": null
},
// The extensions that Zed should automatically install on startup. // The extensions that Zed should automatically install on startup.
// //
// If you don't want any of these extensions, add this field to your settings // If you don't want any of these extensions, add this field to your settings

View file

@ -397,7 +397,7 @@ pub fn init(cx: &mut App) -> Arc<AgentAppState> {
cx.observe_global::<SettingsStore>(move |cx| { cx.observe_global::<SettingsStore>(move |cx| {
let settings = &ProjectSettings::get_global(cx).node; let settings = &ProjectSettings::get_global(cx).node;
let options = NodeBinaryOptions { let options = NodeBinaryOptions {
allow_path_lookup: !settings.ignore_system_version.unwrap_or_default(), allow_path_lookup: !settings.ignore_system_version,
allow_binary_download: true, allow_binary_download: true,
use_paths: settings.path.as_ref().map(|node_path| { use_paths: settings.path.as_ref().map(|node_path| {
let node_path = PathBuf::from(shellexpand::tilde(node_path).as_ref()); let node_path = PathBuf::from(shellexpand::tilde(node_path).as_ref());
@ -417,7 +417,7 @@ pub fn init(cx: &mut App) -> Arc<AgentAppState> {
tx.send(Some(options)).log_err(); tx.send(Some(options)).log_err();
}) })
.detach(); .detach();
let node_runtime = NodeRuntime::new(client.http_client(), rx); let node_runtime = NodeRuntime::new(client.http_client(), None, rx);
let extension_host_proxy = ExtensionHostProxy::global(cx); let extension_host_proxy = ExtensionHostProxy::global(cx);

View file

@ -4,19 +4,18 @@ use anyhow::{Context, Result, anyhow, bail};
pub use archive::extract_zip; pub use archive::extract_zip;
use async_compression::futures::bufread::GzipDecoder; use async_compression::futures::bufread::GzipDecoder;
use async_tar::Archive; use async_tar::Archive;
use futures::AsyncReadExt; use futures::{AsyncReadExt, FutureExt as _, channel::oneshot, future::Shared};
use http_client::{HttpClient, Url}; use http_client::{HttpClient, Url};
use semver::Version; use semver::Version;
use serde::Deserialize; use serde::Deserialize;
use smol::io::BufReader; use smol::io::BufReader;
use smol::{fs, lock::Mutex}; use smol::{fs, lock::Mutex};
use std::env;
use std::ffi::OsString;
use std::io;
use std::process::{Output, Stdio};
use std::{ use std::{
env::consts, env::{self, consts},
ffi::OsString,
io,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::{Output, Stdio},
sync::Arc, sync::Arc,
}; };
use util::ResultExt; use util::ResultExt;
@ -38,11 +37,13 @@ struct NodeRuntimeState {
instance: Option<Box<dyn NodeRuntimeTrait>>, instance: Option<Box<dyn NodeRuntimeTrait>>,
last_options: Option<NodeBinaryOptions>, last_options: Option<NodeBinaryOptions>,
options: async_watch::Receiver<Option<NodeBinaryOptions>>, options: async_watch::Receiver<Option<NodeBinaryOptions>>,
shell_env_loaded: Shared<oneshot::Receiver<()>>,
} }
impl NodeRuntime { impl NodeRuntime {
pub fn new( pub fn new(
http: Arc<dyn HttpClient>, http: Arc<dyn HttpClient>,
shell_env_loaded: Option<oneshot::Receiver<()>>,
options: async_watch::Receiver<Option<NodeBinaryOptions>>, options: async_watch::Receiver<Option<NodeBinaryOptions>>,
) -> Self { ) -> Self {
NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState { NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState {
@ -50,6 +51,7 @@ impl NodeRuntime {
instance: None, instance: None,
last_options: None, last_options: None,
options, options,
shell_env_loaded: shell_env_loaded.unwrap_or(oneshot::channel().1).shared(),
}))) })))
} }
@ -59,6 +61,7 @@ impl NodeRuntime {
instance: None, instance: None,
last_options: None, last_options: None,
options: async_watch::channel(Some(NodeBinaryOptions::default())).1, options: async_watch::channel(Some(NodeBinaryOptions::default())).1,
shell_env_loaded: oneshot::channel().1.shared(),
}))) })))
} }
@ -83,6 +86,7 @@ impl NodeRuntime {
} }
if options.allow_path_lookup { if options.allow_path_lookup {
state.shell_env_loaded.clone().await.ok();
if let Some(instance) = SystemNodeRuntime::detect().await { if let Some(instance) = SystemNodeRuntime::detect().await {
state.instance = Some(instance.boxed_clone()); state.instance = Some(instance.boxed_clone());
return Ok(instance); return Ok(instance);
@ -277,23 +281,6 @@ impl ManagedNodeRuntime {
#[cfg(windows)] #[cfg(windows)]
const NPM_PATH: &str = "node_modules/npm/bin/npm-cli.js"; const NPM_PATH: &str = "node_modules/npm/bin/npm-cli.js";
async fn node_environment_path(&self) -> Result<OsString> {
let node_binary = self.installation_path.join(Self::NODE_PATH);
let mut env_path = vec![
node_binary
.parent()
.expect("invalid node binary path")
.to_path_buf(),
];
if let Some(existing_path) = std::env::var_os("PATH") {
let mut paths = std::env::split_paths(&existing_path).collect::<Vec<_>>();
env_path.append(&mut paths);
}
std::env::join_paths(env_path).context("failed to create PATH env variable")
}
async fn install_if_needed(http: &Arc<dyn HttpClient>) -> Result<Box<dyn NodeRuntimeTrait>> { async fn install_if_needed(http: &Arc<dyn HttpClient>) -> Result<Box<dyn NodeRuntimeTrait>> {
log::info!("Node runtime install_if_needed"); log::info!("Node runtime install_if_needed");
@ -381,6 +368,27 @@ impl ManagedNodeRuntime {
} }
} }
fn path_with_node_binary_prepended(node_binary: &Path) -> Option<OsString> {
let existing_path = env::var_os("PATH");
let node_bin_dir = node_binary.parent().map(|dir| dir.as_os_str());
match (existing_path, node_bin_dir) {
(Some(existing_path), Some(node_bin_dir)) => {
if let Ok(joined) = env::join_paths(
[PathBuf::from(node_bin_dir)]
.into_iter()
.chain(env::split_paths(&existing_path)),
) {
Some(joined)
} else {
Some(existing_path)
}
}
(Some(existing_path), None) => Some(existing_path),
(None, Some(node_bin_dir)) => Some(node_bin_dir.to_owned()),
_ => None,
}
}
#[async_trait::async_trait] #[async_trait::async_trait]
impl NodeRuntimeTrait for ManagedNodeRuntime { impl NodeRuntimeTrait for ManagedNodeRuntime {
fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> { fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> {
@ -401,7 +409,7 @@ impl NodeRuntimeTrait for ManagedNodeRuntime {
let attempt = || async move { let attempt = || async move {
let node_binary = self.installation_path.join(Self::NODE_PATH); let node_binary = self.installation_path.join(Self::NODE_PATH);
let npm_file = self.installation_path.join(Self::NPM_PATH); let npm_file = self.installation_path.join(Self::NPM_PATH);
let env_path = self.node_environment_path().await?; let env_path = path_with_node_binary_prepended(&node_binary).unwrap_or_default();
if smol::fs::metadata(&node_binary).await.is_err() { if smol::fs::metadata(&node_binary).await.is_err() {
return Err(anyhow!("missing node binary file")); return Err(anyhow!("missing node binary file"));
@ -541,9 +549,10 @@ impl NodeRuntimeTrait for SystemNodeRuntime {
) -> anyhow::Result<Output> { ) -> anyhow::Result<Output> {
let node_ca_certs = env::var(NODE_CA_CERTS_ENV_VAR).unwrap_or_else(|_| String::new()); let node_ca_certs = env::var(NODE_CA_CERTS_ENV_VAR).unwrap_or_else(|_| String::new());
let mut command = util::command::new_smol_command(self.npm.clone()); let mut command = util::command::new_smol_command(self.npm.clone());
let path = path_with_node_binary_prepended(&self.node).unwrap_or_default();
command command
.env_clear() .env_clear()
.env("PATH", std::env::var_os("PATH").unwrap_or_default()) .env("PATH", path)
.env(NODE_CA_CERTS_ENV_VAR, node_ca_certs) .env(NODE_CA_CERTS_ENV_VAR, node_ca_certs)
.arg(subcommand) .arg(subcommand)
.args(["--cache".into(), self.scratch_dir.join("cache")]) .args(["--cache".into(), self.scratch_dir.join("cache")])
@ -655,14 +664,14 @@ fn configure_npm_command(
#[cfg(windows)] #[cfg(windows)]
{ {
// SYSTEMROOT is a critical environment variables for Windows. // SYSTEMROOT is a critical environment variables for Windows.
if let Some(val) = std::env::var("SYSTEMROOT") if let Some(val) = env::var("SYSTEMROOT")
.context("Missing environment variable: SYSTEMROOT!") .context("Missing environment variable: SYSTEMROOT!")
.log_err() .log_err()
{ {
command.env("SYSTEMROOT", val); command.env("SYSTEMROOT", val);
} }
// Without ComSpec, the post-install will always fail. // Without ComSpec, the post-install will always fail.
if let Some(val) = std::env::var("ComSpec") if let Some(val) = env::var("ComSpec")
.context("Missing environment variable: ComSpec!") .context("Missing environment variable: ComSpec!")
.log_err() .log_err()
{ {

View file

@ -104,7 +104,7 @@ pub struct NodeBinarySettings {
pub npm_path: Option<String>, pub npm_path: Option<String>,
/// If enabled, Zed will download its own copy of Node. /// If enabled, Zed will download its own copy of Node.
#[serde(default)] #[serde(default)]
pub ignore_system_version: Option<bool>, pub ignore_system_version: bool,
} }
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]

View file

@ -468,7 +468,7 @@ pub fn execute_run(
) )
}; };
let node_runtime = NodeRuntime::new(http_client.clone(), node_settings_rx); let node_runtime = NodeRuntime::new(http_client.clone(), None, node_settings_rx);
let mut languages = LanguageRegistry::new(cx.background_executor().clone()); let mut languages = LanguageRegistry::new(cx.background_executor().clone());
languages.set_language_server_download_dir(paths::languages_dir().clone()); languages.set_language_server_download_dir(paths::languages_dir().clone());
@ -796,7 +796,7 @@ fn initialize_settings(
let settings = &ProjectSettings::get_global(cx).node; let settings = &ProjectSettings::get_global(cx).node;
log::info!("Got new node settings: {:?}", settings); log::info!("Got new node settings: {:?}", settings);
let options = NodeBinaryOptions { let options = NodeBinaryOptions {
allow_path_lookup: !settings.ignore_system_version.unwrap_or_default(), allow_path_lookup: !settings.ignore_system_version,
// TODO: Implement this setting // TODO: Implement this setting
allow_binary_download: true, allow_binary_download: true,
use_paths: settings.path.as_ref().map(|node_path| { use_paths: settings.path.as_ref().map(|node_path| {

View file

@ -259,7 +259,7 @@ where
} }
#[cfg(unix)] #[cfg(unix)]
pub fn load_shell_from_passwd() -> Result<()> { fn load_shell_from_passwd() -> Result<()> {
let buflen = match unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) } { let buflen = match unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) } {
n if n < 0 => 1024, n if n < 0 => 1024,
n => n as usize, n => n as usize,
@ -309,6 +309,8 @@ pub fn load_shell_from_passwd() -> Result<()> {
#[cfg(unix)] #[cfg(unix)]
pub fn load_login_shell_environment() -> Result<()> { pub fn load_login_shell_environment() -> Result<()> {
load_shell_from_passwd().log_err();
let marker = "ZED_LOGIN_SHELL_START"; let marker = "ZED_LOGIN_SHELL_START";
let shell = env::var("SHELL").context( let shell = env::var("SHELL").context(
"SHELL environment variable is not assigned so we can't source login environment variables", "SHELL environment variable is not assigned so we can't source login environment variables",

View file

@ -15,7 +15,7 @@ use editor::Editor;
use extension::ExtensionHostProxy; use extension::ExtensionHostProxy;
use extension_host::ExtensionStore; use extension_host::ExtensionStore;
use fs::{Fs, RealFs}; use fs::{Fs, RealFs};
use futures::{StreamExt, future}; use futures::{StreamExt, channel::oneshot, future};
use git::GitHostingProviderRegistry; use git::GitHostingProviderRegistry;
use gpui::{App, AppContext as _, Application, AsyncApp, UpdateGlobal as _}; use gpui::{App, AppContext as _, Application, AsyncApp, UpdateGlobal as _};
@ -55,9 +55,6 @@ use zed::{
open_paths_with_positions, open_paths_with_positions,
}; };
#[cfg(unix)]
use util::{load_login_shell_environment, load_shell_from_passwd};
#[cfg(feature = "mimalloc")] #[cfg(feature = "mimalloc")]
#[global_allocator] #[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
@ -303,15 +300,16 @@ fn main() {
paths::keymap_file().clone(), paths::keymap_file().clone(),
); );
#[cfg(unix)] let (shell_env_loaded_tx, shell_env_loaded_rx) = oneshot::channel();
if !stdout_is_a_pty() { if !stdout_is_a_pty() {
app.background_executor() app.background_executor()
.spawn(async { .spawn(async {
load_shell_from_passwd().log_err(); #[cfg(unix)]
load_login_shell_environment().log_err(); util::load_login_shell_environment().log_err();
shell_env_loaded_tx.send(()).ok();
}) })
.detach() .detach()
}; }
app.on_open_urls({ app.on_open_urls({
let open_listener = open_listener.clone(); let open_listener = open_listener.clone();
@ -386,7 +384,7 @@ fn main() {
cx.observe_global::<SettingsStore>(move |cx| { cx.observe_global::<SettingsStore>(move |cx| {
let settings = &ProjectSettings::get_global(cx).node; let settings = &ProjectSettings::get_global(cx).node;
let options = NodeBinaryOptions { let options = NodeBinaryOptions {
allow_path_lookup: !settings.ignore_system_version.unwrap_or_default(), allow_path_lookup: !settings.ignore_system_version,
// TODO: Expose this setting // TODO: Expose this setting
allow_binary_download: true, allow_binary_download: true,
use_paths: settings.path.as_ref().map(|node_path| { use_paths: settings.path.as_ref().map(|node_path| {
@ -407,7 +405,7 @@ fn main() {
tx.send(Some(options)).log_err(); tx.send(Some(options)).log_err();
}) })
.detach(); .detach();
let node_runtime = NodeRuntime::new(client.http_client(), rx); let node_runtime = NodeRuntime::new(client.http_client(), Some(shell_env_loaded_rx), rx);
language::init(cx); language::init(cx);
language_extension::init(extension_host_proxy.clone(), languages.clone()); language_extension::init(extension_host_proxy.clone(), languages.clone());