fix(workflows): stop instance designer refresh timers when the circuit disconnects - #992
Conversation
…t disconnects WorkflowInstanceDesigner's periodic activity-state refresh and elapsed-time timers kept firing after the component was disposed, and any exception raised while refreshing (ObjectDisposedException from a torn-down scoped service, JSDisconnectedException from a lost circuit, or OperationCanceledException) escaped the timer callback unhandled, crashing the process. Track disposal with a flag both timer callbacks check before doing any work, and treat those three exception types as "the circuit is gone": stop the periodic refresh and return quietly instead of letting them propagate. Other exceptions still surface as before. DisposeAsync now stops and disposes both timers and the observer. Refs #743 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nd expose internal timer seams Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ed elapsed tick is a no-op Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Concurrent timer disposal and rearming can still produce an unhandled exception from the async-void refresh callback.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Prevents Blazor Server disconnects from leaving workflow-instance timers running against disposed circuit services.
Changes:
- Adds disposal-aware guards for refresh and elapsed timers.
- Handles circuit-disconnection exceptions and stops affected timers.
- Adds nine bUnit tests covering disposal and disconnect behavior.
File summaries
| File | Description |
|---|---|
WorkflowInstanceDesigner.razor.cs |
Adds guarded timer ticks and disposal handling. |
WorkflowInstanceDesignerDisconnectRefreshTests.cs |
Tests timer behavior during disposal and disconnects. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
…disposed refresh timer Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The disposal flag lacks cross-thread visibility, and the concurrency test does not currently create overlapping disposal calls.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Balanced
…ing disposals in the test Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
A lifecycle continuation can still create a periodic elapsed timer after component disposal, leaking the component.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
Both StartElapsedTimer and RefreshActivityStatePeriodically could race with DisposeAsync: they checked _disposed, then created and published a Timer without re-checking, so a concurrent DisposeAsync between the check and the publish left a live timer rooting the disposed component. Both creators now publish the timer atomically via Interlocked and re-check _disposed afterward, detaching and disposing the timer if disposal happened in that window. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Timer exceptions can be hidden, and refresh cleanup can be skipped when observer disposal fails.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Balanced
…resh timer on dispose Give the elapsed timer the same callback shape as the refresh timer so non-circuit-gone exceptions from ElapsedTimerTickAsync propagate instead of becoming unobserved task exceptions. Move refresh-timer cleanup into a finally block in DisposeAsync so it still runs when observer disposal faults. Route refresh-timer publication through a PublishRefreshTimer helper to make the timer's ownership/disposal path explicit for static analysis. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
An in-flight observer creation can publish a live observer after component disposal completes.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
…sposed CreateObserverAsync awaited the observer factory before publishing the result, so a disposal that ran during that await left the new observer unregistered but still subscribed and unmanaged. Re-check the disposal flag after the factory call (and again after publishing the observer atomically) and dispose the observer instead of leaving it dangling. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Both timers can fire before publication, bypassing their new stop and rearm guarantees.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/WorkflowInstanceDesigner.razor.cs:472
- The one-shot refresh timer is also armed before publication. If the creating thread is delayed long enough for the tick to run first, the tick cannot find this timer to stop or rearm; afterward
PublishRefreshTimerstores an already-fired timer, so refresh silently stops and the timer remains retained until teardown. Publish a disabled timer before arming it.
// Ownership of the timer created here transfers to _refreshTimer via PublishRefreshTimer; it is
// disposed by the stop path (StopRefreshActivityStatePeriodically) or by DisposeAsync.
PublishRefreshTimer(new Timer(Callback, null, TimeSpan.FromSeconds(1), Timeout.InfiniteTimeSpan));
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
Creates the elapsed and refresh timers disabled (Timeout.InfiniteTimeSpan), publishes them through the existing atomic publish path, re-checks for concurrent disposal, and only then arms them with Change(...) guarded by a try/catch(ObjectDisposedException). This closes the window where a timer's callback could fire and report a circuit-gone exception before CompareExchange/Exchange published it, causing StopElapsedTimer/ StopRefreshActivityStatePeriodically to see null while the timer went on to be published and rescheduled anyway. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Timer self-disposal can deadlock, and observer publication still races with teardown.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/WorkflowInstanceDesigner.razor.cs:247
- The disposal check and observer subscription/publication are not atomic with
DisposeAsync. Disposal can set_disposed, exchange a null observer, and complete; this continuation can then subscribe the newly created observer before the final check removes it. During that window a SignalR update can invoke component/JS work on the disconnected circuit. Coordinate the disposed transition and observer publication with the same synchronization primitive so no observer can be subscribed after teardown has won the race.
observer.ActivityExecutionLogUpdated += OnActivityExecutionLogUpdated;
var previousObserver = Interlocked.Exchange(ref _workflowInstanceObserver, observer);
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
…ops it Timer.DisposeAsync() only completes once in-flight callbacks return, but RefreshTimerTickAsync's terminal-state branch and RunTimerTickAsync's stop delegate ran it from inside the refresh timer's own callback, which could deadlock the callback on its own completion. Split the stop into a non-waiting StopRefreshTimer() for those tick paths and keep the draining StopRefreshActivityStatePeriodically() for callers outside the callback (DisposeAsync, HandleActivitySelectedAsync). Also dispose the local timer on the false path of PublishRefreshTimer so static analysis sees every path disposing it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Blanket handling of cancellation exceptions can permanently stop refreshes on healthy circuits after transient API cancellation.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/WorkflowInstanceDesigner.razor.cs:602
- Treating every
OperationCanceledExceptionas proof that the circuit is gone also catches cancellations from the refresh's backend calls.RefreshSelectedItemAsynccallsRemoteActivityExecutionService, which forwards the default token to HTTP API calls; a request timeout can therefore surface asTaskCanceledExceptionwhile the circuit is still healthy. This branch then silently removes the refresh timer permanently, contrary to the stated unchanged live-circuit behavior and the intent to propagate real failures. Please suppress cancellation only when it is tied to component/circuit teardown (or narrow this handling to the renderer invocation), rather than classifying all operation cancellation as a disconnect.
src/modules/Elsa.Studio.Workflows.Tests/WorkflowInstanceDesignerDisconnectRefreshTests.cs:228 - This explanation contradicts the asserted behavior: with the previous check/await/clear implementation, the first disposal remains suspended before clearing
_refreshTimer, so theAssert.Nullimmediately below would fail rather than “still hold.” Please state that the field would still be non-null so the regression mechanism is documented accurately.
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Purpose
Stop Elsa Studio from crashing when a Blazor Server circuit disconnects while a workflow instance is open, for example when the tab showing the instance is closed.
Scope
Description
Problem
WorkflowInstanceDesignerdrives a periodic refresh timer and a one-second elapsed-time timer. AfterPOST /_blazor/disconnectthe circuit's scoped services and JS interop are disposed, but the timers kept firing on thread-pool threads. The refresh path then threwObjectDisposedExceptionorMicrosoft.JSInterop.JSDisconnectedExceptionfromRefreshActivityStatePeriodically, and because the timer callback isasync voidthe exception escaped and took the host down.Solution
DisposeAsyncmarks it disposed first, then stops and disposes both timers and the observer, so no timer body runs after disposal, including the documented case where aSystem.Threading.Timercallback fires once more afterDispose.ObjectDisposedException,JSDisconnectedException, orOperationCanceledExceptiontreat the circuit as gone, stop that timer, and return; let every other exception propagate exactly as before, so real failures are not hidden.DisposeAsynccannot throw out of the callback.StateHasChanged, the details-tab refresh, and observer subscription are as they were.internalseams (the assembly already grants the test project internals access), plus aninternal virtualstate-notification seam so a test can make the render notification throw. No public surface changed.This builds on the approach in the draft PR #770, which caught only
ObjectDisposedExceptionon one path.Tests
WorkflowInstanceDesignerDisconnectRefreshTests(bunit, 18 tests) renders a lightweight subclass of the component and drives the ticks directly: after disposal neither tick does anything (the activity execution service is not called and no state notification is issued); when the service or the render notification throws any of the three circuit-gone exceptions, nothing propagates and the corresponding timer is stopped so later ticks make no further calls; a live tick still notifies once; a rearm against a timer disposed concurrently does not throw; two genuinely overlapping disposals (one blocked on an in-flight callback) leave both timer fields null without throwing; timer creators are no-ops after disposal and install exactly one timer on a live component; disposal stops the refresh timer even when observer disposal throws; and an observer created by a factory that completes after disposal is disposed and never published, while a normal creation still publishes and subscribes. The disposed and stop-on-disconnect assertions were checked to fail with the guards removed.Verification
dotnet build Elsa.Studio.sln: success.dotnet test src/modules/Elsa.Studio.Workflows.Tests/...: 204 passed.Fixes #743
🤖 Generated with Claude Code