Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Devolutions.Now.Policy.Api" Version="2026.7.28" />
<PackageReference Include="Devolutions.Now.Policy.Client" Version="2026.7.28" />
<PackageReference Include="Devolutions.Now.Policy.Api" Version="2026.8.5" />
<PackageReference Include="Devolutions.Now.Policy.Client" Version="2026.8.5" />
</ItemGroup>

<ItemGroup>
Expand Down
10 changes: 10 additions & 0 deletions src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,16 @@ protected CancellationToken CancellationToken
}
}

/// <summary>
/// Test hook: installs the cancellation source that MainThread() would normally create,
/// so tests invoking PerformOperation() directly can exercise Cancel().
/// </summary>
internal void SetRunCancellationSourceForTests(CancellationTokenSource source)
{
lock (CancellationLock)
RunCancellationSource = source;
}

private bool TrySetActiveInnerOperation(AbstractOperation operation)
{
bool cancellationRequested;
Expand Down
238 changes: 183 additions & 55 deletions src/UniGetUI.PackageEngine.Operations/PackageOperations.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Text;
using UniGetUI.Core.Classes;
using UniGetUI.Core.Data;
using UniGetUI.Core.Logging;
Expand All @@ -17,8 +16,12 @@
using BrokerClientErrorKind = Devolutions.Now.Policy.Client.BrokerClientErrorKind;
using BrokerClientException = Devolutions.Now.Policy.Client.BrokerClientException;
using BrokerClientOptions = Devolutions.Now.Policy.Client.BrokerClientOptions;
using BrokerDecision = Devolutions.Now.Policy.Api.Decision;
using BrokerElevation = Devolutions.Now.Policy.Api.Elevation;
using BrokerOperationStatus = Devolutions.Now.Policy.Api.OperationStatus;
using BrokerStatusResponse = Devolutions.Now.Policy.Api.StatusResponse;
using OperationCancelQuery = Devolutions.Now.Policy.Client.OperationCancelQuery;
using OperationStatusQuery = Devolutions.Now.Policy.Client.OperationStatusQuery;
#if WINDOWS
using UniGetUI.PackageEngine.Managers.WingetManager;
#endif
Expand All @@ -40,6 +43,28 @@ public abstract class PackageOperation : AbstractProcessOperation
/// </summary>
internal static Func<Devolutions.Now.Policy.Client.IBrokerTransport>? BrokerTransportFactory;

/// <summary>
/// Interval between broker operation status polls. Internal so tests can shorten it.
/// </summary>
internal static int BrokerStatusPollIntervalMs = 500;

/// <summary>
/// Maximum time to wait for the broker to accept a cancel request.
/// </summary>
internal static TimeSpan BrokerCancelRequestTimeout = TimeSpan.FromSeconds(5);

/// <summary>
/// Maximum time to wait for a canceled broker operation to reach a terminal status.
/// </summary>
internal static TimeSpan BrokerCancelConfirmTimeout = TimeSpan.FromSeconds(30);

/// <summary>
/// Upper bound for a brokered operation to reach a terminal status before the
/// operation is reported as failed. Protects against a broker that keeps
/// reporting a non-terminal status indefinitely.
/// </summary>
internal static TimeSpan BrokerOperationTimeout = TimeSpan.FromHours(1);

protected List<string> DesktopShortcutsBeforeStart = [];

public readonly IPackage Package;
Expand Down Expand Up @@ -240,42 +265,62 @@ private async Task<OperationVeredict> PerformBrokerOperation()

try
{
// Send to broker and poll until completion, honoring operation cancellation.
var status = await client.ExecuteAndWait(request, CancellationToken);
// Submit the operation explicitly (instead of ExecuteAndWait) so the
// operation id is available for broker-side cancellation.
var execution = await client.Execute(request, CancellationToken);

// Log status details.
Line($"Broker status: {status.Status}, exitCode={status.ExitCode}", LineType.Information);
if (!string.IsNullOrWhiteSpace(status.Message))
if (execution.Decision.Decision != BrokerDecision.Allow)
{
Line($" Message: {status.Message}", LineType.Information);
string denialReason = execution.Decision.Reason ?? CoreTools.Translate("No reason provided");
Line($"Operation denied by policy: {denialReason}", LineType.Error);
Metadata.FailureTitle = CoreTools.Translate("Operation denied by policy");
Metadata.FailureMessage = denialReason;
return OperationVeredict.Failure;
}
var output = DisplayBrokerOutput(status.Stdout);

if (status.Status == BrokerOperationStatus.Completed)
if (execution.Operation is null)
{
var veredict = await GetProcessVeredict(status.ExitCode ?? -1, output);
if (veredict is OperationVeredict.Success)
{
Line("Operation completed successfully via agent broker.", LineType.Information);
}
else if (!string.IsNullOrWhiteSpace(status.Message))
{
Metadata.FailureMessage = status.Message;
}
Line("Broker allowed the operation but did not return an operation submission.", LineType.Error);
Metadata.FailureTitle = CoreTools.Translate("Operation failed via broker");
Metadata.FailureMessage = CoreTools.Translate(
"The broker accepted the request but did not report an operation to track.");
return OperationVeredict.Failure;
}

return veredict;
string operationId = execution.Operation.OperationId;
Line($"Broker accepted operation: {operationId}", LineType.VerboseDetails);

// NOTE: execution.Operation.EventChannel (live output streaming) is intentionally
// not consumed yet; brokered operations show no captured output until then.

BrokerStatusResponse status;
using var operationTimeout = new CancellationTokenSource(BrokerOperationTimeout);
using var polling = CancellationTokenSource.CreateLinkedTokenSource(
CancellationToken, operationTimeout.Token);
try
{
status = await WaitForBrokerTerminalStatus(client, operationId, polling.Token);
}
catch (OperationCanceledException) when (CancellationToken.IsCancellationRequested)
{
return await CancelBrokerOperation(client, operationId);
}
catch (OperationCanceledException) when (operationTimeout.IsCancellationRequested)
{
string timeoutMessage = CoreTools.Translate(
"The operation did not finish within the allotted time. It may still be running on the agent.");
Line($"Broker operation timed out after {BrokerOperationTimeout}.", LineType.Error);
Logger.Error($"[AgentBroker] Operation {operationId} did not reach a terminal status within {BrokerOperationTimeout}");
Metadata.FailureTitle = CoreTools.Translate("Operation failed via broker");
Metadata.FailureMessage = timeoutMessage;
return OperationVeredict.Failure;
}

// Operation failed — surface a user-visible error.
string reason = status.Message ?? $"Exit code: {status.ExitCode}";
Line($"Operation failed via broker: {reason}", LineType.Error);
Metadata.FailureTitle = CoreTools.Translate("Operation denied or failed via broker");
Metadata.FailureMessage = reason;
return OperationVeredict.Failure;
return await InterpretBrokerTerminalStatus(status);
}
catch (OperationCanceledException)
{
Line("Broker operation was canceled.", LineType.Error);
Line("Broker operation was canceled.", LineType.Information);
return OperationVeredict.Canceled;
}
catch (BrokerClientException ex) when (ex.Kind is BrokerClientErrorKind.BrokerUnavailable)
Expand All @@ -296,55 +341,138 @@ private async Task<OperationVeredict> PerformBrokerOperation()
}

