Skip to content

Disable package managers whose tool is missing on first run - #5573

Merged
Gabriel Dufresne (GabrielDuf) merged 2 commits into
mainfrom
fix/disable-missing-managers-first-run
Oct 22, 2026
Merged

Gabriel Dufresne (GabrielDuf) merged 2 commits into
mainfrom
fix/disable-missing-managers-first-run

Conversation

@GabrielDuf

Copy link
Copy Markdown
Contributor
  • I have read the contributing guidelines, and I agree with the Code of Conduct.
  • Have you checked that there aren't other open pull requests for the same changes?
  • Have you tested that the committed code can be executed without errors?
  • Have you confirmed that this issue is caused by UniGetUI itself, and not by the package manager or the package involved?

Bug report

The maintainer reported this directly; there is no GitHub issue. The first time UniGetUI opens on a machine, every package manager is enabled by default, including the ones whose tool is not installed. So every new user starts with warnings. Managers should only be enabled when their prerequisite is present.

Root cause

PackageManager.IsEnabled() returns !Settings.GetDictionaryItem(K.DisabledManagers, Name), so a manager is enabled unless an entry says otherwise. On a fresh machine DisabledManagers is empty, so every manager counts as enabled. Initialize() then looks for each executable, and any tool that is not installed (Scoop, Chocolatey, Pip, Npm, Cargo, Vcpkg, ...) ends up enabled with Status.Found == false. That logs "enabled but was not found" and shows an Error "Not found" badge in the Avalonia settings pages and the TUI Managers page. Nothing ever recorded a default enabled state from what was actually detected.

Fix

  • PackageManager.Initialize() calls a new _recordDefaultEnabledState(found) right after _loadManagerExecutableFile. If DisabledManagers already has an entry for the manager, nothing changes: an explicit user choice (true or false) always wins. If there is no entry, it writes DisabledManagers[Name] = !found once.
    • A missing manager then gets the normal "{0} is disabled" status and returns early, the same way an explicitly disabled manager does.
    • A found manager is recorded as enabled, so a later uninstall still shows "Not found" instead of silently disabling it.
    • Because the default is written per manager, managers added in later versions are covered too.
  • The check-and-write runs under a static lock. PEInterface initializes all managers in parallel, and Settings.SetDictionaryItem is a read-modify-write of the whole dictionary file, so without the lock concurrent first-run writes lose each other's entries. With the lock removed, the concurrency test failed in 3 of 3 runs.
  • FakeDataEnvironment.Activate: the --fake-data sandbox now seeds an explicit enabled entry for its fake not-found manager (Cargo, Found: false), but only when no entry exists, so a persisted --fake-data-dir keeps the user's toggles. Without the seed, that manager would be auto-disabled and the fake data set (and Managers_DisableAManager_HidesItsPackages, which waits for "Cargo was not found") would lose the not-found state they exist to show.
  • InitializeEnabledManagerWithoutExecutableStaysNotReady now sets an explicit enabled entry, so it still tests what its name says instead of the first-run default.

Re-enabling a manager after installing its tool still goes through the existing toggles (ManagersHomepage, PackageManagerPage, TUI ManagersPage, IPC), which write the entry explicitly. No new user-facing strings.

Verification

  • Build: UniGetUI.PackageEngine.Tests and UniGetUI.Tui.Tests (with dependencies), -p:Platform=x64: 0 errors. The warnings were already there, in files this PR does not touch.
  • Formatters from the repo root: dotnet format whitespace src --folder --verify-no-changes and dotnet format style src/UniGetUI.Windows.slnx --verify-no-changes both exit 0.
  • Tests (from TRX): UniGetUI.PackageEngine.Tests 2493/2493 passed. UniGetUI.Tui.Tests 225 passed, 0 failed, 1 not executed (DumpPages, a diagnostic that is always skipped).
  • Regression proof: with PackageManager.cs and FakeDataEnvironment.cs stashed, the PackageManagerTests filter gave 18 total, 15 passed, 3 failed, and the 3 failures were exactly the new tests. With only FakeDataEnvironment.cs stashed, Managers_DisableAManager_HidesItsPackages failed. With both restored, everything passes.
  • No UI change, and the Avalonia UI was not verified visually. The existing "disabled" badge and status rendering covers auto-disabled managers.
  • The NativeAOT publish was not run. The change adds no reflection, serialization or interop.

Regression tests

  • UniGetUI.PackageEngine.Tests.PackageManagerTests.FirstInitializeDisablesManagerWhoseExecutableIsMissing
  • UniGetUI.PackageEngine.Tests.PackageManagerTests.FirstInitializeKeepsFoundManagerEnabledAfterItsExecutableDisappears
  • UniGetUI.PackageEngine.Tests.PackageManagerTests.FirstInitializeDisablesOnlyMissingManagersWhenInitializedConcurrently

All three fail without the fix and pass with it.

