Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ exec = "0.3"
mise-brew-relocation = { path = "crates/mise-brew-relocation", version = "0.1" }
nix = { version = "0.31", features = [
"dir",
"feature",
"fs",
"hostname",
"signal",
Expand All @@ -309,6 +310,8 @@ core-foundation-sys = "0.8"
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["consoleapi", "minwindef"] }
windows-sys = { version = "0.61", features = [
"Wdk_System_Threading",
"Win32_Foundation",
"Win32_Globalization",
"Win32_Security",
"Win32_Security_Authorization",
Expand Down
3 changes: 3 additions & 0 deletions docs/cli/prune.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ as will versions only referenced on the command line `mise exec <TOOL>@<VERSION>
Tool stubs that have been executed are tracked in ~/.local/state/mise/tracked-stubs.
Versions still referenced by a tracked stub are not deleted.

Versions that a running process was started from are not deleted either,
so a long-running program keeps its files after its version stops being needed.

You can list prunable tools with `mise ls --prunable`

## Arguments
Expand Down
91 changes: 91 additions & 0 deletions e2e/cli/test_prune_running_process
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# A version that no tracked config or tool stub needs any more can still be in
# use: a long-running process started from it before it was replaced keeps
# loading files from its install directory. Pruning keeps such a version until
# the process exits. Windows has its own test in e2e-win.

held=""
trap '[[ -n $held ]] && kill "$held" 2>/dev/null' EXIT

# Stand in for a program started from the version before it stopped being
# needed: a copy of sleep inside the version's own install directory. The copy
# keeps its name because multi-call coreutils and busybox pick the program to
# run from it, and stays out of bin/ so mise does not create a shim for it.
start_held_process() {
mkdir -p "$1/libexec"
cp "$(command -v sleep)" "$1/libexec/sleep"
if [[ "$(uname -s)" == "Darwin" ]]; then
# A copy of a system binary is killed on launch until it is signed again.
codesign --force --sign - "$1/libexec/sleep" 2>/dev/null
fi
"$1/libexec/sleep" 120 &
held=$!
# Wait until the copy is the running program so the checks below do not
# race its exec, and stop here if it could not start.
local expected attempt running
expected="$(realpath "$1/libexec/sleep")"
for attempt in {1..50}; do
if [[ -e /proc/$held/exe ]]; then
running="$(readlink -f "/proc/$held/exe" 2>/dev/null)"
else
running="$(realpath "$(ps -o comm= -p "$held" 2>/dev/null)" 2>/dev/null)"
fi
Comment thread
jdx marked this conversation as resolved.
if [[ $running == "$expected" ]]; then
return
fi
sleep 0.1
done
fail "the stand-in process did not start after $attempt checks"
}

stop_held_process() {
kill "$held"
wait "$held" 2>/dev/null || true
held=""
}

install="$MISE_DATA_DIR/installs/dummy/1.0.0"

echo "=== mise prune keeps a version a process is running from ==="
# Installed on its own, so no tracked config mentions dummy at all.
assert "mise install dummy@1.0.0"
start_held_process "$install"

assert_not_contains "mise ls --prunable" "1.0.0"
assert_contains "mise prune --tools --dry-run 2>&1" \
"dummy@1.0.0 is kept: still running as pid $held (sleep)"
assert_not_contains "mise prune --tools --dry-run 2>&1" "[dryrun]"
# Nothing is left to prune, so --dry-run-code succeeds.
assert_succeed "mise prune --tools --dry-run-code"
MISE_YES=1 assert_contains "mise prune --tools 2>&1" "dummy@1.0.0 is kept"
assert "ls $install"

# Once the process exits, the version is unused again.
stop_held_process
MISE_YES=1 assert_not_contains "mise prune --tools 2>&1" "is kept"
assert_fail "ls $install"

echo "=== deferred pruning after an upgrade waits for the process ==="
cat <<'EOF' >mise.toml
[tools]
dummy = "1"

[settings]
upgrade.prune_after = "0s"
EOF

mise install dummy@1.0.0
start_held_process "$install"
mise upgrade dummy
# The grace period is over, but the receipt is kept while the process runs.
assert_contains "MISE_DEBUG=1 mise current 2>&1" "dummy@1.0.0 because it is still in use"
assert "ls $install"
assert "test -f '$MISE_STATE_DIR/tool-purgatory.json'"

# After the process exits, the next due check removes the version.
stop_held_process
jq '.entries[].remove_after = 0' "$MISE_STATE_DIR/tool-purgatory.json" >purgatory.json
mv purgatory.json "$MISE_STATE_DIR/tool-purgatory.json"
mise current >/dev/null
assert_fail "ls $install"
assert_fail "test -f '$MISE_STATE_DIR/tool-purgatory.json'"
3 changes: 3 additions & 0 deletions man/man1/mise.1
Original file line number Diff line number Diff line change
Expand Up @@ -7579,6 +7579,9 @@ as will versions only referenced on the command line `mise exec <TOOL>@<VERSION>
Tool stubs that have been executed are tracked in ~/.local/state/mise/tracked\-stubs.
Versions still referenced by a tracked stub are not deleted.

Versions that a running process was started from are not deleted either,
so a long\-running program keeps its files after its version stops being needed.

You can list prunable tools with `mise ls \-\-prunable`
.PP
\fBUsage:\fR mise prune [OPTIONS] [<INSTALLED_TOOL>] ...
Expand Down
3 changes: 3 additions & 0 deletions mise.usage.kdl
Original file line number Diff line number Diff line change
Expand Up @@ -5002,6 +5002,9 @@ as will versions only referenced on the command line `mise exec <TOOL>@<VERSION>
Tool stubs that have been executed are tracked in ~/.local/state/mise/tracked-stubs.
Versions still referenced by a tracked stub are not deleted.

Versions that a running process was started from are not deleted either,
so a long-running program keeps its files after its version stops being needed.

You can list prunable tools with `mise ls --prunable`
"""#
flag "-n --dry-run" help="Do not actually delete anything"
Expand Down
5 changes: 3 additions & 2 deletions settings.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4006,8 +4006,9 @@ mise-managed interpreter, for example — and the old version is left in place w
removal.

Versions that another tracked config or tool stub still needs are kept regardless of this setting.
Once such a version is due, mise checks it again at most once a day, so it can be pruned up to a
day after the last reference to it goes away.
So are versions that a running process was started from, even after the grace period
ends. Once such a version is due, mise checks it again at most once a day, so it can be
pruned up to a day after the last reference to it goes away or the last process using it exits.
`mise upgrade --prune` removes the replaced version immediately, and `--no-prune` leaves it in
place without scheduling removal.
"""
Expand Down
38 changes: 35 additions & 3 deletions src/cli/prune.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ use crate::config::tracking::Tracker;
use crate::config::{Config, Settings};
use crate::file::display_path;
use crate::runtime_symlinks;
use crate::toolset::{NeededVersions, ToolVersion, prunable_tools, prunable_tools_with_sources};
use crate::toolset::{
NeededVersions, PrunableTools, RunningProcess, ToolVersion, prunable_tools_with_sources,
};
use crate::ui::install_progress::removal_progress;
use crate::ui::multi_progress_report::MultiProgressReport;
use crate::ui::prompt::{self, Confirmation};
Expand All @@ -27,6 +29,9 @@ use super::trust::Trust;
/// Tool stubs that have been executed are tracked in ~/.local/state/mise/tracked-stubs.
/// Versions still referenced by a tracked stub are not deleted.
///
/// Versions that a running process was started from are not deleted either,
/// so a long-running program keeps its files after its version stops being needed.
///
/// You can list prunable tools with `mise ls --prunable`
#[derive(Debug, usage_rs::Args)]
#[usage(
Expand Down Expand Up @@ -83,7 +88,12 @@ impl Prune {
.as_ref()
.map(|it| it.iter().map(|ta| ta.ba.as_ref()).collect());
let tools = backends.unwrap_or_default();
let (to_delete, needed) = prunable_tools_with_sources(&config, tools).await?;
let PrunableTools {
to_delete,
needed,
running,
} = prunable_tools_with_sources(&config, tools).await?;
explain_running(&running);
let has_work = !to_delete.is_empty();
let explain = self.is_dry_run().then_some(&needed);
delete(
Expand Down Expand Up @@ -130,7 +140,10 @@ pub(super) async fn prune(
tools: Vec<&BackendArg>,
dry_run: bool,
) -> Result<()> {
let to_delete = prunable_tools(config, tools).await?;
let PrunableTools {
to_delete, running, ..
} = prunable_tools_with_sources(config, tools).await?;
explain_running(&running);
delete(
config,
dry_run,
Expand Down Expand Up @@ -234,6 +247,25 @@ pub(crate) fn removal_key(tv: &ToolVersion) -> String {
}
}

/// Say which versions are kept because processes are still running from them.
/// Nothing tracked needs these, so without this a version left behind after a
/// prune would look like a mistake.
fn explain_running(running: &[(ToolVersion, Vec<RunningProcess>)]) {
const SHOWN: usize = 3;
for (tv, processes) in running {
let mut held_by = processes
.iter()
.take(SHOWN)
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
if processes.len() > SHOWN {
held_by.push_str(&format!(" and {} more", processes.len() - SHOWN));
}
info!("{} is kept: still running as {held_by}", tv.style());
}
}

/// Say why `tv` is up for removal.
///
/// Pruning decides by absence — a version goes because nothing among the
Expand Down
5 changes: 3 additions & 2 deletions src/tool_purgatory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,9 @@ pub async fn auto_prune() -> Result<()> {
}
} else if installed_paths.contains(install_path) {
// Keep the receipt while a tracked config or tool stub needs this
// version. It may become prunable again after that reference goes
// away, without another upgrade to create a fresh receipt.
// version, or a process is still running from it. It may become
// prunable again after that reference goes away or the process
// exits, without another upgrade to create a fresh receipt.
debug!("keeping deferred {display} because it is still in use");
entries_to_recheck.push((key, entry));
} else {
Expand Down
45 changes: 38 additions & 7 deletions src/toolset/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use std::{
use tokio::sync::OnceCell;

pub use install_options::InstallOptions;
pub use running::RunningProcess;
pub use tool_deps::ensure_compatible_install_requests;
pub use tool_request::ToolRequest;
pub use tool_request_set::{
Expand All @@ -55,6 +56,7 @@ mod helpers;
mod install_options;
pub mod install_state;
pub mod outdated_info;
mod running;
mod tool_deps;
pub(crate) mod tool_request;
mod tool_request_set;
Expand Down Expand Up @@ -1073,17 +1075,29 @@ pub async fn prunable_tools(
config: &Arc<Config>,
tools: Vec<&BackendArg>,
) -> Result<Vec<(Arc<dyn Backend>, ToolVersion)>> {
Ok(prunable_tools_with_sources(config, tools).await?.0)
Ok(prunable_tools_with_sources(config, tools).await?.to_delete)
}

/// Like [`prunable_tools`], but also returns what the tracked configs and stubs
/// still need. Pruning removes what none of them named, so the versions that
/// were kept — and the files that kept them — are the only evidence available
/// for explaining a removal.
/// The outcome of deciding which installed versions are unused.
pub struct PrunableTools {
/// Versions that nothing tracked or running needs.
pub to_delete: Vec<(Arc<dyn Backend>, ToolVersion)>,
/// What the tracked configs and stubs still need, and the files that need it.
pub needed: NeededVersions,
/// Versions no tracked config or stub needs, kept because processes are
/// still running from them.
pub running: Vec<(ToolVersion, Vec<RunningProcess>)>,
}

/// Like [`prunable_tools`], but also returns why the other versions are kept:
/// what the tracked configs and stubs still need, and which versions running
/// processes hold. Pruning removes what none of them named, so the versions
/// that were kept — and what kept them — are the only evidence available for
/// explaining a removal.
pub async fn prunable_tools_with_sources(
config: &Arc<Config>,
tools: Vec<&BackendArg>,
) -> Result<(Vec<(Arc<dyn Backend>, ToolVersion)>, NeededVersions)> {
) -> Result<PrunableTools> {
let ts = ToolsetBuilder::new().build(config).await?;
let mut to_delete = ts
.list_installed_versions(config)
Expand Down Expand Up @@ -1114,7 +1128,24 @@ pub async fn prunable_tools_with_sources(
to_delete.remove(key);
}

Ok((to_delete.into_values().collect(), needed))
// A process started before its version stopped being needed may still be
// running from it. Removing the version would delete files from under it.
let candidates = to_delete.values().map(|(_, tv)| tv).collect::<Vec<_>>();
let mut processes = running::processes_running_from(&candidates);
let mut running = vec![];
to_delete.retain(|_, (_, tv)| match processes.remove(&tv.install_path()) {
Some(procs) => {
running.push((tv.clone(), procs));
false
}
None => true,
});

Ok(PrunableTools {
to_delete: to_delete.into_values().collect(),
needed,
running,
})
}

async fn is_version_satisfied(
Expand Down
Loading
Loading