Skip to content

Add AsyncAction so async work is awaited, not run as async void [minor] - #78

Merged
matt-edmondson merged 4 commits into
mainfrom
claude/intervalaction-59-async-action
Oct 5, 2026
Merged

matt-edmondson merged 4 commits into
mainfrom
claude/intervalaction-59-async-action

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #59

Problem

IntervalActionOptions.Action is a plain Action. The README's long-running example assigns an async lambda to it, which compiles to async void. That lambda returns at its first await, so ActionTask completed while the work was still running, which caused three problems:

  • runs overlapped (3 at once in the issue's repro),
  • FromLastCompletion measured from the first await,
  • an exception thrown after the await escaped onto the thread pool and crashed the process.

Change

  • New IntervalActionOptions.AsyncAction of type Func<CancellationToken, Task>?, following the triage comment. TryRun runs it through RunAsyncAction, which awaits it. So ActionTask completes when the work completes, FromLastCompletion is stamped after the await, and exceptions fault ActionTask and reach RethrowExceptions() like synchronous ones.
  • Stop() cancels the running action's token. A run that ends with OperationCanceledException because of that cancellation counts as a normal completion. The cancel happens outside Lock so the user's token callbacks never run under it. The run removes its source from the property under Lock before disposing it, and Stop() tolerates the dispose race.
  • Only one of Action or AsyncAction may be set; Start throws ArgumentException if both are. Action still defaults to a no-op, held as one shared NoAction instance so Start can tell the default apart from a value someone set. Action = null is now allowed only when AsyncAction is set. Every existing call site behaves as before.
  • Action has a remarks warning against assigning async lambdas.
  • The README's "Handling Long-Running Tasks" example now uses AsyncAction, and the options table lists the new property.

I tagged this [minor] because it adds public API.

Tests

These are the issue's acceptance criteria, plus two more:

  • AsyncActionRunsNeverOverlap: a 200 ms async body with a 20 ms interval never has more than one copy running.
  • AsyncActionFromLastCompletionMeasuresFromWhenTheWorkFinished: each start is at least ActionInterval after the previous body's awaited work finished.
  • AsyncActionExceptionAfterAwaitIsRethrown: an exception thrown after an await comes out of RethrowExceptions().
  • StopCancelsTheAsyncActionToken: Stop() ends an hour-long, token-honouring run promptly, and the run doesn't fault.
  • StartThrowsWhenBothActionAndAsyncActionAreSet.

These tests can't compile against main, because the API is new. To prove them, I temporarily replaced the awaited branch with the old fire-and-forget behaviour: start the task, don't await it. Under that mutation the three acceptance tests fail ("Async action runs should never overlap", "Run 1 started -46 ms after the previous run finished", "The polling loop should fault after the action throws"). With the real change the full suite passes, 26/26 locally on net10.0.

This touches the same file as #76 and #77, but none of them depends on another. Whichever merges second may need a trivial merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MDVSpB69ZHnwPkg4LgXgcx


Generated by Claude Code

claude added 2 commits October 5, 2026 11:35
An async lambda assigned to Action compiles to async void and returns
at its first await, so ActionTask completed early: runs overlapped,
FromLastCompletion measured from the first await, and an exception
after the await crashed the process. IntervalActionOptions.AsyncAction
(Func<CancellationToken, Task>) is awaited, so a run lasts as long as
its work, exceptions reach RethrowExceptions, and Stop() cancels the
token. Setting both Action and AsyncAction is rejected. The README's
long-running example now uses AsyncAction.

Fixes #59

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDVSpB69ZHnwPkg4LgXgcx
…t in the test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDVSpB69ZHnwPkg4LgXgcx
Comment thread IntervalAction.Test/IntervalActionTests.cs Fixed
…-59-async-action

# Conflicts:
#	IntervalAction.Test/IntervalActionTests.cs
@sonarqubecloud

sonarqubecloud Bot commented Oct 5, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants