Skip to content

Fix dotnet host resolution under proot (ARM64/Termux) - #16462

Open
Azat Mukhametshin (azat-msft) wants to merge 2 commits into
microsoft:mainfrom
azat-msft:azat-msft/fix-16446-proot-dotnet-host-resolution
Open

Fix dotnet host resolution under proot (ARM64/Termux)#16462
Azat Mukhametshin (azat-msft) wants to merge 2 commits into
microsoft:mainfrom
azat-msft:azat-msft/fix-16446-proot-dotnet-host-resolution

Conversation

@azat-msft

Copy link
Copy Markdown
Member

Fixes #16446

Problem

Running dotnet test inside proot (commonly used to run a Linux distribution on Android via Termux) fails with:

Could not find a 'dotnet' host for the 'ARM64' architecture.

ProcessHelper.GetCurrentProcessFileName() returns Process.MainModule?.FileName. Under proot this can be proot's injected loader rather than the running dotnet host. DotnetTestHostManager and DotnetHostHelper then stop recognizing the current process as dotnet, fall back to searching DOTNET_ROOT* and the well-known install locations, and ultimately fail.

The reporter observed all three values in one process:

API Value
Environment.ProcessPath /usr/share/dotnet/dotnet
/proc/self/exe /usr/share/dotnet/dotnet
Process.MainModule.FileName .../libexec/proot/loader

Root cause

.NET builds MainModule from /proc/<pid>/maps. ProcessManager.GetModules (ProcessManager.Linux.cs) parses that file and then tries to hoist the main executable to index 0 by matching module.FileName == Process.GetExePath(pid). MainModule is simply Modules[0].

Two conditions must hold at the same time for the wrong value to be returned:

  1. Guest/host path mismatch. proot translates readlink("/proc/self/exe") to the guest path, but it cannot rewrite the contents of /proc/<pid>/maps, which keep the host path. When the two strings differ the hoist silently fails, so Modules[0] is whatever is mapped lowest.

  2. The loader is mapped below the executable. From proot's src/arch.h:

    Arch LOADER_ADDRESS EXEC_PIC_ADDRESS Lowest mapping
    x86_64 0x600000000000 0x500000000000 executable ✅
    ARM64 0x2000000000 0x3000000000 loader
    ARM EABI 0x10000000 0x0f000000 executable ✅
    x86 0xa0000000 0x0f000000 executable ✅

ARM64 is the only architecture where proot maps its loader below the executable, which is why this reproduces on ARM64 (Android/Termux) and not on x64.

Note that setting DOTNET_ROOT does not work around it: DotnetHostHelper.IsValidArchitectureMuxer only inspects Windows and macOS executable formats, so a configured Linux dotnet is rejected as well. That gap is left as-is here and is worth a separate look.

Fix

Prefer Environment.ProcessPath on .NET. It does not depend on /proc maps ordering, and proot reports it correctly. Guarded with #if NET so .NET Framework keeps the existing MainModule behavior, with a fallback to MainModule if ProcessPath is ever null.

Validation

Reproduced with real proot 5.4.1 on x64 Linux (WSL2) by forcing both required conditions — a guest/host path mismatch, plus the genuine proot loader relocated below the executable via PROOT_LOADER to mirror the ARM64 layout. No process information was mocked or simulated.

Probe under those conditions, matching the report exactly:

Environment.ProcessPath: /usr/share/dotnet/dotnet
MainModule.FileName:     .../libexec/proot/loader

End-to-end dotnet test, where the only difference is this change (built assembly injected into a copy of an SDK):

Build Result
Baseline Could not find 'dotnet' host for the 'X64' architecture. / Test Run Aborted
With fix Passed! - Failed: 0, Passed: 1

The fixed build was also confirmed to work outside proot and inside plain proot, so there is no behavior change in normal environments.

Tests

Added GetCurrentProcessFileNameShouldReturnThePathOfTheRunningExecutable to ProcessHelperTests, using conditional compilation so it compiles and passes on both target frameworks (Environment.ProcessPath does not exist on .NET Framework, which would otherwise break the net481 build with CS0117).