/// <summary>
/// Fails the operation because the agent broker is unreachable: brokered operations
/// must not fall back to local execution, since policy evaluation and kill/pre/post
/// actions are owned by the broker. Sets the failure metadata and raises
/// <see cref="BrokerUnavailable"/> so the UI can notify the user.
/// Polls the broker until the operation reaches a terminal status
/// (Completed, Failed or Canceled).
/// </summary>
private OperationVeredict HandleBrokerUnavailable()
private static async Task<BrokerStatusResponse> WaitForBrokerTerminalStatus(
BrokerClient client,
string operationId,
CancellationToken cancellationToken)
{
Line("Agent broker is not available. The operation cannot continue.", LineType.Error);
Logger.Error("[AgentBroker] Broker not available, aborting operation");
string message = CoreTools.Translate(
"The Devolutions Agent broker is not available. The operation cannot be performed. Please ensure the Devolutions Agent is installed and running.");
Metadata.FailureTitle = CoreTools.Translate("Agent broker unavailable");
Metadata.FailureMessage = message;
BrokerUnavailable?.Invoke(this, message);
return OperationVeredict.Failure;
while (true)
{
await Task.Delay(BrokerStatusPollIntervalMs, cancellationToken);

var status = await client.QueryStatus(
new OperationStatusQuery { OperationId = operationId },
cancellationToken);

if (status.Status is BrokerOperationStatus.Completed
or BrokerOperationStatus.Failed
or BrokerOperationStatus.Canceled)
{
return status;
}
}
}
Comment thread
vnikonov-devolutions marked this conversation as resolved.

