< Summary - Jellyfin

Information
Class: Emby.Server.Implementations.ScheduledTasks.ScheduledTaskWorker
Assembly: Emby.Server.Implementations
File(s): /srv/git/jellyfin/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs
Line coverage
66%
Covered lines: 126
Uncovered lines: 63
Coverable lines: 189
Total lines: 678
Line coverage: 66.6%
Branch coverage
72%
Covered branches: 45
Total branches: 62
Branch coverage: 72.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_LastExecutionResult()62.5%20842.85%
set_LastExecutionResult(...)100%11100%
get_Name()100%11100%
get_Description()100%210%
get_Category()100%210%
get_State()100%44100%
get_InternalTriggers()100%11100%
set_InternalTriggers(...)0%620%
get_Triggers()100%210%
set_Triggers(...)100%210%
get_Id()100%22100%
InitTriggerEvents()100%11100%
ReloadTriggerEvents()100%210%
ReloadTriggerEvents(...)100%22100%
OnProgressChanged(...)100%22100%
Cancel()0%620%
CancelIfRunning()100%22100%
GetScheduledTasksConfigurationDirectory()100%11100%
GetScheduledTasksDataDirectory()100%11100%
GetHistoryFilePath()100%11100%
GetConfigurationFilePath()100%11100%
LoadTriggers()100%11100%
LoadTriggerSettings()75%5466.66%
GetDefaultTriggers()100%2118.18%
SaveTriggers(...)100%210%
OnTaskCompleted(...)100%22100%
Dispose()100%11100%
Dispose(...)91.66%161269.69%
GetTrigger(...)50%301661.9%
DisposeTriggers()100%44100%

File(s)

/srv/git/jellyfin/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs

