Skip to content
Merged
90 changes: 90 additions & 0 deletions e2e/cli/test_hook_env_fast_path
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env bash

# Test that hook-env fast-path correctly detects when nothing has changed
# and skips expensive initialization
#
# Key insight: When fast-path triggers, hook-env returns immediately with NO output.
# When fast-path is bypassed, hook-env outputs shell commands including __MISE_SESSION.
#
# Note: eval "$(mise activate bash)" already runs hook-env and sets __MISE_SESSION,
# so subsequent calls should take the fast-path if nothing changed.

# Activate mise - this runs hook-env internally and establishes a session
eval "$(mise activate bash)"

# Verify session was established by activate
if [[ -n $__MISE_SESSION ]]; then
ok "activate established __MISE_SESSION"
else
fail "activate should establish __MISE_SESSION"
fi

# Fast-path should work - nothing has changed since activate
output=$(mise hook-env -s bash)
if [[ -z $output ]]; then
ok "fast-path works when nothing changed (no output)"
else
fail "fast-path should produce no output but got: '$output'"
fi

# Test 1: Creating a new config file should bypass fast-path
sleep 0.1 # ensure mtime difference
echo '[tools]' >mise.toml
output=$(mise hook-env -s bash)
if [[ $output == *"__MISE_SESSION"* ]]; then
ok "new config file bypasses fast-path"
else
fail "new config file should bypass fast-path but got: '$output'"
fi
rm mise.toml
eval "$output"

# Removing the config file also changes directory mtime, so the first
# hook-env after removal will bypass fast-path. Run it to re-establish session.
output=$(mise hook-env -s bash)
eval "$output"

# Now fast-path should work
output=$(mise hook-env -s bash)
if [[ -z $output ]]; then
ok "fast-path works after session re-established"
else
fail "fast-path should work after session re-established but got: '$output'"
fi

# Test 2: Changing directories should bypass fast-path
# First ensure we have a clean session in the current directory
eval "$(mise activate bash)"
# Verify fast-path works here
output=$(mise hook-env -s bash)
if [[ -n $output ]]; then
fail "expected fast-path before dir change but got output"
fi

# Now change directory and verify fast-path is bypassed
# Use builtin cd to avoid the _mise_hook wrapper that activate installs
mkdir -p subdir
builtin cd subdir
output=$(mise hook-env -s bash)
if [[ $output == *"__MISE_SESSION"* ]]; then
ok "directory change bypasses fast-path"
else
fail "directory change should bypass fast-path but got: '$output'"
fi
builtin cd ..
rmdir subdir

# Test 3: Creating config in parent's .config/mise should be detected
# Re-establish session first
eval "$(mise activate bash)"

mkdir -p ../.config/mise
sleep 0.1 # ensure mtime difference
echo '[tools]' >../.config/mise/config.toml
output=$(mise hook-env -s bash)
if [[ $output == *"__MISE_SESSION"* ]]; then
ok "parent .config/mise config file bypasses fast-path"
else
fail "parent .config/mise should bypass fast-path but got: '$output'"
fi
rm -rf ../.config
7 changes: 6 additions & 1 deletion src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::task::TaskOutput;
use crate::ui::{self, ctrlc};
use crate::{Result, backend};
use crate::{cli::args::ToolArg, path::PathExt};
use crate::{logger, migrate, shims};
use crate::{hook_env as hook_env_module, logger, migrate, shims};
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

[nitpick] Importing hook_env as hook_env_module is unconventional and can be confusing. Prefer a direct use crate::hook_env; and call hook_env::should_exit_early_fast() for clarity.

Suggested change
use crate::{hook_env as hook_env_module, logger, migrate, shims};
use crate::{hook_env, logger, migrate, shims};

