From 35309451437a7338bfcac7a8633e766f1b255c37 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 8 Oct 2026 15:45:35 -0400 Subject: [PATCH 1/3] Stop pip updates from corrupting the environment --- src/Languages/lang_en.json | 4 +- .../ManagerCapabilities.cs | 1 + .../Helpers/PipPkgOperationHelper.cs | 7 +- .../Pip.cs | 1 + .../PackageOperations.cs | 44 +++- .../PackageOperationsTests.cs | 215 ++++++++++++++++++ .../PipManagerTests.cs | 74 ++++++ 7 files changed, 343 insertions(+), 3 deletions(-) diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index 99cae82fab..a389d71a48 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -1842,5 +1842,7 @@ "GitHub returned an empty access token.": "GitHub returned an empty access token.", "GitHub sign-in failed ({0}): {1}": "GitHub sign-in failed ({0}): {1}", "The GitHub sign-in code has expired. Please try again.": "The GitHub sign-in code has expired. Please try again.", - "WinGet repair exited with code {0}.": "WinGet repair exited with code {0}." + "WinGet repair exited with code {0}.": "WinGet repair exited with code {0}.", + "Waiting for another {0} operation to finish...": "Waiting for another {0} operation to finish...", + "A file of {package} is in use by another program. Close any program that may be using it, then try again": "A file of {package} is in use by another program. Close any program that may be using it, then try again" } diff --git a/src/UniGetUI.PackageEngine.Enums/ManagerCapabilities.cs b/src/UniGetUI.PackageEngine.Enums/ManagerCapabilities.cs index 62da046fcd..ba1a2069ba 100644 --- a/src/UniGetUI.PackageEngine.Enums/ManagerCapabilities.cs +++ b/src/UniGetUI.PackageEngine.Enums/ManagerCapabilities.cs @@ -42,6 +42,7 @@ public struct ManagerCapabilities public bool SupportsCustomScopesOnUpdate = true; public bool SupportsCustomScopesOnUninstall = true; public bool SupportsPreRelease = false; + public bool SerializesOperations = false; public bool SupportsCustomLocations = false; public bool SupportsCustomSources = false; public bool SupportsCustomPackageIcons = false; diff --git a/src/UniGetUI.PackageEngine.Managers.Pip/Helpers/PipPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.Pip/Helpers/PipPkgOperationHelper.cs index b60ed17330..2a271f82b4 100644 --- a/src/UniGetUI.PackageEngine.Managers.Pip/Helpers/PipPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Pip/Helpers/PipPkgOperationHelper.cs @@ -101,7 +101,12 @@ int returnCode } } - if (output_string.Contains("--user") && package.OverridenOptions.Scope != PackageScope.User) + if ( + operation is not OperationType.Update + && output_string.Contains("--user") + && !output_string.Contains("[WinError 32]") + && package.OverridenOptions.Scope != PackageScope.User + ) { package.OverridenOptions.Scope = PackageScope.User; return OperationVeredict.AutoRetry; diff --git a/src/UniGetUI.PackageEngine.Managers.Pip/Pip.cs b/src/UniGetUI.PackageEngine.Managers.Pip/Pip.cs index 1078e24943..ebff3a3d55 100644 --- a/src/UniGetUI.PackageEngine.Managers.Pip/Pip.cs +++ b/src/UniGetUI.PackageEngine.Managers.Pip/Pip.cs @@ -64,6 +64,7 @@ public Pip() SupportsCustomScopes = true, CanDownloadInstaller = true, SupportsPreRelease = true, + SerializesOperations = true, CanListDependencies = true, SupportsProxy = ProxySupport.Yes, SupportsProxyAuth = true, diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs index 8294e8d94a..956571aae0 100644 --- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Diagnostics; using System.Text; using UniGetUI.Core.Classes; @@ -344,12 +345,41 @@ protected override async Task PerformOperation() if (!ShouldUseAgentBroker()) { - return await base.PerformOperation(); + if (!Package.Manager.Capabilities.SerializesOperations) + return await base.PerformOperation(); + + SemaphoreSlim gate = ManagerOperationGates.GetOrAdd( + Package.Manager.Name, + _ => new SemaphoreSlim(1, 1) + ); + if (!await gate.WaitAsync(0)) + { + Line( + CoreTools.Translate( + "Waiting for another {0} operation to finish...", + Package.Manager.DisplayName + ), + LineType.ProgressIndicator + ); + await gate.WaitAsync(CancellationToken); + } + + try + { + return await base.PerformOperation(); + } + finally + { + gate.Release(); + } } return await PerformBrokerOperation(); } + private static readonly ConcurrentDictionary ManagerOperationGates = + new(); + /// /// Determines whether this operation should be routed through the agent broker. /// @@ -1058,6 +1088,7 @@ List Output ExplainNotApplicableUpdate(Output, ReturnCode); ExplainInstallerHashMismatch(ReturnCode); ExplainApplicationCurrentlyRunning(Output, ReturnCode); + ExplainFileInUse(Output); } return Task.FromResult(veredict); @@ -1197,6 +1228,17 @@ private void ExplainApplicationCurrentlyRunning(List output, int returnC #endif } + private void ExplainFileInUse(List output) + { + if (!output.Any(line => line.Contains("[WinError 32]", StringComparison.Ordinal))) + return; + + Metadata.FailureMessage = CoreTools.Translate( + "A file of {package} is in use by another program. Close any program that may be using it, then try again", + new Dictionary { { "package", Package.Name } } + ); + } + private void ExplainNotApplicableUpdate(List output, int returnCode) { #if WINDOWS diff --git a/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs b/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs index 05fa34badd..50707704d2 100644 --- a/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs @@ -369,6 +369,221 @@ public void InstallOperationPrepareProcessStartInfoUsesManagerCommandLineAndSets Assert.Equal(PackageScope.Machine, badges.Scope); } + private static TestPackageManager CreateSlowProcessManager(bool serializesOperations) + { + return new PackageManagerBuilder() + .WithName($"SlowManager{Guid.NewGuid():N}") + .ConfigureCapabilities(capabilities => + { + capabilities.SerializesOperations = serializesOperations; + return capabilities; + }) + .ConfigureManager(manager => + { + manager.ExecutablePath = Path.Join(Environment.SystemDirectory, "cmd.exe"); + manager.ExecutableArguments = "/c"; + }) + .ConfigureOperation(helper => + helper.ParametersFactory = (_, _, _) => ["ping", "-n", "3", "127.0.0.1"] + ) + .Build(); + } + + private static bool HasLine(AbstractOperation operation, string prefix) => + operation.GetOutput().Any(line => line.Item1.StartsWith(prefix, StringComparison.Ordinal)); + + private static async Task WaitForConditionAsync(Func condition) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + while (!condition()) + await Task.Delay(25, timeout.Token); + } + + private static Func ReportsWaitingOnTheGate(AbstractOperation operation) + { + bool reported = false; + operation.LogLineAdded += (_, line) => + { + if (line.Item1.StartsWith("Waiting for another", StringComparison.Ordinal)) + reported = true; + }; + return () => reported; + } + + [Fact] + public async Task OperationsOfASerializingManagerNeverRunTheirProcessesConcurrently() + { + if (!OperatingSystem.IsWindows()) + return; + + var manager = CreateSlowProcessManager(serializesOperations: true); + InitializeLoaders(); + using var first = new InstallPackageOperation( + new PackageBuilder().WithManager(manager).WithId("first").Build(), + new InstallOptions(), + IgnoreParallelInstalls: true + ); + using var second = new InstallPackageOperation( + new PackageBuilder().WithManager(manager).WithId("second").Build(), + new InstallOptions(), + IgnoreParallelInstalls: true + ); + var secondWaited = ReportsWaitingOnTheGate(second); + bool? firstFinishedWhenSecondStarted = null; + second.LogLineAdded += (_, line) => + { + if (line.Item1.StartsWith("Executing process", StringComparison.Ordinal)) + firstFinishedWhenSecondStarted = HasLine(first, "Process return value"); + }; + + Task firstRun = first.MainThread(); + await WaitForConditionAsync(() => HasLine(first, "Start Time")); + Task secondRun = second.MainThread(); + await Task.WhenAll(firstRun, secondRun).WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.Equal(OperationStatus.Succeeded, first.Status); + Assert.Equal(OperationStatus.Succeeded, second.Status); + Assert.True(firstFinishedWhenSecondStarted); + Assert.True(secondWaited()); + } + + [Fact] + public async Task OperationsOfANonSerializingManagerRunTheirProcessesConcurrently() + { + if (!OperatingSystem.IsWindows()) + return; + + var manager = CreateSlowProcessManager(serializesOperations: false); + InitializeLoaders(); + using var first = new InstallPackageOperation( + new PackageBuilder().WithManager(manager).WithId("first").Build(), + new InstallOptions(), + IgnoreParallelInstalls: true + ); + using var second = new InstallPackageOperation( + new PackageBuilder().WithManager(manager).WithId("second").Build(), + new InstallOptions(), + IgnoreParallelInstalls: true + ); + var secondWaited = ReportsWaitingOnTheGate(second); + bool? firstFinishedWhenSecondStarted = null; + second.LogLineAdded += (_, line) => + { + if (line.Item1.StartsWith("Executing process", StringComparison.Ordinal)) + firstFinishedWhenSecondStarted = HasLine(first, "Process return value"); + }; + + Task firstRun = first.MainThread(); + await WaitForConditionAsync(() => HasLine(first, "Start Time")); + Task secondRun = second.MainThread(); + await Task.WhenAll(firstRun, secondRun).WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.False(firstFinishedWhenSecondStarted); + Assert.False(secondWaited()); + } + + [Fact] + public async Task CancelingAnOperationWaitingOnASerializingManagerLeavesTheGateUsable() + { + if (!OperatingSystem.IsWindows()) + return; + + var manager = CreateSlowProcessManager(serializesOperations: true); + InitializeLoaders(); + using var first = new InstallPackageOperation( + new PackageBuilder().WithManager(manager).WithId("first").Build(), + new InstallOptions(), + IgnoreParallelInstalls: true + ); + using var waiting = new InstallPackageOperation( + new PackageBuilder().WithManager(manager).WithId("waiting").Build(), + new InstallOptions(), + IgnoreParallelInstalls: true + ); + using var third = new InstallPackageOperation( + new PackageBuilder().WithManager(manager).WithId("third").Build(), + new InstallOptions(), + IgnoreParallelInstalls: true + ); + var waitingReported = ReportsWaitingOnTheGate(waiting); + var thirdWaited = ReportsWaitingOnTheGate(third); + + Task firstRun = first.MainThread(); + await WaitForConditionAsync(() => HasLine(first, "Start Time")); + Task waitingRun = waiting.MainThread(); + await WaitForConditionAsync(waitingReported); + waiting.Cancel(); + await waitingRun.WaitAsync(TimeSpan.FromSeconds(10)); + await firstRun.WaitAsync(TimeSpan.FromSeconds(30)); + await third.MainThread().WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.Equal(OperationStatus.Canceled, waiting.Status); + Assert.False(HasLine(waiting, "Executing process")); + Assert.Equal(OperationStatus.Succeeded, first.Status); + Assert.Equal(OperationStatus.Succeeded, third.Status); + Assert.False(thirdWaited()); + } + + private static TestPackageManager CreateFailingEchoManager(params string[] echoed) + { + return new PackageManagerBuilder() + .WithName($"FailingManager{Guid.NewGuid():N}") + .ConfigureManager(manager => + { + manager.ExecutablePath = Path.Join(Environment.SystemDirectory, "cmd.exe"); + manager.ExecutableArguments = "/c"; + }) + .ConfigureOperation(helper => + { + helper.ParametersFactory = (_, _, _) => ["echo", .. echoed]; + helper.ResultFactory = (_, _, _, _) => OperationVeredict.Failure; + }) + .Build(); + } + + [Fact] + public async Task AFailureCausedByAFileInUseTellsTheUserToCloseTheProgramUsingIt() + { + if (!OperatingSystem.IsWindows()) + return; + + var manager = CreateFailingEchoManager("ERROR:", "[WinError", "32]", "file", "busy"); + InitializeLoaders(); + using var operation = new UpdatePackageOperation( + new PackageBuilder().WithManager(manager).WithId("botocore").WithName("botocore").Build(), + new InstallOptions(), + IgnoreParallelInstalls: true + ); + + await operation.MainThread().WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.Equal(OperationStatus.Failed, operation.Status); + Assert.Equal( + "A file of botocore is in use by another program. Close any program that may be using it, then try again", + operation.Metadata.FailureMessage + ); + } + + [Fact] + public async Task AFailureWithoutAFileInUseKeepsTheGenericFailureMessage() + { + if (!OperatingSystem.IsWindows()) + return; + + var manager = CreateFailingEchoManager("ERROR:", "[WinError", "5]", "denied"); + InitializeLoaders(); + using var operation = new UpdatePackageOperation( + new PackageBuilder().WithManager(manager).WithId("botocore").WithName("botocore").Build(), + new InstallOptions(), + IgnoreParallelInstalls: true + ); + + await operation.MainThread().WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.Equal(OperationStatus.Failed, operation.Status); + Assert.DoesNotContain("in use by another program", operation.Metadata.FailureMessage); + } + [Fact] public async Task InstallOperationSuccessfulRunSetsPackageTagAndAddsInstalledCopy() { diff --git a/src/UniGetUI.PackageEngine.Tests/PipManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/PipManagerTests.cs index f4dcf3a772..c978bb6eb3 100644 --- a/src/UniGetUI.PackageEngine.Tests/PipManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/PipManagerTests.cs @@ -346,6 +346,80 @@ public void OperationHelperUsesAutoRetryMutationsForKnownPipFailures() Assert.Equal(OperationVeredict.Failure, failureResult); } + private static readonly string[] SharingViolationOutput = + [ + "Attempting uninstall: botocore", + "ERROR: Could not install packages due to an OSError: [WinError 32] The process cannot access the file because it is being used by another process: 'C:\\Python314\\Lib\\site-packages\\botocore\\signers.py'", + "Consider using the `--user` option or check the permissions.", + ]; + + private static readonly string[] PermissionDeniedOutput = + [ + "ERROR: Could not install packages due to an OSError: [WinError 5] Access is denied: 'C:\\Python314\\Lib\\site-packages\\requests'", + "Consider using the `--user` option or check the permissions.", + ]; + + [Theory] + [InlineData(OperationType.Install)] + [InlineData(OperationType.Update)] + public void OperationHelperDoesNotMoveToTheUserSchemeOnASharingViolation( + OperationType operation + ) + { + var manager = new Pip(); + var package = new PackageBuilder().WithManager(manager).WithId("boto3").Build(); + + var result = manager.OperationHelper.GetResult( + package, + operation, + SharingViolationOutput, + 1 + ); + + Assert.Equal(OperationVeredict.Failure, result); + Assert.Null(package.OverridenOptions.Scope); + } + + [Fact] + public void OperationHelperDoesNotMoveAnUpdateToTheUserScheme() + { + var manager = new Pip(); + var package = new PackageBuilder().WithManager(manager).WithId("requests").Build(); + + var result = manager.OperationHelper.GetResult( + package, + OperationType.Update, + PermissionDeniedOutput, + 1 + ); + + Assert.Equal(OperationVeredict.Failure, result); + Assert.Null(package.OverridenOptions.Scope); + } + + [Fact] + public void OperationHelperStillRetriesAnInstallInTheUserSchemeWhenAccessIsDenied() + { + var manager = new Pip(); + var package = new PackageBuilder().WithManager(manager).WithId("requests").Build(); + + var result = manager.OperationHelper.GetResult( + package, + OperationType.Install, + PermissionDeniedOutput, + 1 + ); + + Assert.Equal(OperationVeredict.AutoRetry, result); + Assert.Equal(PackageScope.User, package.OverridenOptions.Scope); + } + + [Fact] + public void PipSerializesItsOperations() + { + Assert.True(new Pip().Capabilities.SerializesOperations); + } + [Fact] public async Task ResolveLatestVersionReportsAFailureWhenTheResolutionBudgetIsAlreadySpent() { From 4a157348f814fc42288c4c3e545c5342282a4fa3 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 8 Oct 2026 16:09:00 -0400 Subject: [PATCH 2/3] Free the parallel slot of an operation waiting on its manager --- .../AbstractOperation.cs | 32 ++++++++-- .../PackageOperations.cs | 14 ++++- .../PackageOperationsTests.cs | 63 ++++++++++++++++++- 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs b/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs index 237ccbcefa..f97d23e182 100644 --- a/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs +++ b/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs @@ -319,15 +319,14 @@ private async Task MainThreadCore(CancellationTokenSource runCancellation) while ( FORCE_HOLD_QUEUE - || (IndexInQueue(this) >= MAX_OPERATIONS && !SKIP_QUEUE) + || (SlotsTakenAhead(this) >= MAX_OPERATIONS && !SKIP_QUEUE) ) { - int pos = IndexInQueue(this) - MAX_OPERATIONS + 1; - - if (pos == -1) + if (IndexInQueue(this) - MAX_OPERATIONS + 1 == -1) return; // In this case, operation was canceled; + int pos = SlotsTakenAhead(this) - MAX_OPERATIONS + 1; if (pos != lastPos) { lastPos = pos; @@ -646,6 +645,31 @@ public static int IndexInQueue(AbstractOperation operation) return OperationQueue.IndexOf(operation); } + internal bool IsWaitingForManager + { + get => _isWaitingForManager; + set => _isWaitingForManager = value; + } + private volatile bool _isWaitingForManager; + + private static int SlotsTakenAhead(AbstractOperation operation) + { + lock (QueueLock) + { + int index = OperationQueue.IndexOf(operation); + if (index < 0) + return index; + + int taken = 0; + for (int i = 0; i < index; i++) + { + if (!OperationQueue[i].IsWaitingForManager) + taken++; + } + return taken; + } + } + public static int QueueLength() { lock (QueueLock) diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs index 956571aae0..a6b0de8636 100644 --- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs @@ -361,7 +361,19 @@ protected override async Task PerformOperation() ), LineType.ProgressIndicator ); - await gate.WaitAsync(CancellationToken); + IsWaitingForManager = true; + try + { + await gate.WaitAsync(CancellationToken); + } + catch (OperationCanceledException) + { + return OperationVeredict.Canceled; + } + finally + { + IsWaitingForManager = false; + } } try diff --git a/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs b/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs index 50707704d2..615e6360ac 100644 --- a/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs @@ -384,8 +384,10 @@ private static TestPackageManager CreateSlowProcessManager(bool serializesOperat manager.ExecutableArguments = "/c"; }) .ConfigureOperation(helper => - helper.ParametersFactory = (_, _, _) => ["ping", "-n", "3", "127.0.0.1"] - ) + { + helper.ParametersFactory = (_, _, _) => ["ping", "-n", "3", "127.0.0.1"]; + helper.ResultFactory = (_, _, _, _) => OperationVeredict.Success; + }) .Build(); } @@ -519,11 +521,68 @@ public async Task CancelingAnOperationWaitingOnASerializingManagerLeavesTheGateU Assert.Equal(OperationStatus.Canceled, waiting.Status); Assert.False(HasLine(waiting, "Executing process")); + Assert.DoesNotContain( + waiting.GetOutput(), + line => line.Item1.Contains("CanceledException", StringComparison.Ordinal) + ); Assert.Equal(OperationStatus.Succeeded, first.Status); Assert.Equal(OperationStatus.Succeeded, third.Status); Assert.False(thirdWaited()); } + [Fact] + public async Task AnOperationWaitingOnASerializingManagerDoesNotHoldAParallelSlot() + { + if (!OperatingSystem.IsWindows()) + return; + + var serialized = CreateSlowProcessManager(serializesOperations: true); + var other = CreateSlowProcessManager(serializesOperations: false); + InitializeLoaders(); + using var first = new InstallPackageOperation( + new PackageBuilder().WithManager(serialized).WithId("first").Build(), + new InstallOptions() + ); + using var waiting = new InstallPackageOperation( + new PackageBuilder().WithManager(serialized).WithId("waiting").Build(), + new InstallOptions() + ); + using var unrelated = new InstallPackageOperation( + new PackageBuilder().WithManager(other).WithId("unrelated").Build(), + new InstallOptions() + ); + var waitingReported = ReportsWaitingOnTheGate(waiting); + bool? firstFinishedWhenUnrelatedStarted = null; + unrelated.LogLineAdded += (_, line) => + { + if (line.Item1.StartsWith("Executing process", StringComparison.Ordinal)) + firstFinishedWhenUnrelatedStarted = HasLine(first, "Process return value"); + }; + + AbstractOperation.OperationQueue.Clear(); + int previousMax = AbstractOperation.MAX_OPERATIONS; + AbstractOperation.MAX_OPERATIONS = 2; + try + { + Task firstRun = first.MainThread(); + await WaitForConditionAsync(() => HasLine(first, "Start Time")); + Task waitingRun = waiting.MainThread(); + await WaitForConditionAsync(waitingReported); + Task unrelatedRun = unrelated.MainThread(); + await Task.WhenAll(firstRun, waitingRun, unrelatedRun).WaitAsync(TimeSpan.FromSeconds(30)); + } + finally + { + AbstractOperation.MAX_OPERATIONS = previousMax; + AbstractOperation.OperationQueue.Clear(); + } + + Assert.Equal(OperationStatus.Succeeded, first.Status); + Assert.Equal(OperationStatus.Succeeded, waiting.Status); + Assert.Equal(OperationStatus.Succeeded, unrelated.Status); + Assert.False(firstFinishedWhenUnrelatedStarted); + } + private static TestPackageManager CreateFailingEchoManager(params string[] echoed) { return new PackageManagerBuilder() From 6daf9b6e965d3a0de60625233dd0586aa02bf6eb Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 8 Oct 2026 16:24:21 -0400 Subject: [PATCH 3/3] Keep the externally-managed fallback from moving an update to --user --- .../Helpers/PipPkgOperationHelper.cs | 5 ++++- .../PipManagerTests.cs | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/UniGetUI.PackageEngine.Managers.Pip/Helpers/PipPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.Pip/Helpers/PipPkgOperationHelper.cs index 2a271f82b4..4cc9f403cd 100644 --- a/src/UniGetUI.PackageEngine.Managers.Pip/Helpers/PipPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Pip/Helpers/PipPkgOperationHelper.cs @@ -94,7 +94,10 @@ int returnCode return OperationVeredict.AutoRetry; } - if (package.OverridenOptions.Scope != PackageScope.User) + if ( + operation is not OperationType.Update + && package.OverridenOptions.Scope != PackageScope.User + ) { package.OverridenOptions.Scope = PackageScope.User; return OperationVeredict.AutoRetry; diff --git a/src/UniGetUI.PackageEngine.Tests/PipManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/PipManagerTests.cs index c978bb6eb3..18d5095802 100644 --- a/src/UniGetUI.PackageEngine.Tests/PipManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/PipManagerTests.cs @@ -346,6 +346,28 @@ public void OperationHelperUsesAutoRetryMutationsForKnownPipFailures() Assert.Equal(OperationVeredict.Failure, failureResult); } + [Theory] + [InlineData(OperationType.Install, OperationVeredict.AutoRetry, PackageScope.User)] + [InlineData(OperationType.Update, OperationVeredict.Failure, null)] + public void OperationHelperMovesOnlyNonUpdatesToTheUserSchemeOnARepeatedExternallyManagedFailure( + OperationType operation, + OperationVeredict expectedResult, + string? expectedScope + ) + { + var manager = new Pip(); + var package = new PackageBuilder().WithManager(manager).WithId("requests").Build(); + string[] output = ["error: externally-managed-environment"]; + + var firstResult = manager.OperationHelper.GetResult(package, operation, output, 1); + var secondResult = manager.OperationHelper.GetResult(package, operation, output, 1); + + Assert.Equal(OperationVeredict.AutoRetry, firstResult); + Assert.True(package.OverridenOptions.Pip_BreakSystemPackages); + Assert.Equal(expectedResult, secondResult); + Assert.Equal(expectedScope, package.OverridenOptions.Scope); + } + private static readonly string[] SharingViolationOutput = [ "Attempting uninstall: botocore",