Review notes (non-blocking)

  • Existing installs: an upgrading user who never toggled a manager also has no entry for it. On the first launch after the update, managers missing at that moment get disabled and found ones get recorded as enabled. That is intended (the same warnings go away). But if a manager is only temporarily missing during that one launch (a broken PATH, for example), it stays disabled until the user turns it back on. There is no global first-run marker, because the per-manager entry also handles managers added in later versions.
  • In-app "Install Scoop" button (PackageManagerPage.axaml.cs, PackageManagerViewModel.cs:143): on a first-run machine Scoop is now recorded as disabled. After installing it and restarting, the user also has to flip the enable toggle on the same page. Before this change, Scoop worked right after the restart. This is a small UX follow-up (the install command could clear the entry), not a regression of the reported case.
  • Settings race: the static lock only serializes first-run default writes against each other. SetDictionaryItem's read-modify-write can still race with a UI or IPC toggle at the same moment. That race already existed and this change does not widen it.
  • There is no onboarding notice telling the user that managers were auto-disabled. That would be a UX decision outside this scope.
  • Checked and unaffected: MissingDependencyDialog only looks at IsReady() managers. Telemetry and InstalledPackagesLoader filter on IsEnabled() && Found. BaseNuGet.Initialize calls base.Initialize, so NuGet managers go through the new path. Other tests that initialize real managers use an isolated data directory override.

🤖 Generated with Claude Code

PackageManager.IsEnabled() returns !DisabledManagers[Name], so a manager
is enabled unless an entry says otherwise. On a fresh machine the
dictionary is empty, every manager counts as enabled, and each tool that
is not installed ends up enabled with Status.Found == false. That shows
an Error "Not found" badge in the settings pages and the TUI Managers
page, so a new user gets a warning for every tool they never installed.

Initialize() now records a default enabled state right after looking for
the executable, but only when DisabledManagers has no entry for the
manager. An explicit user choice always wins. A missing manager is
recorded as disabled and gets the normal "is disabled" status; a found
manager is recorded as enabled, so a later uninstall still shows "Not
found" instead of silently disabling it. Because the default is written
per manager, managers added in later versions are covered too.

PEInterface initializes managers in parallel and SetDictionaryItem is a
read-modify-write of the whole dictionary file, so the check-and-write
runs under a static lock; without it, concurrent first-run writes lose
each other's entries.

The --fake-data sandbox seeds an explicit enabled entry for its fake
not-found manager (Cargo) when none exists, so the fake data set and
the TUI E2E tests keep the not-found state they exist to show.
InitializeEnabledManagerWithoutExecutableStaysNotReady now sets an
explicit enabled entry so it still tests an enabled manager whose
executable is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Cold-cache reads can overwrite newly saved defaults during parallel initialization, and the concurrency test prewarms the cache.

1 open issue
What changed in this PR

This PR records default enabled states from executable detection, reducing first-run warnings for unavailable package managers while preserving explicit user choices.

Changes:

  • Persist per-manager defaults and serialize default writes.
  • Add regression tests for missing tools, remembered states, and concurrent initialization.
  • Preserve the fake-data sandbox’s intentional not-found manager state.
File Description
src/​UniGetUI.Tui/​FakeData/​FakeDataEnvironment.cs Seeds enabled defaults for intentionally missing fake managers.
src/​UniGetUI.PackageEngine.Tests/​PackageManagerTests.cs Tests default selection and preserves explicit-enable coverage.
src/​UniGetUI.PackageEngine.PackageManagerClasses/​Manager/​PackageManager.cs Records detection-based defaults and stops initialization for auto-disabled managers.

Review effort: Balanced

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.

🔵 Needs a closer look

Cold-cache reads remain outside the new lock and can overwrite recorded defaults during parallel initialization.

1 open issue

Review effort: Balanced

Settings._getDictionary read and deserialized the file on a cache miss,
then published the result with a plain assignment. A SetDictionary that
landed in between was replaced in the cache by the older snapshot, and
the next SetDictionaryItem wrote that snapshot back to disk without the
other writer's entry. With managers initializing in parallel on first
run, one manager could load an empty DisabledManagers while another
saved its default under the PackageManager lock, losing that default.

A cold load now publishes through _publishLoadedDictionary, which uses
GetOrAdd. If its own dictionary is the one stored, it returns its value
as before; if a write or another load published first, it reads again
through the cache-hit path and returns the newer value. The fix sits in
the settings engine rather than in a cache warm-up before parallel
initialization, so every cold reader (UI, IPC) is covered. SetDictionary
still assigns directly, since a write always replaces the cache.

The publish step is its own internal method so the race can be tested
deterministically: ADictionaryLoadPublishedAfterAWriteKeepsTheWrite
writes first and publishes a stale load second, the exact interleaving
of the bug, with no threads or timing. It fails with the old plain
assignment and passes with GetOrAdd. ADictionaryLoadIntoAColdCacheIsPublished
covers the normal cold load. UniGetUI.Core.Settings gets the same
InternalsVisibleTo.cs file the other core projects use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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 changes preserve explicit settings, resolve the reported cache-publication race, and include focused regression coverage with no unresolved blocking findings.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@randy-but-a-ro randy-but-a-ro Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Pull request was approved automatically: the AI review is complete and all its review threads are resolved. 🎉

Integration Details
{
	"deliveryId": "2a24d8a0-ce1c-11f1-9956-39fe7aec0a56",
	"headSha": "8063f1ac069e990960e80f18f793276395b257ca",
	"reviewer": "copilot-pull-request-reviewer[bot]"
}

@GabrielDuf
Gabriel Dufresne (GabrielDuf) merged commit 9a69980 into main Oct 22, 2026
6 checks passed
@GabrielDuf
Gabriel Dufresne (GabrielDuf) deleted the fix/disable-missing-managers-first-run branch October 22, 2026 14:41
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.

2 participants