From 081e4ccac09cbc82df82aaee72d70f61885d3c71 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 21 Feb 2026 15:57:46 +0100 Subject: [PATCH] Track workflow execution in `AppComponentTest` to prevent premature disposal. --- .../Helpers/Abstractions/AppComponentTest.cs | 70 +++++++++++-------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Abstractions/AppComponentTest.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Abstractions/AppComponentTest.cs index 8d29ba0fe..b2783427a 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Abstractions/AppComponentTest.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Abstractions/AppComponentTest.cs @@ -1,8 +1,8 @@ +using System.Collections.Concurrent; using Elsa.Common.Multitenancy; +using Elsa.Testing.Shared; +using Elsa.Testing.Shared.Services; using Elsa.Workflows.ComponentTests.Fixtures; -using Elsa.Workflows.Management; -using Elsa.Workflows.Management.Filters; -using Elsa.Workflows.Models; using Microsoft.Extensions.DependencyInjection; namespace Elsa.Workflows.ComponentTests.Abstractions; @@ -15,6 +15,9 @@ public abstract class AppComponentTest : IDisposable protected Infrastructure Infrastructure { get; } protected IServiceScope Scope { get; } private readonly IDisposable _tenantScope; + private readonly WorkflowEvents _workflowEvents; + private readonly ManualResetEventSlim _allWorkflowsIdle = new(true); // starts signaled (no workflows running) + private readonly ConcurrentDictionary _runningWorkflowIds = new(); protected AppComponentTest(App app) { @@ -25,14 +28,20 @@ public abstract class AppComponentTest : IDisposable var tenantAccessor = Scope.ServiceProvider.GetRequiredService(); _tenantScope = tenantAccessor.PushContext(new Tenant { Id = string.Empty, Name = "Default" }); + + // Subscribe to workflow lifecycle events to track in-flight workflows. + _workflowEvents = app.WorkflowServer.Services.GetRequiredService(); + _workflowEvents.WorkflowStateCommitted += OnWorkflowStateCommitted; } void IDisposable.Dispose() { - // Wait for all workflows to reach terminal state before disposing scope - // This prevents TaskCanceledException when workflows are still executing + // Wait for all workflows to reach terminal state before disposing scope. + // This prevents TaskCanceledException when workflows are still executing. WaitForWorkflowsToComplete(); + _workflowEvents.WorkflowStateCommitted -= OnWorkflowStateCommitted; + _allWorkflowsIdle.Dispose(); _tenantScope.Dispose(); Scope.Dispose(); OnDispose(); @@ -42,39 +51,38 @@ public abstract class AppComponentTest : IDisposable { } + private void OnWorkflowStateCommitted(object? sender, WorkflowStateCommittedEventArgs e) + { + var instanceId = e.WorkflowExecutionContext.Id; + var status = e.WorkflowExecutionContext.Status; + + if (status == WorkflowStatus.Running) + { + // Track this instance as in-flight. + if (_runningWorkflowIds.TryAdd(instanceId, 0) && _runningWorkflowIds.Count == 1) + _allWorkflowsIdle.Reset(); + } + else + { + // Workflow reached a terminal state; remove it. + _runningWorkflowIds.TryRemove(instanceId, out _); + + if (_runningWorkflowIds.IsEmpty) + _allWorkflowsIdle.Set(); + } + } + private void WaitForWorkflowsToComplete() { try { - var workflowInstanceStore = Scope.ServiceProvider.GetRequiredService(); - var timeout = TimeSpan.FromSeconds(10); - var pollInterval = TimeSpan.FromMilliseconds(50); - var deadline = DateTime.UtcNow.Add(timeout); - - while (DateTime.UtcNow < deadline) - { - var filter = new WorkflowInstanceFilter - { - WorkflowStatus = WorkflowStatus.Running - }; - - // Use async method synchronously - acceptable in cleanup/dispose - var runningWorkflows = workflowInstanceStore.FindManyAsync(filter, CancellationToken.None) - .GetAwaiter() - .GetResult(); - - if (!runningWorkflows.Any()) - return; // All workflows completed - - Thread.Sleep(pollInterval); - } - - // If we reach here, workflows didn't complete in time - // Log but don't throw to avoid masking actual test failures + // Wait up to 10 seconds for all tracked workflows to finish. + // If no workflows were started, the event is already signaled and this returns immediately. + _allWorkflowsIdle.Wait(TimeSpan.FromSeconds(10)); } catch { - // Swallow exceptions during cleanup to avoid masking test failures + // Swallow exceptions during cleanup to avoid masking test failures. } } } \ No newline at end of file