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-79 — Task.Factory.StartNew(async () => { ... await ProcessJobs(...); ... }) is not unwrapped.
src/Paramore.Brighter.Mediator/Waker.cs:66-77 — Task.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.
Summary
Paramore.Brighter.Mediator.Runner.RunAsyncandWaker.RunAsyncuseTask.Factory.StartNew(async () => ...)without calling.Unwrap().StartNewreturns aTask<Task>whose outer task completes the moment the lambda hits its firstawait— not when the inner work finishes. The subsequentTask.WaitAll([task], cancellationToken)therefore waits on the outer task, which signals completion almost immediately, andRunAsyncreturns (logging "Finished") whileProcessJobs/Wakeis still running on the threadpool.Affected sites
src/Paramore.Brighter.Mediator/Runner.cs:71-79—Task.Factory.StartNew(async () => { ... await ProcessJobs(...); ... })is not unwrapped.src/Paramore.Brighter.Mediator/Waker.cs:66-77—Task.Factory.StartNew(async () => { ... await Wake(...); ... })is not unwrapped.Symptom
The "Finished" log fires the moment the lambda first awaits, even though the loop continues running in the background. Hosts that rely on
RunAsyncreturning 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()soWaitAllwaits on the inner work:Same change applies to
Waker.RunAsync.Related quality issues in the same files (smaller scope, fixable alongside)
Runner.cs:67andWaker.cs:62:RunAsyncreturnsvoiddespite theAsyncsuffix and the fact that it blocks viaTask.WaitAll. Either rename toRunor change the signature to return aTaskand let callers await.Runner.cs:77: redundantcancellationToken.ThrowIfCancellationRequested()afterawait ProcessJobs(cancellationToken)—awaitalready propagates cancellation.Waker.cs:72-73:if (cancellationToken.IsCancellationRequested) cancellationToken.ThrowIfCancellationRequested();— theifis pointless becauseThrowIfCancellationRequestedalready performs that check.Context
Surfaced during code review of the fix for #4071 (the
TaskScheduler.Currentdeadlock). Those two files were touched to apply the sameTaskScheduler.Defaultpin, 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.