fix(backend): stop fuzzy requests installing literal dirs#9276
Conversation
There was a problem hiding this comment.
Code Review
This pull request modifies the upgrade logic in src/cli/upgrade.rs to force the reinstallation of outdated tools, ensuring that channel-based versions like @latest or nightly are correctly updated even if their installation directories already exist. The review feedback identifies a potential failure when global locked settings are enabled and suggests explicitly setting locked: false in the InstallOptions to allow the installation of new versions before the lockfile is updated.
| force: true, | ||
| jobs: self.jobs, | ||
| raw: self.raw, | ||
| resolve_options: ResolveOptions { |
There was a problem hiding this comment.
When Settings::get().locked is enabled (e.g., via global configuration), InstallOptions will inherit locked: true by default. This causes mise upgrade to fail during the installation of new versions because they are not yet present in the lockfile with their associated metadata (like URLs and checksums). Since the purpose of upgrade is to resolve and install new versions and subsequently update the lockfile, it should explicitly set locked: false to bypass this verification during the installation phase.
Additionally, note that setting force: true globally in InstallOptions will cause any already-installed dependencies of the outdated tools to be reinstalled as well. While this ensures a clean state, it might lead to unnecessary work if a tool has many dependencies that are already up-to-date.
| force: true, | |
| jobs: self.jobs, | |
| raw: self.raw, | |
| resolve_options: ResolveOptions { | |
| force: true, | |
| jobs: self.jobs, | |
| raw: self.raw, | |
| locked: false, | |
| resolve_options: ResolveOptions { |
Greptile SummaryThis PR fixes two root-cause bugs behind stale literal install directories: Confidence Score: 4/5Safe to merge with two minor P2 items; no blocking correctness issues remain. All remaining findings are P2: a narrower channel-dir filter in the e2e/tools/test_runtime_symlink_migration — test does not exercise stale-dir migration; src/backend/mod.rs — Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[mise install / upgrade] --> B{ToolRequest type?}
B -->|Version / Prefix| C[request.install_path]
C --> D{path exists?}
D -->|No| G[check tv.install_path\nresolved concrete path]
D -->|Yes| E{request basename\n== tv.version?}
E -->|Yes| F[✓ already installed]
E -->|No — stale dir| G
G --> H{concrete path exists?}
H -->|Yes| F
H -->|No| I[run backend install hook\nwrites installs/id/2.0.0/]
J[startup / mise run] --> K[migrate_runtime_symlink_dirs\none-time, marker gated]
K --> L[migrate_real_dirs_in_dir\nfor each backend]
L --> M{from in\nconcrete_installs?}
M -->|Yes| N[skip — real concrete install]
M -->|No| O[remove_all stale dir\nmake_symlink_or_file]
P[rebuild runtime symlinks] --> Q[rebuild_symlinks_in_dir]
Q --> R{is_runtime_symlink?}
R -->|Yes, wrong target| S[remove symlink → recreate]
R -->|No — real dir| T{from_name != to_name\nAND not concrete?}
T -->|Yes| U[remove_all stale dir → recreate]
T -->|No| V[continue]
Reviews (8): Last reviewed commit: "fix(backend): preserve concrete installs..." | Re-trigger Greptile |
|
You should add an e2e test that fails without this change, but passes with this change. I use |
|
I haven't looked deep but I suspect this will break shit. I'm surprised the e2e tests passed. |
… pins `is_version_installed` short-circuits on `tv.request.install_path()`, which is derived from the REQUEST version. For a channel pin like `@latest`, the request path is `installs/<id>/latest/`. If that dir exists as a real directory (not a runtime symlink) — e.g. a prior install left `tv.version` set to the literal string "latest" because resolve fell back to the alias (offline, transient remote 404, etc.) — the early return fires and the backend's install hook never runs. `mise upgrade` prints `✓ installed <new-version>` while the on-disk binary is stale. An existing guard handled this for `ToolRequest::Prefix` by comparing `install_path.file_name()` against `tv.version` and falling through to the resolved path when they differ. Extend the same guard to `ToolRequest::Version`, which covers both `@latest` and any other alias that resolves to a concrete version. Adds `e2e/cli/test_upgrade_latest_stale` reproducing the scenario: a real `installs/dummy/latest/` dir with a sentinel file in place before `mise up`. Before the fix the install silently skipped; with the fix, `installs/dummy/2.0.0/` is created and `mise x dummy -- dummy` runs the new binary. Refs: jdx#9275
9ffdfe1 to
f1e70bc
Compare
|
@risu729 @jdx — you were both right, the TL;DR: The early return compares This answers @risu729's "doesn't it force-reinstall even when the current version is the same as the latest?" — it no longer does. It also answers why you haven't hit this with most E2e regression test included per @risu729's request: |
|
oh is this why the "latest" as directory thing started happening? we definitely need to fix this |
|
@jdx Confirmed — that's the mechanism. Three links in the chain:
So the current PR makes Pushing a follow-up commit on this branch: after Two calls for you:
|
Extends the `is_version_installed` fix (f1e70bc) to repair the on-disk state so the stale alias dir can never silently shadow the resolved install again. Chain of bugs: 1. `is_version_installed` short-circuited on `tv.request.install_path()` — FIXED by f1e70bc (check resolved path for channel pins). 2. `runtime_symlinks::rebuild` deliberately refuses to overwrite real directories with a symlink — intentional safety rail. 3. Consequence of (2): once `installs/<id>/latest/` exists as a real dir (offline first-install, transient remote 404, etc.), `latest/` stays a real dir forever. `latest_installed_version` (src/backend/mod.rs ~line 855) even returns the literal `"latest"` string for it, self-reinforcing the stale state. The fix runs inside `install_version` after `install_version_` succeeds and writes backend metadata. It is gated by five guards that together limit the action to the exact bug surface: - `ToolRequest::Version | Prefix` only (channel pins; skips Ref, Sub, Path, System) - request basename != resolved basename (it is actually a channel pin, not a direct `@2.0.0`) - request path and resolved path differ (not the trivial case) - request path lives under `tv.ba().installs_path` (NEVER touch `--system` / `--shared` dirs; @jdx flagged this explicitly) - request path exists - request path is NOT already a runtime symlink (idempotent; re-runs of `mise install` do nothing) When all guards pass, `remove_all_with_warning` nukes the stale real dir and `make_symlink_or_file` creates `./<resolved-version>` in its place — a plain relative runtime symlink, identical to what `runtime_symlinks::rebuild` would have written on a clean install. Windows falls back to a text file, handled by the helper. Addresses @jdx's "'latest' as a directory" concern from PR jdx#9276 review: after this fix, the directory literally cannot remain as a real dir past a successful install. e2e test updated to lock the invariant with `test -L $MISE_DATA_DIR/installs/dummy/latest` alongside the existing binary-version assertion. Refs: jdx#9275, jdx#9276
9d050e6 to
f1e70bc
Compare
|
I want some time to look this over properly but should get back to you soon. Nice work identifying this. |
|
I dug into the regression path a bit more and found the behavior split across two changes:
To address both the new and existing bad states, I pushed a follow-up commit on this branch (
That handles existing installs with low ongoing blast radius, which seems to match the concern here better than a perpetual broad cleanup. I also left a removal note on the migration so it can be deleted after the grace period. Locally verified with:
This comment was generated by an AI coding assistant. |
## Summary
Fixes the `test_go_install_slow` failure on the 2026.4.19 release PR
where `mise x go:github.com/go-kratos/kratos/cmd/kratos/v2@latest --
bash -c 'kratos --help 2>&1'` exits 127 (`kratos: command not found`).
## Root Cause
`parse_goproxy()` in [src/backend/go.rs](src/backend/go.rs:441) used
`env::var(\"GOPROXY\").unwrap_or_else(...)`. `unwrap_or_else` only fires
when the variable is *unset*; it does not fire when the variable is set
to the empty string.
The e2e harness forwards `GOPROXY=\"\${GOPROXY:-}\"` in
[e2e/run_test:84](e2e/run_test:84), which evaluates to an empty string
inside the docker container. That empty string feeds into
`parse_goproxy_value(\"\")`, which returns no proxies, so:
1. `fetch_proxy_versions` returns `None`.
2. The `go list -m -versions` fallback returns an empty `Versions` list
for submodules without tagged releases (e.g.
`github.com/go-kratos/kratos/cmd/kratos/v2`).
3. `_list_remote_versions` returns `[]` and mise prints `WARN No
versions found for go:…`.
This bug was latent. Before
[#9276](#9276), `resolve_version` fell
through to `resolve_prefix(\"latest\")`, which resolved to the literal
string `\"latest\"` — and `go install …@latest` handled it natively.
After #9276, resolution errors with `no versions found` and `mise x`
never installs the tool, so `kratos` is not on PATH → `bash -c 'kratos
--help'` exits 127.
Go's own tooling treats `GOPROXY=` identically to unset:
\`\`\`
\$ env GOPROXY= go env GOPROXY
https://proxy.golang.org,direct
\`\`\`
## Fix
Treat both unset and empty `GOPROXY` as the default.
\`\`\`rust
let goproxy = std::env::var(\"GOPROXY\")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_GOPROXY.to_string());
\`\`\`
Added unit test `parse_goproxy_empty_uses_default`.
## Validation
- \`cargo test --bin mise backend::go::tests\` — all 12 tests pass,
including the new one.
- Reproduced the failure locally with \`env -i GOPROXY= … mise ls-remote
go:github.com/go-kratos/kratos/cmd/kratos/v2\` (returned nothing before
the fix, now returns \`2.0.0-20260404020628-f149714c1d54\`).
Failing run this fixes:
https://github.com/jdx/mise/actions/runs/24803479152/job/72592666111
*This PR was generated by an AI coding assistant.*
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Low Risk**
> Low risk: small, localized change to Go backend proxy parsing plus a
unit test; behavior only changes when `GOPROXY` is set to an empty
string.
>
> **Overview**
> Fixes Go backend proxy resolution to treat `GOPROXY=` (empty) the same
as an unset `GOPROXY`, defaulting to `https://proxy.golang.org,direct`
to match `go env` behavior.
>
> Adds a unit test (`parse_goproxy_empty_uses_default`) that temporarily
sets `GOPROXY` to empty and asserts the default proxy is used.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
61d7c9f. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Fix the root cause behind stale real install directories like
installs/<tool>/latest/and explicit prefix requests resolving to literal on-disk directories when mise cannot determine a concrete version.This PR also keeps the one-time migration for existing broken installs and teaches runtime symlink rebuild to replace stale real dirs with the correct symlink once a concrete install exists.
Importantly, this does not blanket-treat every short version like
1.0,4.13, or2026.1as fuzzy.~/src/mise-versionscontains many tools with legitimate 1-part or 2-part stable versions (lua,ffmpeg,staticcheck,ccache,hlint,upx, etc.), so exact short versions must continue to work.Root Cause
There were two separate problems:
resolve_prefixinsrc/toolset/tool_version.rsusedNone => prefix, so an explicit prefix request could resolve to the literal prefix when remote matching returned nothing.latestrequests also had a bad fallback loop throughlatest_installed_versionwheninstalls/<tool>/latestexisted as a real directory.dc840866(fix(env): use runtime symlink paths for fuzzy versions), PATH/bin resolution began preferring request paths likelatest,24, and24.0. That made previously latent bad directories actively win at runtime.There was also a reinforcing loop in
latest_installed_version: ifinstalls/<tool>/latestexisted as a real dir instead of a symlink, mise treated that as installedlatestand kept reusing the alias literally.Changes
src/toolset/tool_version.rslatestand explicitprefix:requests to literal install dirs when no concrete version can be found.1.0,4.13, or2026.1.src/backend/mod.rslatest_installed_version(None)so a reallatest/directory is no longer treated as installedlatest.latest/is not a runtime symlink, fall back to the highest concrete installed version instead.src/runtime_symlinks.rslatest -> ./2.0.0,24 -> ./24.3.1).src/migrate.rsTests
e2e/backend/test_fuzzy_versions_do_not_install_literal_dirsmise install tool@latest,tool@prefix:24, andtool@prefix:24.0fail instead of creating literal install dirs when no concrete version can be resolved.e2e/cli/test_upgrade_latest_stalelatest/dir is repaired to./2.0.0during upgrade and runtime exec uses the new binary.e2e/tools/test_runtime_symlink_migration1.0/andlatest/dirs and writes the marker file.e2e/tools/test_runtime_symlinkslatest_installed_versionignoring a reallatest/dir.Validation
cargo check --all-featurescargo test --all-features test_latest_installed_version_ignores_real_latest_dir -- --nocapturecargo test --all-features runtime_path_does_not_return_file_based_runtime_symlink -- --nocapturemise run test:e2e e2e/backend/test_fuzzy_versions_do_not_install_literal_dirs e2e/cli/test_upgrade_latest_stale e2e/tools/test_runtime_symlink_migration e2e/tools/test_runtime_symlinksNotes
This PR fixes the definite broken cases without regressing tools that genuinely use short exact versions:
latestand explicitprefix:requests no longer create literal install directories when resolution fails