Skip to content

Mediator Runner/Waker fire-and-forget: RunAsync returns before background work finishes #4078

@thomhurst

Description

@thomhurst

Summary

Paramore.Brighter.Mediator.Runner.RunAsync and Waker.RunAsync use Task.Factory.StartNew(async () => ...) without calling .Unwrap(). StartNew returns a Task<Task> whose outer task completes the moment the lambda hits its first await — not when the inner work finishes. The subsequent Task.WaitAll([task], cancellationToken) therefore waits on the outer task, which signals completion almost immediately, and RunAsync returns (logging "Finished") while ProcessJobs / Wake is still running on the threadpool.

Affected sites

  • src/Paramore.Brighter.Mediator/Runner.cs:71-79Task.Factory.StartNew(async () => { ... await ProcessJobs(...); ... }) is not unwrapped.
  • src/Paramore.Brighter.Mediator/Waker.cs:66-77Task.Factory.StartNew(async () => { ... await Wake(...); ... }) is not unwrapped.

Symptom

Starting runner {RunnerName}
Finished runner {RunnerName}   <-- logged immediately, before ProcessJobs has finished

The "Finished" log fires the moment the lambda first awaits, even though the loop continues running in the background. Hosts that rely on RunAsync returning to know when shutdown has completed will see a false signal. Cancellation does eventually propagate through the inner task, but observability and shutdown ordering are broken.

Proposed fix

Append .Unwrap() so WaitAll waits on the inner work:

var task = Task.Factory.StartNew(async () =>
{
    cancellationToken.ThrowIfCancellationRequested();
    await ProcessJobs(cancellationToken);
    cancellationToken.ThrowIfCancellationRequested();
}, cancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default).Unwrap();

Task.WaitAll([task], cancellationToken);

Same change applies to Waker.RunAsync.

Related quality issues in the same files (smaller scope, fixable alongside)

  • Runner.cs:67 and Waker.cs:62: RunAsync returns void despite the Async suffix and the fact that it blocks via Task.WaitAll. Either rename to Run or change the signature to return a Task and let callers await.
  • Runner.cs:77: redundant cancellationToken.ThrowIfCancellationRequested() after await ProcessJobs(cancellationToken)await already propagates cancellation.
  • Waker.cs:72-73: if (cancellationToken.IsCancellationRequested) cancellationToken.ThrowIfCancellationRequested(); — the if is pointless because ThrowIfCancellationRequested already performs that check.

Context

Surfaced during code review of the fix for #4071 (the TaskScheduler.Current deadlock). Those two files were touched to apply the same TaskScheduler.Default pin, and this fire-and-forget bug was visible in the surrounding code. It is pre-existing and out of scope for #4071, hence this separate report.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions