Skip to content

fix(shim): stop a shim from launching itself as its PATH fallback - #280

Merged
CalvinAllen merged 2 commits into
mainfrom
fix/shim/self-launch-loop
Oct 17, 2026
Merged

CalvinAllen merged 2 commits into
mainfrom
fix/shim/self-launch-loop

Conversation

@CalvinAllen

Copy link
Copy Markdown
Contributor

Summary

When a shim can't resolve a version, it falls back to the next match on PATH. That lookup skipped only ShimsDir(), which comes from DTVEM_ROOT or the home directory. When a process ran with a different home than the one the shims on PATH were installed under, the real shims directory wasn't skipped, so the shim found itself and launched itself. That chain grew to thousands of processes and exhausted memory.

This PR applies both fixes the issue suggests.

1. The fallback lookup excludes the running shim

  • New path.LookPathExcludingSelf(execName, self) skips the directory of the running executable (os.Executable(), symlinks resolved) and any candidate that os.SameFiles it. This works whatever the environment says the home is.
  • Both shim fallbacks use it: no configured version, and leftover/unknown shim.
  • LookPathExcludingShims keeps its behavior for migration detection; it does not skip the dtvem binary's own directory, which may be a shared bin dir. Its directory comparison now also matches different spellings of the shims directory: trailing separator, . segments, case, and, through os.SameFile, 8.3 short names and symlinked paths.

2. Recursion guard

  • Before handing off, a shim sets DTVEM_SHIM_FALLBACK=<path it is launching>. A shim that starts and finds itself named there was launched by another shim's fallback, so it exits with an explanation instead of looping.
  • The marker names a file, not a shim name as the issue sketched (DTVEM_SHIM_ACTIVE=node). A name-based marker is inherited by every descendant, so a real system node reached through the fallback that runs a script spawning node again would be wrongly refused. With a path, that nested shim is not the file named, so it runs normally.
  • The guard is needed beyond fix 1 when two dtvem shim directories are on PATH and neither matches the environment. Each shim then skips only itself and finds the other, and without the guard they would launch each other forever.

On the MCP trigger in the issue

A likely cause, not verified: the launch environment had no USERPROFILE. os.UserHomeDir() then fails, ShimsDir() returns "" so nothing is skipped, and getRootDir() falls back to ./.dtvem in the working directory, so resolution fails too. Fix 1 covers that case because it doesn't depend on the home directory.

Tests

  • path: LookPathExcludingSelf (own dir, hard link to self, the old self-finding lookup when no self path is given), shims dir written differently (trailing separator, dot segment, case on Windows, symlink where supported), and SameFile.
  • cmd/shim/fallback_test.go: end-to-end tests that run copies of the test binary as real shim processes. TestMain caps the chain at 8 hops, so a regression fails the test instead of fork-bombing CI. Cases:
    • home differs and nothing else is on PATH: clean "no system installation" error
    • a real executable further down PATH: it runs and receives the marker
    • two shim directories: the guard stops it
    • a real executable that runs node again: not mistaken for the loop
  • With the old lookup and no guard, all four end-to-end tests hit the hop cap (exit 99), so they catch this bug.
  • Format, lint and the full test suite pass locally on Windows.

Resolves #279

Installs downloaded and extracted into os.TempDir() and then used
os.Rename to move the result into the versions directory. When the
dtvem root lives on a different drive than the OS temp directory
(e.g. DTVEM_ROOT on H: with Windows on C:), the rename fails with
"The system cannot move the file to a different disk drive". The same
happens on Unix when /tmp is a separate filesystem such as tmpfs.

Stage installs in <root>/tmp/<runtime>-<version> instead, so the final
move is always a same-volume rename. Leftovers from an interrupted
install are cleared before reuse since the OS no longer cleans them.
When a shim cannot resolve a version it falls back to the next match on
PATH, skipping only ShimsDir(). ShimsDir() is derived from DTVEM_ROOT or
the home directory, so when a process runs with a different home (a
sandbox redirecting USERPROFILE, or no USERPROFILE at all) the real shims
directory was not skipped. The shim found itself and launched itself,
one process per hop, until the machine ran out of memory.

The fallback lookup now also skips the directory of the running shim and
any candidate that is the same file, whatever the environment says the
home is. Directory comparison also matches different spellings of the
same directory (trailing separator, 8.3 short names, symlinks) via
os.SameFile.

As a backstop, a shim records the path it hands off to in
DTVEM_SHIM_FALLBACK. A shim that starts and finds itself named there was
launched by another shim's fallback and exits with an explanation
instead of looping. The marker names a file rather than a shim name so a
real executable reached this way can still start the same command again.
@CalvinAllen
CalvinAllen merged commit 83a3ecf into main Oct 17, 2026
11 checks passed
@CalvinAllen
CalvinAllen deleted the fix/shim/self-launch-loop branch October 17, 2026 17:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: shim launches itself forever when its home differs from the one on PATH

1 participant