#LineLine coverage
 1#nullable disable
 2
 3using System;
 4using System.Collections.Generic;
 5using System.Globalization;
 6using System.IO;
 7using System.Linq;
 8using System.Text.Json;
 9using System.Threading;
 10using System.Threading.Tasks;
 11using Emby.Server.Implementations.ScheduledTasks.Triggers;
 12using Jellyfin.Data.Events;
 13using Jellyfin.Extensions.Json;
 14using MediaBrowser.Common.Configuration;
 15using MediaBrowser.Common.Extensions;
 16using MediaBrowser.Model.Tasks;
 17using Microsoft.Extensions.Logging;
 18
 19namespace Emby.Server.Implementations.ScheduledTasks;
 20
 21/// <summary>
 22/// Class ScheduledTaskWorker.
 23/// </summary>
 24public class ScheduledTaskWorker : IScheduledTaskWorker
 25{
 39926    private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
 27    private readonly IApplicationPaths _applicationPaths;
 28    private readonly ILogger _logger;
 29    private readonly ITaskManager _taskManager;
 39930    private readonly Lock _lastExecutionResultSyncLock = new();
 31    private bool _readFromFile;
 32    private TaskResult _lastExecutionResult;
 33    private Task _currentTask;
 34    private Tuple<TaskTriggerInfo, ITaskTrigger>[] _triggers;
 35    private string _id;
 36
 37    /// <summary>
 38    /// Initializes a new instance of the <see cref="ScheduledTaskWorker" /> class.
 39    /// </summary>
 40    /// <param name="scheduledTask">The scheduled task.</param>
 41    /// <param name="applicationPaths">The application paths.</param>
 42    /// <param name="taskManager">The task manager.</param>
 43    /// <param name="logger">The logger.</param>
 44    /// <exception cref="ArgumentNullException">
 45    /// scheduledTask
 46    /// or
 47    /// applicationPaths
 48    /// or
 49    /// taskManager
 50    /// or
 51    /// jsonSerializer
 52    /// or
 53    /// logger.
 54    /// </exception>
 55    public ScheduledTaskWorker(IScheduledTask scheduledTask, IApplicationPaths applicationPaths, ITaskManager taskManage
 56    {
 39957        ArgumentNullException.ThrowIfNull(scheduledTask);
 39958        ArgumentNullException.ThrowIfNull(applicationPaths);
 39959        ArgumentNullException.ThrowIfNull(taskManager);
 39960        ArgumentNullException.ThrowIfNull(logger);
 61
 39962        ScheduledTask = scheduledTask;
 39963        _applicationPaths = applicationPaths;
 39964        _taskManager = taskManager;
 39965        _logger = logger;
 66
 39967        InitTriggerEvents();
 39968    }
 69
 70    /// <inheritdoc />
 71    public event EventHandler<GenericEventArgs<double>> TaskProgress;
 72
 73    /// <inheritdoc />
 74    public IScheduledTask ScheduledTask { get; private set; }
 75
 76    /// <inheritdoc />
 77    public TaskResult LastExecutionResult
 78    {
 79        get
 80        {
 38481            var path = GetHistoryFilePath();
 82
 83            lock (_lastExecutionResultSyncLock)
 84            {
 38485                if (_lastExecutionResult is null && !_readFromFile)
 86                {
 33687                    if (File.Exists(path))
 88                    {
 089                        var bytes = File.ReadAllBytes(path);
 090                        if (bytes.Length > 0)
 91                        {
 92                            try
 93                            {
 094                                _lastExecutionResult = JsonSerializer.Deserialize<TaskResult>(bytes, _jsonOptions);
 095                            }
 096                            catch (JsonException ex)
 97                            {
 098                                _logger.LogError(ex, "Error deserializing {File}", path);
 099                            }
 100                        }
 101                        else
 102                        {
 0103                            _logger.LogDebug("Scheduled Task history file {Path} is empty. Skipping deserialization.", p
 104                        }
 105                    }
 106
 336107                    _readFromFile = true;
 108                }
 384109            }
 110
 384111            return _lastExecutionResult;
 112        }
 113
 114        private set
 115        {
 33116            _lastExecutionResult = value;
 117
 33118            var path = GetHistoryFilePath();
 33119            Directory.CreateDirectory(Path.GetDirectoryName(path));
 120
 121            lock (_lastExecutionResultSyncLock)
 122            {
 33123                using FileStream createStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
 33124                using Utf8JsonWriter jsonStream = new Utf8JsonWriter(createStream);
 33125                JsonSerializer.Serialize(jsonStream, value, _jsonOptions);
 126            }
 33127        }
 128    }
 129
 130    /// <inheritdoc />
 517131    public string Name => ScheduledTask.Name;
 132
 133    /// <inheritdoc />
 0134    public string Description => ScheduledTask.Description;
 135
 136    /// <inheritdoc />
 0137    public string Category => ScheduledTask.Category;
 138
 139    /// <summary>
 140    /// Gets or sets the current cancellation token.
 141    /// </summary>
 142    /// <value>The current cancellation token source.</value>
 143    private CancellationTokenSource CurrentCancellationTokenSource { get; set; }
 144
 145    /// <summary>
 146    /// Gets or sets the current execution start time.
 147    /// </summary>
 148    /// <value>The current execution start time.</value>
 149    private DateTime CurrentExecutionStartTime { get; set; }
 150
 151    /// <inheritdoc />
 152    public TaskState State
 153    {
 154        get
 155        {
 447156            if (CurrentCancellationTokenSource is not null)
 157            {
 14158                return CurrentCancellationTokenSource.IsCancellationRequested
 14159                            ? TaskState.Cancelling
 14160                            : TaskState.Running;
 161            }
 162
 433163            return TaskState.Idle;
 164        }
 165    }
 166
 167    /// <inheritdoc />
 168    public double? CurrentProgress { get; private set; }
 169
 170    /// <summary>
 171    /// Gets or sets the triggers that define when the task will run.
 172    /// </summary>
 173    /// <value>The triggers.</value>
 174    private Tuple<TaskTriggerInfo, ITaskTrigger>[] InternalTriggers
 175    {
 798176        get => _triggers;
 177        set
 178        {
 0179            ArgumentNullException.ThrowIfNull(value);
 180
 181            // Cleanup current triggers
 0182            if (_triggers is not null)
 183            {
 0184                DisposeTriggers();
 185            }
 186
 0187            _triggers = value.ToArray();
 188
 0189            ReloadTriggerEvents(false);
 0190        }
 191    }
 192
 193    /// <inheritdoc />
 194    public IReadOnlyList<TaskTriggerInfo> Triggers
 195    {
 196        get
 197        {
 0198            return Array.ConvertAll(InternalTriggers, i => i.Item1);
 199        }
 200
 201        set
 202        {
 0203            ArgumentNullException.ThrowIfNull(value);
 204
 205            // This null check is not great, but is needed to handle bad user input, or user mucking with the config fil
 0206            var triggerList = value.Where(i => i is not null).ToArray();
 207
 0208            SaveTriggers(triggerList);
 209
 0210            InternalTriggers = Array.ConvertAll(triggerList, i => new Tuple<TaskTriggerInfo, ITaskTrigger>(i, GetTrigger
 0211        }
 212    }
 213
 214    /// <inheritdoc />
 215    public string Id
 216    {
 217        get
 218        {
 849219            return _id ??= ScheduledTask.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture);
 220        }
 221    }
 222
 223    private void InitTriggerEvents()
 224    {
 399225        _triggers = LoadTriggers();
 399226        ReloadTriggerEvents(true);
 399227    }
 228
 229    /// <inheritdoc />
 230    public void ReloadTriggerEvents()
 231    {
 0232        ReloadTriggerEvents(false);
 0233    }
 234
 235    /// <summary>
 236    /// Reloads the trigger events.
 237    /// </summary>
 238    /// <param name="isApplicationStartup">if set to <c>true</c> [is application startup].</param>
 239    private void ReloadTriggerEvents(bool isApplicationStartup)
 240    {
 1554241        foreach (var triggerInfo in InternalTriggers)
 242        {
 378243            var trigger = triggerInfo.Item2;
 244
 378245            trigger.Stop();
 246
 378247            trigger.Triggered -= OnTriggerTriggered;
 378248            trigger.Triggered += OnTriggerTriggered;
 378249            trigger.Start(LastExecutionResult, _logger, Name, isApplicationStartup);
 250        }
 399251    }
 252
 253    /// <summary>
 254    /// Handles the Triggered event of the trigger control.
 255    /// </summary>
 256    /// <param name="sender">The source of the event.</param>
 257    /// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param>
 258    private async void OnTriggerTriggered(object sender, EventArgs e)
 259    {
 260        var trigger = (ITaskTrigger)sender;
 261
 262        if (ScheduledTask is IConfigurableScheduledTask configurableTask && !configurableTask.IsEnabled)
 263        {
 264            return;
 265        }
 266
 267        _logger.LogDebug("{0} fired for task: {1}", trigger.GetType().Name, Name);
 268
 269        trigger.Stop();
 270
 271        _taskManager.QueueScheduledTask(ScheduledTask, trigger.TaskOptions);
 272
 273        await Task.Delay(1000).ConfigureAwait(false);
 274
 275        trigger.Start(LastExecutionResult, _logger, Name, false);
 276    }
 277
 278    /// <summary>
 279    /// Executes the task.
 280    /// </summary>
 281    /// <param name="options">Task options.</param>
 282    /// <returns>Task.</returns>
 283    /// <exception cref="InvalidOperationException">Cannot execute a Task that is already running.</exception>
 284    public async Task Execute(TaskOptions options)
 285    {
 286        var task = Task.Run(async () => await ExecuteInternal(options).ConfigureAwait(false));
 287
 288        _currentTask = task;
 289
 290        try
 291        {
 292            await task.ConfigureAwait(false);
 293        }
 294        finally
 295        {
 296            _currentTask = null;
 297            GC.Collect();
 298        }
 299    }
 300
 301    private async Task ExecuteInternal(TaskOptions options)
 302    {
 303        // Cancel the current execution, if any
 304        if (CurrentCancellationTokenSource is not null)
 305        {
 306            throw new InvalidOperationException("Cannot execute a Task that is already running");
 307        }
 308
 309        var progress = new Progress<double>();
 310
 311        CurrentCancellationTokenSource = new CancellationTokenSource();
 312
 313        _logger.LogDebug("Executing {0}", Name);
 314
 315        ((TaskManager)_taskManager).OnTaskExecuting(this);
 316
 317        progress.ProgressChanged += OnProgressChanged;
 318
 319        TaskCompletionStatus status;
 320        CurrentExecutionStartTime = DateTime.UtcNow;
 321
 322        Exception failureException = null;
 323
 324        try
 325        {
 326            if (options is not null && options.MaxRuntimeTicks.HasValue)
 327            {
 328                CurrentCancellationTokenSource.CancelAfter(TimeSpan.FromTicks(options.MaxRuntimeTicks.Value));
 329            }
 330
 331            await ScheduledTask.ExecuteAsync(progress, CurrentCancellationTokenSource.Token).ConfigureAwait(false);
 332
 333            status = TaskCompletionStatus.Completed;
 334        }
 335        catch (OperationCanceledException)
 336        {
 337            status = TaskCompletionStatus.Cancelled;
 338        }
 339        catch (Exception ex)
 340        {
 341            _logger.LogError(ex, "Error executing Scheduled Task");
 342
 343            failureException = ex;
 344
 345            status = TaskCompletionStatus.Failed;
 346        }
 347
 348        var startTime = CurrentExecutionStartTime;
 349        var endTime = DateTime.UtcNow;
 350
 351        progress.ProgressChanged -= OnProgressChanged;
 352        CurrentCancellationTokenSource.Dispose();
 353        CurrentCancellationTokenSource = null;
 354        CurrentProgress = null;
 355
 356        OnTaskCompleted(startTime, endTime, status, failureException);
 357    }
 358
 359    /// <summary>
 360    /// Progress_s the progress changed.
 361    /// </summary>
 362    /// <param name="sender">The sender.</param>
 363    /// <param name="e">The e.</param>
 364    private void OnProgressChanged(object sender, double e)
 365    {
 129366        e = Math.Min(e, 100);
 367
 129368        CurrentProgress = e;
 369
 129370        TaskProgress?.Invoke(this, new GenericEventArgs<double>(e));
 128371    }
 372
 373    /// <summary>
 374    /// Stops the task if it is currently executing.
 375    /// </summary>
 376    /// <exception cref="InvalidOperationException">Cannot cancel a Task unless it is in the Running state.</exception>
 377    public void Cancel()
 378    {
 0379        if (State != TaskState.Running)
 380        {
 0381            throw new InvalidOperationException("Cannot cancel a Task unless it is in the Running state.");
 382        }
 383
 0384        CancelIfRunning();
 0385    }
 386
 387    /// <summary>
 388    /// Cancels if running.
 389    /// </summary>
 390    public void CancelIfRunning()
 391    {
 20392        if (State == TaskState.Running)
 393        {
 2394            _logger.LogInformation("Attempting to cancel Scheduled Task {0}", Name);
 2395            CurrentCancellationTokenSource.Cancel();
 396        }
 20397    }
 398
 399    /// <summary>
 400    /// Gets the scheduled tasks configuration directory.
 401    /// </summary>
 402    /// <returns>System.String.</returns>
 403    private string GetScheduledTasksConfigurationDirectory()
 404    {
 399405        return Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks");
 406    }
 407
 408    /// <summary>
 409    /// Gets the scheduled tasks data directory.
 410    /// </summary>
 411    /// <returns>System.String.</returns>
 412    private string GetScheduledTasksDataDirectory()
 413    {
 417414        return Path.Combine(_applicationPaths.DataPath, "ScheduledTasks");
 415    }
 416
 417    /// <summary>
 418    /// Gets the history file path.
 419    /// </summary>
 420    /// <value>The history file path.</value>
 421    private string GetHistoryFilePath()
 422    {
 417423        return Path.Combine(GetScheduledTasksDataDirectory(), new Guid(Id) + ".js");
 424    }
 425
 426    /// <summary>
 427    /// Gets the configuration file path.
 428    /// </summary>
 429    /// <returns>System.String.</returns>
 430    private string GetConfigurationFilePath()
 431    {
 399432        return Path.Combine(GetScheduledTasksConfigurationDirectory(), new Guid(Id) + ".js");
 433    }
 434
 435    /// <summary>
 436    /// Loads the triggers.
 437    /// </summary>
 438    /// <returns>IEnumerable{BaseTaskTrigger}.</returns>
 439    private Tuple<TaskTriggerInfo, ITaskTrigger>[] LoadTriggers()
 440    {
 441        // This null check is not great, but is needed to handle bad user input, or user mucking with the config file in
 399442        var settings = LoadTriggerSettings().Where(i => i is not null);
 443
 399444        return settings.Select(i => new Tuple<TaskTriggerInfo, ITaskTrigger>(i, GetTrigger(i))).ToArray();
 445    }
 446
 447    private TaskTriggerInfo[] LoadTriggerSettings()
 448    {
 399449        string path = GetConfigurationFilePath();
 399450        TaskTriggerInfo[] list = null;
 399451        if (File.Exists(path))
 452        {
 0453            var bytes = File.ReadAllBytes(path);
 0454            list = JsonSerializer.Deserialize<TaskTriggerInfo[]>(bytes, _jsonOptions);
 455        }
 456
 457        // Return defaults if file doesn't exist.
 399458        return list ?? GetDefaultTriggers();
 459    }
 460
 461    private TaskTriggerInfo[] GetDefaultTriggers()
 462    {
 463        try
 464        {
 399465            return ScheduledTask.GetDefaultTriggers().ToArray();
 466        }
 0467        catch
 468        {
 0469            return
 0470            [
 0471                new()
 0472                {
 0473                    IntervalTicks = TimeSpan.FromDays(1).Ticks,
 0474                    Type = TaskTriggerInfoType.IntervalTrigger
 0475                }
 0476            ];
 477        }
 399478    }
 479
 480    /// <summary>
 481    /// Saves the triggers.
 482    /// </summary>
 483    /// <param name="triggers">The triggers.</param>
 484    private void SaveTriggers(TaskTriggerInfo[] triggers)
 485    {
 0486        var path = GetConfigurationFilePath();
 487
 0488        Directory.CreateDirectory(Path.GetDirectoryName(path));
 0489        using FileStream createStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
 0490        using Utf8JsonWriter jsonWriter = new Utf8JsonWriter(createStream);
 0491        JsonSerializer.Serialize(jsonWriter, triggers, _jsonOptions);
 0492    }
 493
 494    /// <summary>
 495    /// Called when [task completed].
 496    /// </summary>
 497    /// <param name="startTime">The start time.</param>
 498    /// <param name="endTime">The end time.</param>
 499    /// <param name="status">The status.</param>
 500    /// <param name="ex">The exception.</param>
 501    private void OnTaskCompleted(DateTime startTime, DateTime endTime, TaskCompletionStatus status, Exception ex)
 502    {
 33503        var elapsedTime = endTime - startTime;
 504
 33505        _logger.LogInformation("{0} {1} after {2} minute(s) and {3} seconds", Name, status, Math.Truncate(elapsedTime.To
 506
 33507        var result = new TaskResult
 33508        {
 33509            StartTimeUtc = startTime,
 33510            EndTimeUtc = endTime,
 33511            Status = status,
 33512            Name = Name,
 33513            Id = Id
 33514        };
 515
 33516        result.Key = ScheduledTask.Key;
 517
 33518        if (ex is not null)
 519        {
 1520            result.ErrorMessage = ex.Message;
 1521            result.LongErrorMessage = ex.StackTrace;
 522        }
 523
 33524        LastExecutionResult = result;
 525
 33526        ((TaskManager)_taskManager).OnTaskCompleted(this, result);
 33527    }
 528
 529    /// <inheritdoc />
 530    public void Dispose()
 531    {
 399532        Dispose(true);
 399533        GC.SuppressFinalize(this);
 399534    }
 535
 536    /// <summary>
 537    /// Releases unmanaged and - optionally - managed resources.
 538    /// </summary>
 539    /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only 
 540    protected virtual void Dispose(bool dispose)
 541    {
 399542        if (dispose)
 543        {
 399544            DisposeTriggers();
 545
 399546            var wasRunning = State == TaskState.Running;
 399547            var startTime = CurrentExecutionStartTime;
 548
 399549            var token = CurrentCancellationTokenSource;
 399550            if (token is not null)
 551            {
 552                try
 553                {
 9554                    _logger.LogInformation("{Name}: Cancelling", Name);
 9555                    token.Cancel();
 9556                }
 0557                catch (Exception ex)
 558                {
 0559                    _logger.LogError(ex, "Error calling CancellationToken.Cancel();");
 0560                }
 561            }
 562
 399563            var task = _currentTask;
 399564            if (task is not null)
 565            {
 566                try
 567                {
 8568                    _logger.LogInformation("{Name}: Waiting on Task", Name);
 8569                    var exited = task.Wait(2000);
 570
 8571                    if (exited)
 572                    {
 8573                        _logger.LogInformation("{Name}: Task exited", Name);
 574                    }
 575                    else
 576                    {
 0577                        _logger.LogInformation("{Name}: Timed out waiting for task to stop", Name);
 578                    }
 8579                }
 0580                catch (Exception ex)
 581                {
 0582                    _logger.LogError(ex, "Error calling Task.WaitAll();");
 0583                }
 584            }
 585
 399586            if (token is not null)
 587            {
 588                try
 589                {
 9590                    _logger.LogDebug("{Name}: Disposing CancellationToken", Name);
 9591                    token.Dispose();
 9592                }
 0593                catch (Exception ex)
 594                {
 0595                    _logger.LogError(ex, "Error calling CancellationToken.Dispose();");
 0596                }
 597            }
 598
 399599            if (wasRunning)
 600            {
 8601                OnTaskCompleted(startTime, DateTime.UtcNow, TaskCompletionStatus.Aborted, null);
 602            }
 603        }
 399604    }
 605
 606    /// <summary>
 607    /// Converts a TaskTriggerInfo into a concrete BaseTaskTrigger.
 608    /// </summary>
 609    /// <param name="info">The info.</param>
 610    /// <returns>BaseTaskTrigger.</returns>
 611    /// <exception cref="ArgumentException">Invalid trigger type:  + info.Type.</exception>
 612    private ITaskTrigger GetTrigger(TaskTriggerInfo info)
 613    {
 378614        var options = new TaskOptions
 378615        {
 378616            MaxRuntimeTicks = info.MaxRuntimeTicks
 378617        };
 618
 378619        if (info.Type == TaskTriggerInfoType.DailyTrigger)
 620        {
 42621            if (!info.TimeOfDayTicks.HasValue)
 622            {
 0623                throw new ArgumentException("Info did not contain a TimeOfDayTicks.", nameof(info));
 624            }
 625
 42626            return new DailyTrigger(TimeSpan.FromTicks(info.TimeOfDayTicks.Value), options);
 627        }
 628
 336629        if (info.Type == TaskTriggerInfoType.WeeklyTrigger)
 630        {
 0631            if (!info.TimeOfDayTicks.HasValue)
 632            {
 0633                throw new ArgumentException("Info did not contain a TimeOfDayTicks.", nameof(info));
 634            }
 635
 0636            if (!info.DayOfWeek.HasValue)
 637            {
 0638                throw new ArgumentException("Info did not contain a DayOfWeek.", nameof(info));
 639            }
 640
 0641            return new WeeklyTrigger(TimeSpan.FromTicks(info.TimeOfDayTicks.Value), info.DayOfWeek.Value, options);
 642        }
 643
 336644        if (info.Type == TaskTriggerInfoType.IntervalTrigger)
 645        {
 273646            if (!info.IntervalTicks.HasValue)
 647            {
 0648                throw new ArgumentException("Info did not contain a IntervalTicks.", nameof(info));
 649            }
 650
 273651            return new IntervalTrigger(TimeSpan.FromTicks(info.IntervalTicks.Value), options);
 652        }
 653
 63654        if (info.Type == TaskTriggerInfoType.StartupTrigger)
 655        {
 63656            return new StartupTrigger(options);
 657        }
 658
 0659        throw new ArgumentException("Unrecognized trigger type: " + info.Type);
 660    }
 661
 662    /// <summary>
 663    /// Disposes each trigger.
 664    /// </summary>
 665    private void DisposeTriggers()
 666    {
 1554667        foreach (var triggerInfo in InternalTriggers)
 668        {
 378669            var trigger = triggerInfo.Item2;
 378670            trigger.Triggered -= OnTriggerTriggered;
 378671            trigger.Stop();
 378672            if (trigger is IDisposable disposable)
 673            {
 315674                disposable.Dispose();
 675            }
 676        }
 399677    }
 678}