Track workflow execution in AppComponentTest to prevent premature disposal.

This commit is contained in:
Sipke Schoorstra 2026-02-21 15:57:46 +01:00
parent d5efd38dcc
commit 081e4ccac0
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F

View file

@ -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<string, byte> _runningWorkflowIds = new();
protected AppComponentTest(App app)
{
@ -25,14 +28,20 @@ public abstract class AppComponentTest : IDisposable
var tenantAccessor = Scope.ServiceProvider.GetRequiredService<ITenantAccessor>();
_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>();
_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<IWorkflowInstanceStore>();
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.
}
}
}