Skip to content

Split NuGet V2 update checks so a long module list stops dropping every update - #5577

Merged
Gabriel Dufresne (GabrielDuf) merged 1 commit into
mainfrom
fix/issue-5555
Oct 22, 2026
Merged

Gabriel Dufresne (GabrielDuf) merged 1 commit into
mainfrom
fix/issue-5555

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?

Root cause

The V2 path of BaseNuGet.GetAvailableUpdates_UnSafe (src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGet.cs) put every installed package for a source into one <source>/GetUpdates()?packageIds='a|b|...'&versions='...' GET request. It did not deduplicate ids, so a module installed side by side appeared once per version, and it never split the request.

With about 450 PowerShell 7 modules (Microsoft.Graph.Beta.*, Az.*), the URL grew past what PSGallery accepts. Triage measured:

URL length PSGallery response
~11.8k chars 200
~15.7k chars 404
~19.6k chars 414

On a non-success response the code only logged and did errors++, so the source contributed zero updates. PowerShell 7 then had no updates at all, which is also why it was missing from the Software Updates source filter. PowerShell 5 worked only because its module list was short enough. The same path serves PowerShell 5, Chocolatey's HTTP update path and any V2 feed.

Fix

  • New BaseNuGet.BuildV2UpdateUrls(source, installedPackages, canPrerelease). It deduplicates ids case-insensitively and keeps the lowest installed version of each, comparing through the manager's CompareVersions with the same VersionStringToStruct fallback that KeepUpdatesNewerThanInstalled uses. It then splits the request into GetUpdates() URLs of at most MaxV2UpdateUrlLength = 8000 characters. A single entry longer than that on its own still gets a URL of its own.
  • Keeping the lowest version gives the same server answer as the old request: the latest version is newer than one of the installed versions exactly when it is newer than the lowest. KeepUpdatesNewerThanInstalled still filters against the highest installed version, as before.
  • GetAvailableUpdates_UnSafe now sends one request per chunk on a shared HttpClient. Each chunk has its own error handling: a non-success status or an exception is logged and counted with errors++, and the loop moves to the next chunk. A failed chunk no longer drops the updates found by the other chunks.
  • The packageIdVersion and packageIdScope maps (last-wins) are unchanged, so the installed version and scope shown for an update are the same as before. The query string format is unchanged too: %27 quoting and the | separator encoded as %7c, now produced per item and joined.