private List<string> DisplayBrokerOutput(string? encodedStdout)
/// <summary>
/// Requests broker-side cancellation of a running operation, then waits (bounded)
/// for the operation to reach a terminal status. The remote process may win the
/// race and complete or fail before the cancel takes effect; in that case the
/// terminal status is honored instead of reporting a cancellation.
/// </summary>
private async Task<OperationVeredict> CancelBrokerOperation(BrokerClient client, string operationId)
{
List<string> output = [];
if (string.IsNullOrWhiteSpace(encodedStdout))
Line("Cancellation requested; asking broker to cancel the remote operation...", LineType.Information);

try
{
return output;
using var cancelTimeout = new CancellationTokenSource(BrokerCancelRequestTimeout);
var cancelResponse = await client.Cancel(
new OperationCancelQuery { OperationId = operationId },
cancelTimeout.Token);
Line($"Broker acknowledged cancel request: {cancelResponse.Status}", LineType.VerboseDetails);
}
catch (Exception ex)
{
// Best-effort: the cancel request is idempotent, and the operation may already
// have reached a terminal state. Still wait below for the terminal status.
Logger.Warn($"[AgentBroker] Cancel request for operation {operationId} failed: {ex}");
Line("Broker cancel request failed; checking final operation status...", LineType.Information);
}

string decoded;
try
{
decoded = Encoding.UTF8.GetString(Convert.FromBase64String(encodedStdout));
using var confirmTimeout = new CancellationTokenSource(BrokerCancelConfirmTimeout);
var status = await WaitForBrokerTerminalStatus(client, operationId, confirmTimeout.Token);

if (status.Status is not BrokerOperationStatus.Canceled)
{
// The remote process finished before the cancel took effect.
Line($"Broker operation finished before cancellation took effect: {status.Status}", LineType.Information);
return await InterpretBrokerTerminalStatus(status);
}
}
catch (Exception ex)
{
// The user asked for cancellation; do not surface polling failures as errors.
Logger.Warn($"[AgentBroker] Could not confirm terminal status of canceled operation {operationId}: {ex}");
}
catch (FormatException ex)

Line("Broker operation was canceled.", LineType.Information);
return OperationVeredict.Canceled;
}

/// <summary>
/// Maps a terminal broker status response to an operation veredict, setting
/// failure metadata where appropriate.
/// </summary>
private async Task<OperationVeredict> InterpretBrokerTerminalStatus(BrokerStatusResponse status)
{
Line($"Broker status: {status.Status}, exitCode={status.ExitCode}", LineType.Information);
if (!string.IsNullOrWhiteSpace(status.Message))
{
Line($" Message: {status.Message}", LineType.Information);
}

if (status.Status is BrokerOperationStatus.Canceled)
{
Logger.Error($"[AgentBroker] Broker returned invalid base64 stdout: {ex}");
Line("Broker returned captured output in an invalid format.", LineType.Error);
return output;
Line("Broker operation was canceled.", LineType.Information);
return OperationVeredict.Canceled;
}
Comment thread
vnikonov-devolutions marked this conversation as resolved.

foreach (var line in decoded.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'))
if (status.Status is BrokerOperationStatus.Completed)
{
if (line.Length == 0)
// Captured output is not available anymore over the status endpoint; live
// output will be restored through the per-operation event channel.
var veredict = await GetProcessVeredict(status.ExitCode ?? -1, []);
if (veredict is OperationVeredict.Success)
{
Line("Operation completed successfully via agent broker.", LineType.Information);
}
else if (!string.IsNullOrWhiteSpace(status.Message))
{
continue;
Metadata.FailureMessage = status.Message;
}

output.Add(line);
Line(line, LineType.Information);
return veredict;
}

return output;
// Operation failed — surface a user-visible error.
string reason = status.Message ?? $"Exit code: {status.ExitCode}";
Line($"Operation failed via broker: {reason}", LineType.Error);
Metadata.FailureTitle = CoreTools.Translate("Operation denied or failed via broker");
Metadata.FailureMessage = reason;
return OperationVeredict.Failure;
}

/// <summary>
/// Fails the operation because the agent broker is unreachable: brokered operations
/// must not fall back to local execution, since policy evaluation and kill/pre/post
/// actions are owned by the broker. Sets the failure metadata and raises
/// <see cref="BrokerUnavailable"/> so the UI can notify the user.
/// </summary>
private OperationVeredict HandleBrokerUnavailable()
{
Line("Agent broker is not available. The operation cannot continue.", LineType.Error);
Logger.Error("[AgentBroker] Broker not available, aborting operation");
string message = CoreTools.Translate(
"The Devolutions Agent broker is not available. The operation cannot be performed. Please ensure the Devolutions Agent is installed and running.");
Metadata.FailureTitle = CoreTools.Translate("Agent broker unavailable");
Metadata.FailureMessage = message;
BrokerUnavailable?.Invoke(this, message);
return OperationVeredict.Failure;
}

private static BrokerClient CreateBrokerClient(bool requestedElevation) =>
Expand Down
Loading
Loading