Local Release build: 0 warnings, 0 errors across all target frameworks.

Unit test suites touching this code path, compared against the same build with the change stashed (the pre-existing failures are Windows-only tests that do not run correctly on Linux):

Suite Baseline With fix
vstest.console.UnitTests 640 total, 5 failed 641 total, 5 failed (+1 new test passing)
Microsoft.TestPlatform.CoreUtilities.UnitTests 201 total, 2 failed 201 total, 2 failed
Microsoft.TestPlatform.CrossPlatEngine.UnitTests 735 total, 8 failed 735 total, 8 failed

Remaining uncertainty

The reproduction was done on x64 by emulating ARM64's loader placement. I did not have an ARM64 Android/Termux device available, so a confirmation on the reporter's original setup would be welcome.

ProcessHelper.GetCurrentProcessFileName used Process.MainModule.FileName to
identify the running executable. Under proot the value can be the injected
proot loader instead of the running dotnet host, so DotnetTestHostManager and
DotnetHostHelper stop recognizing the current process as dotnet, fall back to
searching DOTNET_ROOT* and the well known installation folders, and finally
fail with:

  Could not find 'dotnet' host for the '<arch>' architecture.

.NET builds MainModule from /proc/<pid>/maps. proot translates
readlink("/proc/self/exe") to the guest path, but it cannot rewrite the
contents of /proc/<pid>/maps, which keep the host path. When the two differ,
ProcessManager.GetModules cannot match the executable and leaves the lowest
mapped module first. On ARM64 proot maps its loader below the executable
(LOADER_ADDRESS 0x2000000000 < EXEC_PIC_ADDRESS 0x3000000000, see proot
src/arch.h), so that first module is the loader. On x64 the order is reversed,
which is why this only reproduces on ARM64.

Prefer Environment.ProcessPath on .NET. It does not depend on the /proc maps
ordering and proot reports it correctly. The change is guarded by #if NET so
.NET Framework keeps the existing MainModule behavior, and it falls back to
MainModule when ProcessPath is null.

Verified with real proot 5.4.1 by reproducing both required conditions on x64
(guest/host path mismatch plus a loader relocated below the executable):
baseline aborts with the error above, the fixed build runs the tests. No
behavior change outside proot.

Fixes microsoft#16446

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings September 9, 2026 15:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new unit test currently assumes Environment.ProcessPath is always non-null on .NET, despite the production code explicitly supporting a fallback path.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR addresses dotnet host resolution failures when running dotnet test inside proot (notably on ARM64/Termux) by making ProcessHelper.GetCurrentProcessFileName() prefer Environment.ProcessPath on .NET, avoiding incorrect Process.MainModule.FileName values when proot injects a loader.

Changes:

  • Update ProcessHelper.GetCurrentProcessFileName() to return Environment.ProcessPath on .NET (with a fallback to MainModule when unavailable).
  • Add a unit test validating GetCurrentProcessFileName() behavior across TFMs.
File summaries
File Description
src/Microsoft.TestPlatform.PlatformAbstractions/common/System/ProcessHelper.cs Prefer Environment.ProcessPath to correctly identify the running executable under proot/sandboxed environments.
test/vstest.console.UnitTests/ProcessHelperTests.cs Add a regression test for GetCurrentProcessFileName() and update class documentation.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread test/vstest.console.UnitTests/ProcessHelperTests.cs Outdated
Environment.ProcessPath is nullable, and GetCurrentProcessFileName falls back
to MainModule when it is null. The test asserted against ProcessPath directly,
so it would have failed in a host where ProcessPath is null even though the
production fallback behaved correctly. Compute the expected value the same way
the production code does instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 9, 2026 15:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

The change is narrowly scoped, matches the documented root cause in proot, and is covered by a new cross-TFM unit test mirroring the production fallback behavior.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

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.

dotnet test fails on Linux under proot when the current process is not detected as the dotnet muxer (ARM64 'dotnet host' not found)

2 participants