Copilot uses AI. Check for mistakes.
use clap::{ArgAction, CommandFactory, Parser, Subcommand};
use eyre::bail;
use std::path::PathBuf;
Expand Down Expand Up @@ -433,6 +433,11 @@ impl Cli {
if *crate::env::MISE_TOOL_STUB && args.len() >= 2 {
tool_stub::short_circuit_stub(&args[2..]).await?;
}
// Fast-path for hook-env: exit early if nothing has changed
// This avoids expensive backend::load_tools() and config loading
if hook_env_module::should_exit_early_fast() {
return Ok(());
}
measure!("logger", { logger::init() });
check_working_directory();
Comment on lines +436 to 442
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

[nitpick] The fast-path returns before check_working_directory(), suppressing the warning when the current directory is invalid. If the warning is important regardless of initialization, consider moving check_working_directory() above the fast-path or explicitly running it in the fast path.

Suggested change
// Fast-path for hook-env: exit early if nothing has changed
// This avoids expensive backend::load_tools() and config loading
if hook_env_module::should_exit_early_fast() {
return Ok(());
}
measure!("logger", { logger::init() });
check_working_directory();
check_working_directory();
// Fast-path for hook-env: exit early if nothing has changed
// This avoids expensive backend::load_tools() and config loading
if hook_env_module::should_exit_early_fast() {
return Ok(());
}
measure!("logger", { logger::init() });

Copilot uses AI. Check for mistakes.
measure!("handle_shim", { shims::handle_shim().await })?;
Expand Down
107 changes: 100 additions & 7 deletions src/hook_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use crate::env::PATH_KEY;
use crate::env_diff::{EnvDiffOperation, EnvDiffPatches, EnvMap};
use crate::hash::hash_to_str;
use crate::shell::Shell;
use crate::{dirs, env, hooks, watch_files};
use crate::{dirs, env, file, hooks, watch_files};

pub static PREV_SESSION: Lazy<HookEnvSession> = Lazy::new(|| {
env::var("__MISE_SESSION")
Expand Down Expand Up @@ -59,28 +59,121 @@ impl From<PathBuf> for WatchFilePattern {
}
}

/// this function will early-exit the application if hook-env is being
/// called and it does not need to be
pub fn should_exit_early(
watch_files: impl IntoIterator<Item = WatchFilePattern>,
reason: Option<HookReason>,
) -> bool {
/// Fast-path early exit check that can be called BEFORE loading config/tools.
/// This checks basic conditions using only the previous session data.
/// Returns true if we can definitely skip hook-env, false if we need to continue.
pub fn should_exit_early_fast() -> bool {
let args = env::ARGS.read().unwrap();
if args.len() < 2 || args[1] != "hook-env" {
return false;
}
// Can't exit early if no previous session
// Check for dir being set as a proxy for "has valid session"
// (loaded_configs can be empty if there are no config files)
if PREV_SESSION.dir.is_none() {
return false;
}
// Can't exit early if --force flag is present
if args.iter().any(|a| a == "--force" || a == "-f") {
return false;
}
// Check if running from precmd for the first time
if args
.iter()
.any(|a| a == "--reason=precmd" || a.starts_with("--reason=precmd"))
&& !*env::__MISE_ZSH_PRECMD_RUN
{
return false;
}
Copy link

Choose a reason for hiding this comment

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

Bug: Fast-path fails to detect space-separated reason argument

The check for --reason=precmd only matches the equals-sign format, but zsh activation scripts use --reason precmd with a space. When called with space-separated arguments, the check fails to detect the first precmd run, potentially causing the fast-path to exit early when it should run the full initialization to catch PATH modifications from shell initialization.

Fix in Cursor Fix in Web

// Can't exit early if directory changed
if dir_change().is_some() {
return false;
}
// Can't exit early if MISE_ env vars changed
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

[nitpick] Please document what specific MISE_ variables are hashed and compared by have_mise_env_vars_been_modified(), and whether non-MISE_ variables that affect resolution are intentionally excluded. This clarifies the assumptions of the fast path.

Suggested change
// Can't exit early if MISE_ env vars changed
// Can't exit early if MISE_ env vars changed
// Only environment variables with the `MISE_` prefix are hashed and compared by
// `have_mise_env_vars_been_modified()`. Non-`MISE_` variables that might affect
// resolution are intentionally excluded from this check. This assumes that only
// `MISE_` variables influence the fast path's correctness.

Copilot uses AI. Check for mistakes.
if have_mise_env_vars_been_modified() {
return false;
}
// Check if any loaded config files have been modified
for config_path in &PREV_SESSION.loaded_configs {
if let Ok(metadata) = config_path.metadata() {
if let Ok(modified) = metadata.modified() {
let modtime = modified
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
if modtime > PREV_SESSION.latest_update {
return false;
}
}
Copy link

Choose a reason for hiding this comment

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

Bug: Fast-path continues when modification time unavailable

The fast-path check silently continues when metadata.modified() fails, potentially skipping necessary reloads. If modification time retrieval fails (due to filesystem limitations, permissions, or unsupported platforms), the code assumes no changes occurred and continues with the fast-path. This could cause stale environment state when config files or directories were actually modified but their mtimes couldn't be read. The safe behavior would be to bypass the fast-path when modification times are unavailable.

Additional Locations (2)

Fix in Cursor Fix in Web

} else if !config_path.exists() {
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

If metadata() fails for reasons other than non-existence (e.g., permissions), this logic continues without invalidating the fast path, potentially skipping necessary re-initialization. To be safe, any failure to read metadata should cause a full run, not an early exit.

Suggested change
} else if !config_path.exists() {
} else {

Copilot uses AI. Check for mistakes.
return false;
}
}
// Check if data dir has been modified (new tools installed, etc.)
if dirs::DATA.exists()
&& let Ok(metadata) = dirs::DATA.metadata()
&& let Ok(modified) = metadata.modified()
{
let modtime = modified
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
if modtime > PREV_SESSION.latest_update {
return false;
}
}
Copy link

Choose a reason for hiding this comment

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

Bug: Fast-path misses deleted data directory

The fast-path only checks dirs::DATA modification time when it exists, but skips the check entirely if it doesn't exist. If dirs::DATA existed in the previous session but is now deleted, the fast-path won't detect this change and may incorrectly exit early, leaving stale tool paths in the environment. The check should return false when dirs::DATA is missing to trigger a full environment update.

Fix in Cursor Fix in Web

// Check if any directory in the config search path has been modified
// This catches new config files created anywhere in the hierarchy
if let Some(cwd) = &*dirs::CWD
&& let Ok(ancestor_dirs) = file::all_dirs(cwd, &env::MISE_CEILING_PATHS)
{
// Config subdirectories that might contain config files
let config_subdirs = ["", ".config/mise", ".mise", "mise", ".config"];
for dir in ancestor_dirs {
for subdir in &config_subdirs {
let check_dir = if subdir.is_empty() {
dir.clone()
} else {
dir.join(subdir)
};
if let Ok(metadata) = check_dir.metadata()
&& let Ok(modified) = metadata.modified()
{
let modtime = modified
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
if modtime > PREV_SESSION.latest_update {
return false;
}
}
}
}
}
true
}

/// Check if hook-env can exit early after config is loaded.
/// This is called after the fast-path check and handles cases that need
/// the full config (watch_files, hook scheduling).
pub fn should_exit_early(
watch_files: impl IntoIterator<Item = WatchFilePattern>,
reason: Option<HookReason>,
) -> bool {
// Force hook-env to run at least once from precmd after activation
// This catches PATH modifications from shell initialization (e.g., path_helper in zsh)
if reason == Some(HookReason::Precmd) && !*env::__MISE_ZSH_PRECMD_RUN {
trace!("__MISE_ZSH_PRECMD_RUN=0 and reason=precmd, forcing hook-env to run");
return false;
}
// Schedule hooks on directory change (can't do this in fast-path)
if dir_change().is_some() {
hooks::schedule_hook(hooks::Hooks::Cd);
hooks::schedule_hook(hooks::Hooks::Enter);
hooks::schedule_hook(hooks::Hooks::Leave);
return false;
}
// Check full watch_files list from config (may include more than config files)
let watch_files = match get_watch_files(watch_files) {
Ok(w) => w,
Err(e) => {
Expand Down
Loading