// Copyright (C) 2026, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. #nullable enable using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using CoCoL; using Duplicati.Library.Interface; using Microsoft.VisualStudio.Threading; namespace Duplicati.Library.Main; /// /// Static helper that wires up instances for an /// operation, owns the progress ticker, and provides the disposable handle that /// fires the completion callbacks. Extracted from to keep /// the report-module plumbing in one place. /// internal static class ReportModuleController { /// /// The tag used for logging. /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(); /// /// The interval at which progress snapshots are pushed to report modules. /// Report modules decide for themselves how often to act on these ticks. /// public static readonly TimeSpan ProgressTickInterval = TimeSpan.FromSeconds(5); /// /// Wires up any loaded instances on the given /// controller so they observe the operation's lifecycle, backend events, log /// entries and progress. Each active module is wrapped in a /// appended to the controller's message sink, /// and a periodic ticker is started that pushes progress snapshots to the modules. /// /// The controller running the operation. /// The result object for the operation. /// The options containing the loaded report modules. /// The cancellation token. /// A handle that completes the report modules when disposed. public static async Task SetupAsync(Controller controller, IBasicResults result, Options options, CancellationToken cancellationToken) { var adapters = new List(); var loadedModules = options.LoadedModules; if (loadedModules != null) { foreach (var mx in loadedModules) { if (mx is not IReportModule reportModule || !reportModule.IsActive) continue; var adapter = new ReportModuleAdapter(reportModule, cancellationToken); adapters.Add(adapter); controller.AppendSinkAsync(adapter).FireAndForget(); try { await reportModule.OnOperationStartedAsync(options.MainAction.ToString(), result, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, $"ReportModuleStartError{reportModule.Key}", ex, "Report module {0} OnOperationStartedAsync failed: {1}", reportModule.Key, ex.Message); } } } // When no report module is active there is nothing to wire up: avoid the // progress ticker and leave the message sink untouched so the hot path // keeps its original (zero-overhead) behavior. if (adapters.Count == 0) return default; var tickerCts = new CancellationTokenSource(); var ticker = StartProgressTicker(adapters, tickerCts.Token); return new ReportModulesHandle(adapters, result, ticker, tickerCts); } /// /// Starts a background loop that periodically pushes progress snapshots to the /// given report module adapters. The loop stops when the cancellation token is /// cancelled, and the returned task is awaited by the handle on dispose. /// /// The adapters to tick. /// The token that stops the loop. /// A task that completes when the loop stops. private static Task StartProgressTicker(IReadOnlyList adapters, CancellationToken cancellationToken) { if (adapters.Count == 0) return Task.CompletedTask; return Task.Run(async () => { try { using var timer = new PeriodicTimer(ProgressTickInterval); while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) { foreach (var adapter in adapters) await adapter.TickAsync().ConfigureAwait(false); } } catch (OperationCanceledException) { /* expected when the operation ends */ } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "ReportModuleTickerError", ex, "Report module progress ticker failed: {0}", ex.Message); } }, cancellationToken); } /// /// Handle that completes report modules when disposed: it stops the progress /// ticker and fires on each /// module. It is disposed in before /// the module instances themselves are disposed. /// public class ReportModulesHandle : IDisposable { private readonly List m_adapters; private readonly IBasicResults m_result; private readonly Task m_ticker; private readonly CancellationTokenSource m_tickerCts; public ReportModulesHandle(List adapters, IBasicResults result, Task ticker, CancellationTokenSource tickerCts) { m_adapters = adapters; m_result = result; m_ticker = ticker; m_tickerCts = tickerCts; } public Exception? OperationException { get; set; } public async Task StopAsync(CancellationToken cancellationToken) { // Stop the ticker first so no more progress ticks race the completion callbacks. try { m_tickerCts?.Cancel(); } catch { } if (m_ticker != null) try { await m_ticker.WithCancellation(cancellationToken); } catch { } if (m_adapters != null) { // Signal all modules to stop processing var tasks = new List(); foreach (var adapter in m_adapters) { try { tasks.Add(adapter.Module.OnOperationCompletedAsync(m_result, OperationException, cancellationToken)); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, $"ReportModuleCompleteError{adapter.Module.Key}", ex, "Report module {0} OnOperationCompletedAsync failed: {1}", adapter.Module.Key, ex.Message); } } if (tasks.Count > 0) { try { await Task.WhenAll(tasks) .WithCancellation(cancellationToken) .ConfigureAwait(false); } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "ReportModuleCompletionError", ex, "Report module completion callbacks failed: {0}", ex.Message); } } } m_tickerCts?.Dispose(); } public void Dispose() { try { m_tickerCts?.Cancel(); } catch { } m_tickerCts?.Dispose(); } } }