Skip to content

feat(hook-env): add hook_env.cache_ttl and hook_env.chpwd_only settings for NFS optimization#7312

Merged
jdx merged 5 commits intomainfrom
feat/hook-env-nfs-optimization
Dec 15, 2025
Merged

feat(hook-env): add hook_env.cache_ttl and hook_env.chpwd_only settings for NFS optimization#7312
jdx merged 5 commits intomainfrom
feat/hook-env-nfs-optimization

Conversation

@jdx
Copy link
Copy Markdown
Owner

@jdx jdx commented Dec 15, 2025

Summary

Adds two new settings to mitigate shell latency on slow filesystems like NFS (addresses #2164):

  • hook_env.cache_ttl (default: "0s"): Cache directory check results for this duration, skipping stat operations within the TTL window
  • hook_env.chpwd_only (default: false): Only run hook-env checks on directory change, not on every shell prompt

The Problem

On NFS filesystems with cold cache, mise hook-env causes multi-second delays because each stat operation can take hundreds of milliseconds. The should_exit_early_fast() function performs:

  • Stats on loaded config files (~1-3)
  • Stats on data directory (1)
  • Stats on each ancestor directory from CWD to ceiling, checking multiple subdirs (.mise/, .config/mise/, etc.)

For a path 20 levels deep: ~80+ stats = potential 17+ second delay on NFS.

The Solution

These settings allow users to trade off accuracy for performance:

# Cache stats for 5 seconds (reduces stat calls on slow NFS)
export MISE_HOOK_ENV_CACHE_TTL=5s

# Only check on directory change (most aggressive optimization)
export MISE_HOOK_ENV_CHPWD_ONLY=1

# Both (for very slow NFS)
export MISE_HOOK_ENV_CACHE_TTL=30s
export MISE_HOOK_ENV_CHPWD_ONLY=1

# Bypass cache when needed
mise hook-env --force

Trade-offs

  • With cache_ttl enabled, newly created config files may not be detected until the TTL expires
  • With chpwd_only enabled, changes to existing config files won't be detected until directory change
  • Both can be bypassed with mise hook-env --force

Test plan

  • Build passes
  • Lint passes
  • All existing hook-env e2e tests pass
  • Settings accessible via mise settings get hook_env.cache_ttl and env vars
  • Manual testing of settings

🤖 Generated with Claude Code


Note

Introduce hook-env performance settings (hook_env.cache_ttl, hook_env.chpwd_only) and implement per-directory timestamp caching to skip costly filesystem checks.

  • Hook Env (core logic):
    • Implement TTL-based early-exit and chpwd-only mode in should_exit_early_fast(); skip filesystem stats when within TTL or on precmd with chpwd_only.
    • Persist per-directory last-check timestamps under state/hook-env-checks; read/write via read_last_full_check/write_last_full_check and update in build_session().
    • Parse durations from settings; minor path handling fix for config subdirs using Path::parent().
  • Config/Schema:
    • Add settings.hook_env.cache_ttl (Duration, MISE_HOOK_ENV_CACHE_TTL) and settings.hook_env.chpwd_only (Bool, MISE_HOOK_ENV_CHPWD_ONLY) to schema/mise.json and settings.toml with docs.

Written by Cursor Bugbot for commit 8679af0. This will update automatically on new commits. Configure here.

Copilot AI review requested due to automatic review settings December 15, 2025 12:31
@jdx jdx changed the title feat(cli): add hook_env.cache_ttl and hook_env.chpwd_only settings for NFS optimization feat(hook-env): add hook_env.cache_ttl and hook_env.chpwd_only settings for NFS optimization Dec 15, 2025
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces two new settings to optimize mise hook-env performance on slow filesystems like NFS by reducing expensive stat operations. The changes allow users to cache directory checks and limit hook-env execution to directory changes only.

Key changes:

  • Adds hook_env.cache_ttl setting to cache directory check results for a configurable duration
  • Adds hook_env.chpwd_only setting to skip hook-env checks on shell prompts when the directory hasn't changed
  • Implements caching logic to track directories without config files and reuse this information within the TTL window

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/hook_env.rs Implements the caching logic and chpwd_only check in should_exit_early_fast(), adds fields to HookEnvSession to track cached state
settings.toml Defines the two new settings with documentation explaining their purpose and trade-offs
schema/mise.json Updates the JSON schema to include the new hook_env settings object

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/hook_env.rs
Comment on lines +100 to +102
let cache_ttl_ms = duration::parse_duration(&settings.hook_env.cache_ttl)
.map(|d| d.as_millis())
.unwrap_or(0);
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

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

The unwrap_or(0) silently ignores parsing errors for cache_ttl. If a user provides an invalid duration format, they won't receive any feedback. Consider logging a warning when parsing fails so users can identify configuration issues.

Copilot uses AI. Check for mistakes.
Comment thread src/hook_env.rs Outdated
{
let config_subdirs = DEFAULT_CONFIG_FILENAMES
.iter()
.map(|f| f.rsplit_once("/").map(|(dir, _)| dir).unwrap_or(""))
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

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

Using string literal / for path separation is not cross-platform. Consider using std::path::MAIN_SEPARATOR or path methods to ensure Windows compatibility.

Suggested change
.map(|f| f.rsplit_once("/").map(|(dir, _)| dir).unwrap_or(""))
.map(|f| {
Path::new(f)
.parent()
.map(|p| p.to_str().unwrap_or(""))
.unwrap_or("")
})

Copilot uses AI. Check for mistakes.
…r NFS optimization

Add two new settings to mitigate shell latency on slow filesystems like NFS:

- `hook_env.cache_ttl` (default: "0s"): Cache directory check results for
  this duration, skipping stat operations within the TTL window
- `hook_env.chpwd_only` (default: false): Only run hook-env checks on
  directory change, not on every shell prompt

These settings address the performance issues described in #2164
where users on NFS experience multi-second delays due to many stat
operations during hook-env execution.

Usage:
  export MISE_HOOK_ENV_CACHE_TTL=5s
  export MISE_HOOK_ENV_CHPWD_ONLY=1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@jdx jdx force-pushed the feat/hook-env-nfs-optimization branch from ae94d35 to 17c8adf Compare December 15, 2025 12:33
Comment thread src/hook_env.rs Outdated
Comment thread src/hook_env.rs Outdated
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Dec 15, 2025

xtasks/test/perf

Command mise-2025.12.7 mise Variance
install (cached) 113ms 110ms +2%
ls (cached) 66ms 67ms -1%
bin-paths (cached) 73ms 72ms +1%
task-ls (cached) 451ms ✅ 280ms +61%

✅ Performance improvement: task-ls cached is 61%

- Fix cache directory skip logic to only trust cache when within TTL window
  (previously used stale cache after TTL expired, missing new config files)
- Move env var check before chpwd_only early exit since it's a cheap
  in-memory hash comparison with no filesystem I/O
- Log warning when hook_env.cache_ttl has invalid duration format
  instead of silently falling back to 0
- Use cross-platform Path methods instead of string literal "/" for
  parsing config subdirectories

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Comment thread src/hook_env.rs Outdated
The directory caching optimization was dead code because:
1. When within_ttl_window is true, we return early (line 134)
2. When we reach the cache check (line 178), within_ttl_window is always false
3. The condition `if within_ttl_window && ...` is therefore always false

Since the cache is only meant to be used within the TTL window, and we
skip all filesystem checks during that window anyway, the cache adds
overhead without providing any benefit.

Removed:
- checked_dirs_without_config field from HookEnvSession
- Dead cache skip logic in should_exit_early_fast()
- Expensive computation in build_session() that populated the cache

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Bug: TTL cache not refreshed after filesystem checks pass

The cache_ttl feature doesn't work as intended because last_full_check is only updated in build_session(), which is only called when config actually changes. When the TTL expires and should_exit_early_fast() performs filesystem checks that find nothing changed (returning true at line 187), last_full_check is never updated. Subsequent prompts will repeatedly perform expensive stat operations since the original TTL has long expired. The cache effectively only works for one TTL period after each config change, defeating the NFS optimization purpose.

src/hook_env.rs#L131-L187

mise/src/hook_env.rs

Lines 131 to 187 in 263f46c

// This is useful for slow filesystems like NFS where stat calls are expensive
if within_ttl_window {
trace!("within cache TTL, skipping filesystem checks");
return true;
}
// 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()
&& mtime_to_millis(modified) > PREV_SESSION.latest_update
{
return false;
}
} else if !config_path.exists() {
return false;
}
}
// Check if data dir has been modified (new tools installed, etc.)
// Also check if it's been deleted - this requires a full update
if !dirs::DATA.exists() {
return false;
}
if let Ok(metadata) = dirs::DATA.metadata()
&& let Ok(modified) = metadata.modified()
&& mtime_to_millis(modified) > PREV_SESSION.latest_update
{
return false;
}
// 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 = DEFAULT_CONFIG_FILENAMES
.iter()
.map(|f| Path::new(f).parent().and_then(|p| p.to_str()).unwrap_or(""))
.unique()
.collect::<Vec<_>>();
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()
&& mtime_to_millis(modified) > PREV_SESSION.latest_update
{
return false;
}
}
}
}
true

