Skip to content

Fix ambiguous SDK uninstall matching and missing --install-path - #270

Merged
Redth merged 5 commits into
mainfrom
redth-cuddly-waffle
Sep 25, 2026
Merged

Redth merged 5 commits into
mainfrom
redth-cuddly-waffle

Conversation

@Redth

@Redth Redth commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Why

Uninstalling a tracked .NET SDK channel (e.g. 11.0.1xx) via dotnetup could fail or, worse, target the wrong install root or the wrong SDK entirely, depending on what else was installed:

  1. dotnetup commands built by Sherpa never passed --install-path, so they silently used dotnetup's own hardcoded default install root instead of the root the tracked spec actually lives in. If the user's real SDKs live at a different (e.g. user-configured default) install path, the uninstall command fails with "No .NET SDK install spec found", even though the spec clearly exists.
  2. Separately, a coarse channel spec like 11.0.1xx can match more than one installed prerelease SDK sharing that coarse version (e.g. a preview.7 build alongside an rc.2 build). The existing matching logic only compared major/minor/patch-hundreds and ignored prerelease labels, so it could silently resolve to the wrong sibling SDK.

While testing this, several related Doctor / SDK management issues turned up and are fixed here too (see below).

Approach

  • DotnetSdkPresentationBuilder.FindTrackedSdkSpec now accepts the full list of installations and cross-checks each sibling's SdkFeatureBand (already used elsewhere for workload isolation). If a coarse spec could ambiguously resolve to siblings with different prerelease bands, it returns null instead of guessing, and DotnetSdk.razor shows an alert directing the user to use Untrack instead.
  • DotnetUpArguments.SdkUninstall / RuntimeUninstall now accept an optional installPath and emit --install-path when provided. DotnetUpService and the DotnetSdk.razor uninstall/untrack call sites now pass the tracked spec's own InstallRoot, so the command always targets the root the spec is actually tracked in.

Additional fixes

  • Workload inventory timeouts: DotnetWorkloadService.GetInventoriesAsync ran dotnet workload commands for every SDK feature band at the same time. The concurrent CLI processes contended badly enough that dotnet workload list --machine-readable could exceed the 45s timeout. Reads are now throttled to 2 at a time with a SemaphoreSlim, the same pattern PublishProfileService uses.
  • Doctor JS interop crash: BackgroundTaskCenter.OnAfterRenderAsync made JS interop calls without the disposal guards DisposeAsync already had, so a disposal race surfaced as "JS object instance with ID 1 does not exist". It now checks a _disposed flag and catches the same disposed-object exceptions.
  • Doctor workload-set Fix did nothing: preparing the update can take tens of seconds with no feedback, and a second click threw an unobserved "A process modal is already being shown". Doctor now shows a loading state and ignores repeat clicks while a fix runs. It also turns that exception into a toast. ProcessModalService.ShowProcessAsync now always releases its visibility gate in a finally.
  • Installed SDK detection: LocalSdkService.GetInstalledSdkVersions counted any version-named folder under sdk/ as installed. It now requires dotnet.dll, dotnet.runtimeconfig.json and Sdks/, so leftovers from partial installs or uninstalls are skipped.

Notes for reviewers

  • Verified manually against a live dotnetup environment with SDKs tracked across multiple install roots, and by rebuilding and exercising the macOS app.
  • New unit tests: the ambiguity check (DotnetSdkPresentationBuilderTests), --install-path argument construction (DotnetUpArgumentsTests) and incomplete-SDK filtering (LocalSdkServiceTests). The Core suite passes (649 tests).
  • LocalSdkServiceTests.GetInstalledManifestAsync_ResolvesManifestFromRemappedFeatureBand fails on my machine both with and without these changes, because it reads the host's real SDK state.

Redth and others added 5 commits September 23, 2026 13:40
Two related dotnetup SDK uninstall bugs:

- FindTrackedSdkSpec now cross-checks sibling installations' SdkFeatureBand
  so a coarse channel spec (e.g. 11.0.1xx) that could resolve to more than
  one sibling prerelease SDK (e.g. preview.7 vs rc.2) is treated as
  ambiguous and blocks the uninstall instead of guessing, with an updated
  alert explaining why and pointing at Untrack as the safe path.

- DotnetUpArguments.SdkUninstall/RuntimeUninstall (and the DotnetUpService/
  DotnetSdk.razor call sites) never passed --install-path, so uninstall
  requests silently targeted dotnetup's own hardcoded default root instead
  of wherever the tracked spec actually lives. Threaded the spec's own
  InstallRoot through to fix this.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sherpa was scanning every installed SDK's workload state concurrently
and unthrottled (Task.WhenAll over all targets) whenever the workload
inventory refreshed, e.g. on app startup. Logs confirmed a
`dotnet workload list --machine-readable` invocation for feature band
11.0.100-rc.1 stalled past the hard 45s timeout during a burst of five
simultaneous `dotnet` invocations right after launch, even though the
exact same command completed in ~1s when run in isolation with the
same DOTNET_ROOT/PATH. Concurrent `dotnet` CLI invocations can contend
for shared per-user resources (NuGet HTTP cache locks, first-run
experience markers, etc.), which is consistent with one invocation
serializing behind the others long enough to hit the timeout.

Limit concurrent inventory reads to 2 in flight at a time, following
the existing SemaphoreSlim throttling pattern already used in
PublishProfileService.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Navigating to Doctor could log an unhandled JS interop exception
("JS object instance with ID 1 does not exist (has it been
disposed?).") during component disposal. The failing call originated
from OnAfterRenderAsync's first-render ensureStylesheets/bindPopover
JS interop, which had no guard against the component being disposed
while an await was still in flight -- unlike DisposeAsync, which
already handles this exact JSException/JSDisconnectedException
pattern.

Add a _disposed flag checked before and after each JS interop await
in OnAfterRenderAsync, and wrap the method body in the same
disposed-object exception handling already used in DisposeAsync, so a
disposal race no longer surfaces as an unhandled exception.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Clicking Fix on a "Workload Set" update (or a version tag) could
appear to do nothing. The app log showed why: resolving the workload
update request runs several `dotnet workload search version`
invocations that can take tens of seconds in a contended/slow dotnet
environment, and nothing disabled the Fix button or gave feedback
while that ran. A user who thought their click hadn't registered
would click again, and the second call raced ProcessModalService's
single in-flight guard, throwing "A process modal is already being
shown" -- as an unobserved task exception with no visible symptom.

- Doctor.razor: FixDependency now guards re-entrancy and immediately
  shows the loading state (matching the existing full-scan behavior)
  before dispatching to any fix path, so a slow fix visibly runs and
  can't be double-clicked into this race. UpdateToVersion (also
  reachable directly from the version-tag click handlers) sets the
  same loading state, and those version tags are now disabled/inert
  while a fix is in progress. Also catch the
  "already showing" InvalidOperationException there so, if it does
  happen, it surfaces as a toast instead of an unobserved exception.
- ProcessModalService.ShowProcessAsync: wrap the body in try/finally
  so IsVisible/CurrentRequest are always released even if pushing the
  native modal page or awaiting completion throws, so a single failure
  can't permanently wedge every future Fix click behind this guard.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
GetInstalledSdkVersions treated any version-named folder under sdk/ as
an installed SDK. Leftovers from interrupted installs or partial
uninstalls were therefore reported as installed. Only count a folder
when it contains dotnet.dll, dotnet.runtimeconfig.json and Sdks/.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@Redth
Redth merged commit ce13f95 into main Sep 25, 2026
9 checks passed
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.

1 participant