Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions cli/args/flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1913,9 +1913,17 @@ pub fn flags_from_vec_with_initial_cwd(
}
}
_ => {
// Node.js compatibility: `--interactive` / `-i` at the top level
// forces REPL mode regardless of any other flags.
let interactive_flag = matches
.try_get_one::<bool>("interactive")
.ok()
.flatten()
.copied()
.unwrap_or(false);
let has_non_globals = app
.get_arguments()
.filter(|arg| !arg.is_global_set())
.filter(|arg| !arg.is_global_set() && arg.get_id() != "interactive")
.any(|arg| {
matches
.value_source(arg.get_id().as_str())
Expand All @@ -1924,7 +1932,9 @@ pub fn flags_from_vec_with_initial_cwd(
})
});

if has_non_globals || matches.contains_id("script_arg") {
if !interactive_flag
&& (has_non_globals || matches.contains_id("script_arg"))
{
run_parse(&mut flags, &mut matches, app, true)?;
} else {
handle_repl_flags(
Expand Down Expand Up @@ -2137,6 +2147,19 @@ pub fn clap_root() -> Command {
.action(ArgAction::SetTrue)
.global(true),
)
.arg(
// Node.js compatibility: `node --interactive` / `node -i` forces REPL
// mode. We accept it as a no-op flag at the top level so that scripts
// spawning the binary the same way fall through to Deno's default
// REPL behavior. Deliberately not global() — only valid when no
// subcommand is given.
Arg::new("interactive")
.short('i')
.long("interactive")
.help("Enter REPL mode (Node.js compatibility)")
.action(ArgAction::SetTrue)
.hide(true),
)
Comment on lines +2150 to +2162
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about this one... if it's needed for tests then we should improve our shim wrapper to handle that correctly (libs/node_shim/)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shim wrapper (libs/node_shim/) only sees the args when the user (or a child spawn) invokes the node_shim binary directly. The Node-compat test that motivates this PR (tests/node_compat/runner/suite/test/parallel/test-cwd-enoent-repl.js) does:

const child = spawn(process.execPath, ['-i']);

process.execPath inside Deno is Deno.execPath() — the deno binary, not node_shim. So the args reach Deno's clap parser unmediated. Routing through the shim would mean either (a) shipping the shim alongside deno and pointing process.execPath at it in node-compat mode, or (b) having Deno embed node_shim::parse_args as a pre-clap fallback for unknown args. Both are bigger architectural moves than this PR; happy to do them in a follow-up if you'd prefer.

For this PR I kept the flag minimal: hidden, top-level only (not .global()), and the parsing branch only forces REPL when no other meaningful args are present. That limits the surface area to what the test (and any analogous Node-mimicking spawn) actually exercises. WDYT — keep it as-is and move the shim integration to a follow-up, or block this on doing the shim integration first?

.subcommand(run_subcommand())
.subcommand(serve_subcommand())
.defer(|cmd| {
Expand Down
14 changes: 14 additions & 0 deletions cli/factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,20 @@ impl CliFactory {
self.services.workspace_factory.get_or_try_init(|| {
let initial_cwd = match self.overrides.initial_cwd.clone() {
Some(v) => v,
// For modes that don't depend on a real cwd (REPL, eval), fall back
// to a sentinel path when current_dir() fails — matches Node.js
// semantics where `node --interactive` works even after the cwd has
// been unlinked.
None
if matches!(
self.flags.subcommand,
DenoSubcommand::Repl(_) | DenoSubcommand::Eval(_)
) =>
{
crate::util::env::resolve_cwd_or_fallback(
self.flags.initial_cwd.as_deref(),
)
}
None => {
crate::util::env::resolve_cwd(self.flags.initial_cwd.as_deref())?
.into_owned()
Expand Down
27 changes: 27 additions & 0 deletions cli/util/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::collections::HashSet;
use std::env;
use std::ffi::OsString;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::OnceLock;
Expand All @@ -32,6 +33,32 @@ pub fn resolve_cwd(
}
}

/// Like `resolve_cwd`, but falls back to a sensible default (the system
/// root) when the current working directory can't be determined — for
/// example when it has been unlinked. This matches Node.js semantics where
/// the REPL still starts even if the parent process's cwd was deleted.
pub fn resolve_cwd_or_fallback(initial_cwd: Option<&Path>) -> PathBuf {
match resolve_cwd(initial_cwd) {
Ok(cwd) => cwd.into_owned(),
Err(_) => fallback_cwd(),
}
}

fn fallback_cwd() -> PathBuf {
if cfg!(windows) {
// System drive root, e.g. `C:\`.
std::env::var_os("SystemDrive")
.map(|d| {
let mut p = PathBuf::from(d);
p.push("\\");
p
})
.unwrap_or_else(|| PathBuf::from("C:\\"))
} else {
PathBuf::from("/")
}
}

#[derive(Debug, Clone)]
struct WatchEnvTrackerInner {
// Track all loaded variables and their values
Expand Down
48 changes: 38 additions & 10 deletions ext/process/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,9 @@ fn create_command(
command.args(args.args);
}

command.current_dir(run_env.cwd);
if let Some(cwd) = run_env.cwd {
command.current_dir(cwd);
}
command.env_clear();
command.envs(run_env.envs.into_iter().map(|(k, v)| (k.into_inner(), v)));

Expand Down Expand Up @@ -1138,7 +1140,10 @@ impl std::cmp::PartialEq for EnvVarKey {

struct RunEnv {
envs: HashMap<EnvVarKey, OsString>,
cwd: PathBuf,
/// `None` means "inherit the parent's cwd". This happens when no cwd was
/// explicitly passed and the current working directory could not be
/// resolved (e.g. because it was unlinked, matching Node.js semantics).
cwd: Option<PathBuf>,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this change... SpawnArgs.cwd is already an option. We should just compute it before creating RunEnv instead of passing this option all the way down

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e0df4fb:

  • RunEnv.cwd is now PathBuf again. The inherit-parent-cwd decision is computed once inside compute_run_env and stored in a separate set_cwd_on_command: bool field. Nothing downstream of RunEnv deals with an Option<PathBuf> anymore — resolve_cmd and which_in see a concrete PathBuf (set to "." as a placeholder when the cwd is unknown).
  • Inheritance is also scoped: compute_run_env takes an allow_cwd_inherit: bool argument that only op_node_spawn_child passes as true. op_spawn_child, op_spawn_sync, and op_run still error with failed resolving cwd: when the parent's cwd is unlinked, which restores the tests/unit/process_test.ts:581 nonExistentCwd contract that the unit-test CI was failing on.

}

/// Computes the current environment, which will then be used to inform
Expand All @@ -1155,11 +1160,21 @@ fn compute_run_env(
clippy::disallowed_methods,
reason = "ok for now because launching a sub process requires the real fs"
)]
let cwd =
std::env::current_dir().map_err(ProcessError::FailedResolvingCwd)?;
let cwd = arg_cwd
.map(|cwd_arg| resolve_path(cwd_arg, &cwd))
.unwrap_or(cwd);
let current_dir = std::env::current_dir();
let cwd = match arg_cwd {
Some(cwd_arg) => {
let arg_path = Path::new(cwd_arg);
if arg_path.is_absolute() {
Some(
deno_path_util::normalize_path(Cow::Borrowed(arg_path)).into_owned(),
)
} else {
let base = current_dir.map_err(ProcessError::FailedResolvingCwd)?;
Some(resolve_path(cwd_arg, &base))
}
}
None => current_dir.ok(),
};
let envs = if arg_clear_env {
arg_envs
.iter()
Expand All @@ -1182,14 +1197,25 @@ fn resolve_cmd(cmd: &str, env: &RunEnv) -> Result<PathBuf, ProcessError> {
#[cfg(windows)]
let is_path = is_path || cmd.contains('\\') || Path::new(&cmd).is_absolute();
if is_path {
Ok(resolve_path(cmd, &env.cwd))
let cmd_path = Path::new(cmd);
if cmd_path.is_absolute() {
Ok(deno_path_util::normalize_path(Cow::Borrowed(cmd_path)).into_owned())
} else {
let cwd = env.cwd.as_deref().ok_or_else(|| {
ProcessError::FailedResolvingCwd(std::io::Error::from(
std::io::ErrorKind::NotFound,
))
})?;
Ok(resolve_path(cmd, cwd))
}
} else {
let path = env.envs.get(&EnvVarKey::new(OsString::from("PATH")));
let lookup_cwd = env.cwd.clone().unwrap_or_else(|| PathBuf::from("."));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth a sanity check on which_in's behavior when lookup_cwd is "." and the actual cwd is unlinked:

let lookup_cwd = env.cwd.clone().unwrap_or_else(|| PathBuf::from("."));

If the user spawns a binary by a non-PATH non-absolute name (e.g., spawn('./helper')) from a deleted cwd, which_in will try to resolve ./helper against "." which canonicalises to the deleted cwd anyway → still fails. That's fine — it's the right outcome since the file genuinely isn't reachable. But for binaries via PATH (e.g., spawn('node')), which_in walks PATH entries and the lookup_cwd is only used as the fallback search root, which shouldn't matter for a PATH-resolvable name.

The test (spawn(process.execPath, ['--interactive'])) uses an absolute path so it short-circuits to the cmd_path.is_absolute() branch above this line and never touches lookup_cwd. So this fallback is only stress-tested by hypothetical relative-path spawns from a deleted cwd — a real but unusual scenario.

Not blocking. Worth a one-line comment that the "." here is a best-effort fallback that doesn't actually resolve usefully when cwd is unlinked, just so future readers know it's not a bug.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e0df4fb — added a comment above the which_in call noting that env.cwd may be a placeholder "." when the cwd is unknown (set_cwd_on_command == false), and that PATH-resolvable lookups don't depend on it.

match deno_permissions::which::which_in(
sys_traits::impls::RealSys,
cmd,
path.cloned(),
env.cwd.clone(),
lookup_cwd,
) {
Ok(cmd) => Ok(cmd),
Err(deno_permissions::which::Error::CannotFindBinaryPath) => {
Expand Down Expand Up @@ -1665,7 +1691,9 @@ mod deprecated {
for arg in args.iter().skip(1) {
c.arg(arg);
}
c.current_dir(run_env.cwd);
if let Some(cwd) = run_env.cwd {
c.current_dir(cwd);
}

c.env_clear();
for (key, value) in run_env.envs {
Expand Down
1 change: 1 addition & 0 deletions tests/node_compat/config.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@
"parallel/test-crypto-verify-failure.js": {},
"parallel/test-crypto-webcrypto-aes-decrypt-tag-too-small.js": {},
"parallel/test-crypto-worker-thread.js": {},
"parallel/test-cwd-enoent-repl.js": {},
"parallel/test-datetime-change-notify.js": {},
"parallel/test-debugger-address.mjs": {
"ignore": true,
Expand Down
Loading