src/hook_env.rs#L335-L336

mise/src/hook_env.rs

Lines 335 to 336 in 263f46c

latest_update: mtime_to_millis(max_modtime),
last_full_check: now,

Fix in Cursor Fix in Web


jdx and others added 2 commits December 15, 2025 07:22
The cache_ttl feature wasn't working correctly because last_full_check
was only updated in build_session() which is only called when config
changes. When TTL expired and filesystem checks passed, the timestamp
wasn't updated, causing subsequent prompts to repeat expensive stat
operations.

Fix by storing the last check timestamp in per-directory files under
~/.local/state/mise/hook-env-checks/<hash>. This allows updating the
timestamp when exiting early after filesystem checks pass, without
needing to output shell commands.

Per-directory files (using CWD hash) ensure multiple shells in different
directories don't interfere with each other.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Avoid unnecessary file I/O when the cache_ttl feature is not being used
(the default). The timestamp file read/write is now conditional on
cache_ttl_ms > 0.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@jdx jdx enabled auto-merge (squash) December 15, 2025 13:47
@jdx jdx merged commit 6a0a376 into main Dec 15, 2025
39 of 43 checks passed
@jdx jdx deleted the feat/hook-env-nfs-optimization branch December 15, 2025 14:20
jdx pushed a commit that referenced this pull request Dec 15, 2025
### 🚀 Features

