Skip to content

Fix discarded fire-and-forget Publish calls that ran inline and blocked their caller - #487

Merged
clrudolphi merged 2 commits into
masterfrom
fix/477-fire-and-forget-task-discard
Aug 26, 2026
Merged

Fix discarded fire-and-forget Publish calls that ran inline and blocked their caller#487
clrudolphi merged 2 commits into
masterfrom
fix/477-fire-and-forget-task-discard

Conversation

@clrudolphi

Copy link
Copy Markdown
Collaborator

Summary

  • Discarding a Task reference (_ = _mediator.Publish(...)) is not fire-and-forget: an async method's body runs synchronously up to its first genuine await, so a caller that never observes the returned Task can still be blocked for that entire synchronous prefix, and any exception the task throws goes unobserved.
  • Confirmed real impact (traced during the LSP server: workspace/inlayHint/refresh and workspace/semanticTokens/refresh scale badly on large solutions, blocking unrelated requests for tens of seconds #471 investigation): a single .cs edit could make textDocument/didChange itself take several-to-tens of seconds to "complete" from the protocol's perspective — the entire reparse-every-open-feature-file cascade ran inline on the calling thread before the handler returned — blocking every other request queued behind it.
  • Adds FireAndForgetExtensions (Reqnroll.IdeSupport.Common) with a Func<Task> overload that genuinely backgrounds work via Task.Run and a Task overload for work that's already independently running, both logging any fault instead of leaving it unobserved (an IIdeSupportLogger overload and a Microsoft.Extensions.Logging ILogger overload, for the two logging abstractions in use across the codebase).
  • Replaces the four _ = _mediator.Publish(...) sites (BindingRegistryProviderRouter, MembershipIndex x2, LspWorkspaceScopeManager) and the LspServerConnectionService shutdown Task.Run, which lacked an outer catch around one non-critical-path await.
  • Leaves the three genuinely-safe Task.Run sites that already carry their own full try/catch (ConnectorBindingRegistryProvider.RunDiscoveryAsync, CommentToggleCommandFilter) as-is — they never actually leave a fault unobserved, so wrapping them would add nothing.
  • Also fixes a real bug surfaced by the change: the deferred Publish calls must use CancellationToken.None rather than the notification's own request-scoped token, since a background continuation that runs after the request completes would otherwise find that token already cancelled and silently drop the notification. Caught by the existing ProjectFilesDelta.feature spec, which failed until this was corrected.

Fixes #477

Test plan

  • Added FireAndForgetExtensionsTests covering: the Func<Task> overload genuinely runs off the calling thread, a faulting Task/Func<Task> logs its exception instead of losing it, and a successful task logs nothing.
  • Updated MembershipIndexTests / LspWorkspaceScopeManagerMembershipTests to poll for the now-genuinely-asynchronous Publish call instead of asserting immediately.
  • dotnet test across Reqnroll.IdeSupport.Common.Tests, Reqnroll.IdeSupport.LSP.Server.Tests, and Reqnroll.IdeSupport.LSP.Server.Specs — all green, ProjectFilesDelta.feature's bound→unbound scenario in particular confirmed passing (it caught the CancellationToken bug above during development).
  • Full solution build + full solution dotnet test — all green.

🤖 Generated with Claude Code

clrudolphi and others added 2 commits August 25, 2026 16:44
…blocked their caller (#477)

Discarding a Task reference (_ = mediator.Publish(...)) does not defer execution: an async
method's body runs synchronously up to its first genuine await, so the entire reparse-every
-open-feature-file cascade ran inline on the calling didChange/didChangeWatchedFiles handler's
thread, blocking every other request queued behind it (confirmed during the #471 investigation).

Adds FireAndForgetExtensions (Reqnroll.IdeSupport.Common) with a Func<Task> overload that
genuinely backgrounds work via Task.Run and a Task overload that just observes an
already-independent task, both logging any fault instead of leaving it unobserved. Replaces the
four `_ = _mediator.Publish(...)` sites (BindingRegistryProviderRouter, MembershipIndex x2,
LspWorkspaceScopeManager) and the LspServerConnectionService shutdown Task.Run, which lacked an
outer catch around one non-critical-path await.

The three genuinely-fire-and-forget-safe Task.Run sites already carrying their own full
try/catch (ConnectorBindingRegistryProvider.RunDiscoveryAsync, CommentToggleCommandFilter) are
left as-is -- they never actually leave a fault unobserved.

Also fixes a real bug the change surfaced: the deferred Publish calls must use
CancellationToken.None rather than the notification's own request-scoped token, since a
background continuation that runs after the request completes would otherwise find that token
already cancelled and silently drop the notification (caught by the existing
ProjectFilesDelta.feature spec).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…currencyProbeTests

Fresh-eyes finding: FireAndForgetExtensions logged only Flatten().InnerException (the first of
possibly several aggregated exceptions) on a fault, silently dropping any siblings. Log the
flattened AggregateException itself instead, so nothing is lost.

CI fix: ConcurrencyProbeTests.Cheap_request_latency_under_concurrent_codeLens_load (a #471
regression gate asserting the *bad* dispatch-pipeline-stall behavior is still present) started
failing on this branch once #477's fix landed. Investigation:
- #477's fix is a plausible direct cause: it makes BindingRegistryProviderRouter's Publish call
  genuinely background instead of running its reparse cascade inline on the [Serial]-tagged
  didChange/didOpen handler that #471 describes as the stall mechanism.
- But the improvement is partial, not complete, and the residual magnitude proved highly
  sensitive to what else was running on the machine: isolated runs measured a consistent
  ~15x-20x (down from the documented 45x-56x); running the full ~831-test assembly measured
  ~40x+ in two separate runs, and CI's one failing run measured ~1.3x. Naively flipping the
  assertion to require good (<5x) behavior would have just moved the flakiness to a different
  threshold, not removed it.
- Root cause of the swing: xUnit's default cross-class parallelization ran this wall-clock
  latency benchmark concurrently with ~830 unrelated tests, contaminating the measurement with
  CPU contention that has nothing to do with the LSP dispatch pipeline under test. Disabling
  parallelization for the assembly (AssemblyInfo.cs) removes that noise source; the benchmark
  now lands consistently in the ~15x-20x range across repeated full-assembly runs, matching the
  isolated numbers.
- Converted the assertion from a "must reproduce the fully-broken behavior" floor to a
  regression ceiling (<30x, comfortably above the now-stable ~15x-20x, comfortably below the
  original 45x-56x) so it still catches a real regression without asserting a specific ratio
  that isn't yet reliably near 1x. Full elimination of the residual stall is left as a #471
  follow-up, not claimed here.

Verified: dotnet test on the full LSP.Server.Tests assembly (Release, matching CI) passed 831/831
across three consecutive runs; full solution build + test (Release) all green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@clrudolphi
clrudolphi merged commit 406ada2 into master Aug 26, 2026
17 checks passed
@clrudolphi
clrudolphi deleted the fix/477-fire-and-forget-task-discard branch August 26, 2026 14:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Discarded Task from _mediator.Publish(...) doesn't defer work — several fire-and-forget call sites actually run inline and block their caller

1 participant