Verification

  • Build: dotnet build UniGetUI.PackageEngine.Tests.csproj -p:Platform=x64 -p:UseSharedCompilation=false exit 0 (builds BaseNuGet's project too). No new warnings in the changed files.
  • Whitespace formatter: dotnet format whitespace src --folder --verify-no-changes exit 0.
  • Style formatter: dotnet format style src/UniGetUI.Windows.slnx --no-restore --verify-no-changes --include src/UniGetUI.PackageEngine.Managers.Generic.NuGet/ src/UniGetUI.PackageEngine.Tests/NuGetV2UpdatesTests.cs exit 0.
  • Tests (TRX): filter FullyQualifiedName~NuGetV2UpdatesTests: 2 total, 2 passed, 0 failed.
  • Without the fix: with BaseNuGet.cs stashed, both tests failed for the reported reason ("Expected: 450 Actual: 0" and "Collection was empty"). After restoring the fix and rebuilding they passed again.
  • The full suite is left to CI.

Regression tests

src/UniGetUI.PackageEngine.Tests/NuGetV2UpdatesTests.cs, using a local HTTP V2 feed that rejects URLs longer than 11000 characters (mirroring the measured PSGallery behaviour):

  • NuGetV2UpdatesTests.GetAvailableUpdatesReportsEveryModuleWhenTheInstalledListExceedsTheFeedUrlLimit
  • NuGetV2UpdatesTests.GetAvailableUpdatesKeepsTheOtherChunksWhenOneRequestFails

Both fail without the fix and pass with it.

Not covered

  • The 8000-character chunk size is a conservative constant below the ~11.8k PSGallery was seen to accept. It is not configurable or adaptive, and there is no retry with a smaller chunk on 404/414.
  • The outer per-source catch in GetAvailableUpdates_UnSafe still does not increment errors. That is existing behaviour and is left alone.
  • A module installed in both scopes still resolves to a single update through the last-wins version and scope maps. Scope-aware identity is a separate, known limitation ([BUG] Powershell modules always updates as User Scope #5163).
  • No live PSGallery or real 450-module PowerShell 7 run was done.
  • The V3 and local-folder update paths were not changed; they already query per package.

Review notes (non-blocking)

  • Full-solution dotnet format style src/UniGetUI.Windows.slnx --no-restore --verify-no-changes exits 2 in the local worktree. All 459 diagnostics are IDE0044/IDE0060 in untouched src/UniGetUI.Avalonia/ViewModels/* files, probably false positives because the CommunityToolkit source generators had not run (the Avalonia project was not built there). Neither changed file is among them, and the command scoped to the changed files exits 0. CI builds before it formats, so this should not affect the PR, but it has not been confirmed locally.
  • Without the fix, GetAvailableUpdatesKeepsTheOtherChunksWhenOneRequestFails fails because the single unchunked URL (450 ids, about 19k characters) exceeds the fake feed's 11000 limit, not because one chunk's error wiped out the others. That is still a valid regression failure for the reported bug. With the fix, the test does exercise per-chunk error isolation: the 500 hits Module000 and Module449 still comes back.
  • Behaviour change: an exception thrown by client.Send/ReadAsString inside a chunk is now counted in errors and the loop moves on. Before, it went to the outer per-source catch, which does not increment errors. errors only feeds logger.Close(errors), so the only visible effect is the task log's error count.
  • MaxV2UpdateUrlLength = 8000 moves the ceiling rather than removing it. It sits below the ~11.8k PSGallery was measured to accept, but it has not been checked against live PSGallery or other V2 feeds such as Chocolatey community, Nexus or ProGet.

Closes #5555

🤖 Generated with Claude Code

…ry update

The V2 update path put every installed package of a source into one
GetUpdates() GET request, without deduplicating ids, so a module installed
side by side appeared once per version. With about 450 PowerShell 7 modules
the URL grew past what PSGallery accepts (about 11.8k characters returned
200, 15.7k returned 404, 19.6k returned 414). A non-success response was
only logged, so the whole source contributed zero updates and PowerShell 7
disappeared from the Software Updates source filter.

BuildV2UpdateUrls now deduplicates ids case-insensitively, keeps the lowest
installed version of each (compared through the manager's CompareVersions
with the same VersionStringToStruct fallback KeepUpdatesNewerThanInstalled
uses), and splits the query into URLs of at most 8000 characters. Keeping
the lowest version gives the same server answer as listing all of them: the
latest version is newer than one installed version exactly when it is newer
than the lowest. KeepUpdatesNewerThanInstalled still filters against the
highest installed version.

Each chunk is requested on a shared HttpClient with its own error handling,
so a failed chunk is logged and counted without discarding the updates the
other chunks found. The query string format and the last-wins version and
scope maps are unchanged.

Fixes #5555

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 scoped batching change preserves existing filtering, isolates request failures, and has no identified blocking issues.

No open issues 🎉

What changed in this PR

Fixes missing updates for large installed-module lists by splitting shared NuGet V2 update requests, addressing the PowerShell 7 issue reported in #5555.

Changes:

  • Deduplicates package IDs and batches update URLs to an 8,000-character target while preserving existing update filtering.
  • Handles failures per request so successful chunks still contribute updates.
  • Adds regression tests for oversized module lists and partial request failures.
File Description
src/​UniGetUI.PackageEngine.Tests/​NuGetV2UpdatesTests.cs Adds local-feed tests for request batching and failure isolation.
src/​UniGetUI.PackageEngine.Managers.Generic.NuGet/​BaseNuGet.cs Builds deduplicated V2 request batches and processes each independently.

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": "356da1e0-ce32-11f1-948e-c5a6ce73cd42",
	"headSha": "e3b0b7d3f60f6dc04da826442f0dfdf203b96680",
	"reviewer": "copilot-pull-request-reviewer[bot]"
}

@GabrielDuf
Gabriel Dufresne (GabrielDuf) merged commit 9ef1b4c into main Oct 22, 2026
6 checks passed
@GabrielDuf
Gabriel Dufresne (GabrielDuf) deleted the fix/issue-5555 branch October 22, 2026 18:56
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] PowerShell 7 not listed as source in Software updates

2 participants