- **(conda)** add dependency resolution for conda packages by @jdx in
[#7280](#7280)
- **(go)** add created_at support to ls-remote --json by @jdx in
[#7305](#7305)
- **(hook-env)** add hook_env.cache_ttl and hook_env.chpwd_only settings
for NFS optimization by @jdx in
[#7312](#7312)
- **(hooks)** add MISE_TOOL_NAME and MISE_TOOL_VERSION to
preinstall/postinstall hooks by @jdx in
[#7311](#7311)
- **(shell_alias)** add shell_alias support for cross-shell aliases by
@jdx in [#7316](#7316)
- **(tool)** add security field to mise tool --json by @jdx in
[#7303](#7303)
- add --before flag for date-based version filtering by @jdx in
[#7298](#7298)

### 🐛 Bug Fixes

- **(aqua)** support cosign v3 bundle verification by @jdx in
[#7314](#7314)
- **(config)** use correct config_root in tera context for hooks by @jdx
in [#7309](#7309)
- **(nu)** fix nushell deactivation script on Windows by @fu050409 in
[#7213](#7213)
- **(python)** apply uv_venv_create_args in auto-venv code path by @jdx
in [#7310](#7310)
- **(shell)** escape exe path in activation scripts for paths with
spaces by @jdx in [#7315](#7315)
- **(task)** parallelize exec_env loading to fix parallel task execution
by @jdx in [#7313](#7313)
- track downloads for python and java by @jdx in
[#7304](#7304)
- include full tool ID in download track by @jdx in
[#7320](#7320)

### 📚 Documentation

- Switch `postinstall` code to be shell-agnostic by @thejcannon in
[#7317](#7317)

### 🧪 Testing

- **(e2e)** disable debug mode by default for windows-e2e by @jdx in
[#7318](#7318)

### New Contributors

- @fu050409 made their first contribution in
[#7213](#7213)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants