Skip to content

Fix coordinator timer disposal race - #14765

Draft
emaf wants to merge 1 commit into
dotnet:mainfrom
emaf:emaf-fix-coordinator-timer-disposal
Draft

Fix coordinator timer disposal race#14765
emaf wants to merge 1 commit into
dotnet:mainfrom
emaf:emaf-fix-coordinator-timer-disposal

Conversation

@emaf

@emaf emaf commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Context

The Windows net472 x86 Coordinator test process in #14725 crashed after all 168 tests passed because a queued heartbeat timer callback entered CheckHeartbeats after CoordinatorServer.Dispose() had disposed _clientsLock. The priority scheduling change did not modify timer or lock disposal; this race exists independently on main.

Fix

  • make server disposal idempotent and nonblocking
  • stop rearming timers once shutdown begins and reuse the shutdown timer instead of replacing it
  • track in-flight heartbeat and shutdown callbacks under a lifecycle gate
  • dispose _cts and _clientsLock only after RunAsync client cleanup and all entered timer callbacks complete
  • add deterministic coverage that pauses a heartbeat while it holds the clients read lock and races concurrent Dispose() calls

Validation

  • build.cmd -rebuild -configuration Release -projects src\MSBuild.Coordinator.UnitTests\MSBuild.Coordinator.UnitTests.csproj -v quiet
  • net11.0 Coordinator tests: 80 passed
  • net472 x86 Coordinator tests: 75 passed

Track in-flight server timer callbacks and defer synchronization-state disposal until RunAsync and callbacks have completed. Reuse the shutdown timer and add deterministic concurrent-disposal coverage.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

@DustinCampbell DustinCampbell left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, I'm not sure I understand all of the changes in this PR. Some of it looks solid, but other code looks a bit more desperate. There's a lot of code that "hardens" the CoodinatorServer against cases that don't happen in production and should probably be fixed in the test infrastructure.

There are some strange changes added without explanation, such as wrapping a single _output.WriteLine(...) call in try/finally? Is that because the TestCoordinatorDebugOutput was being called after the test had finished running? If that's the case, why doesn't the test wait until the server is disposed? I recognize that there's a problem with timers not being shutdown properly, but that should be handled in the same way that the CoordinatorClient's HeartbeatTimer does.

Comment on lines +82 to +91
lock (_lifecycleLock)
{
ObjectDisposedException.ThrowIf(_disposeState != NotDisposed, this);

_output.WriteLine($"CoordinatorServer: Accept loop started on pipe '{_pipeName}' (budget={_settings.TotalNodeBudget})");
if (_runStarted)
{
throw new InvalidOperationException("The coordinator server can only be run once.");
}
_runStarted = true;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks suspicious to me. In production, there's only a single caller of RunAsync(...) in Program.cs. Is this just for tests? If so, could we fix this in the test infrastructure rather than complicating the server code for cases that can't happen in production?

while (!token.IsCancellationRequested)
{
NamedPipeServerStream? pipeStream = await WaitForClientAsync(token);
NamedPipeServerStream? pipeStream = await WaitForClientAsync(token).ConfigureAwait(false);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was ConfigureAwait(false) added? Is there a reason we don't want to continue in the current context in the server?

{
try
{
await Task.WhenAll(remainingTasks).ConfigureAwait(false);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is ConfigureAwait(false) used here?

Comment on lines +130 to +133
try
{
_output.WriteLine("CoordinatorServer: Accept loop exiting");
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it necessary to protect a call to _output.WriteLine(...) in a try/finally?

Comment on lines +158 to +161
lock (_lifecycleLock)
{
_runCompleted = true;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, this is very suspicious to me. It really feels like changing production code rather than test infrastruture.

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.

2 participants