Skip to content

fix(server): prevent devenv.exe from hanging on shutdown - #99

Merged
CalvinAllen merged 2 commits into
mainfrom
fix/server/shutdown-deadlock
Aug 5, 2026
Merged

fix(server): prevent devenv.exe from hanging on shutdown#99
CalvinAllen merged 2 commits into
mainfrom
fix/server/shutdown-deadlock

Conversation

@CalvinAllen

@CalvinAllen CalvinAllen commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Resolves #97

The problem

MCPServerPackage.Dispose runs on the Visual Studio UI thread and blocked it on ServerManager.StopAsync(). That method had no ConfigureAwait(false) anywhere, so every await inside captured the UI thread's synchronization context and posted its continuation back to the thread already blocked waiting for it. Classic deadlock: the main window closes, the UI thread never returns from Dispose, and devenv.exe stays in Task Manager.

The reporter noted this only happens when the server is running, which is the confirming detail. With no child process and no listening pipe, RequestShutdownAsync and both StopAsync methods return already-completed tasks, awaiting those continues inline, and nothing is ever posted back.

The fix

  • ConfigureAwait(false) throughout the shutdown path in ServerProcessManager and RpcServer, so no continuation ever needs the caller's thread back.
  • Package disposal runs shutdown on Task.Run and waits with a timeout. Task.Run starts with no synchronization context, so nothing can post back to the UI thread; the timeout bounds the damage if anything ever does.
  • Bounded cooperative shutdown. A half-open named pipe could leave the RPC shutdown call pending indefinitely — the old 5s + 2s waits had no ceiling in that case. Now: 1s for the RPC ack, 1.5s for a clean exit, then kill with a 0.5s wait.
  • Kill-on-close job object. The server process is assigned to a Windows job object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so it dies with Visual Studio even if devenv.exe crashes or is killed from Task Manager. This fixes the mirror-image problem nobody had filed yet: an orphaned server holding port 5050 against the next VS session.
  • VSTHRD002 is no longer suppressed project-wide. That analyzer exists to catch exactly this deadlock and was globally disabled. It is now suppressed only at the two Dispose call sites that genuinely need a blocking wait, each with a written justification.
  • Two latent bugs in the pipe listener. Cancelling during the retry backoff threw out of a catch block and faulted the listener task; the cancellation source could also be disposed while the listener was still observing its token.

Tests

This adds the repo's first test project (CodingWithCalvin.MCPServer.Tests, xunit on net48) plus a Test workflow, since the existing build workflow only compiles the VSIX project and would not have run them.

  • ServerShutdownTests drives the shutdown path from a thread whose synchronization context silently drops posted callbacks — the observable behaviour of a UI thread blocked in Dispose. Both tests were verified to fail against the previous code and pass against this change.
  • ProcessJobObjectTests covers the new P/Invoke wrapper, including that closing the job actually terminates an assigned process.

Package disposal blocked the Visual Studio UI thread on ServerProcessManager
.StopAsync(), whose awaits captured that thread's synchronization context and
posted their continuations straight back to it. The shutdown never completed,
so devenv.exe stayed resident after the main window closed.

The deadlock only reproduced with the server running: with no child process
and no listening pipe, every await in the path completed synchronously and
nothing was ever posted back.

- Use ConfigureAwait(false) throughout the shutdown path in
  ServerProcessManager and RpcServer.
- Run shutdown via Task.Run and wait with a timeout in package disposal, so
  no continuation can need the UI thread and VS exits regardless.
- Bound the cooperative shutdown. A half-open pipe could leave the RPC
  shutdown call pending forever; the previous 5s + 2s waits were also
  unbounded in the worst case.
- Assign the server process to a kill-on-close job object so it dies with
  Visual Studio even when devenv.exe terminates abnormally, rather than
  surviving to hold its HTTP port against the next session.
- Stop suppressing VSTHRD002 project-wide. That analyzer flagged this exact
  deadlock; it is now suppressed only at the two Dispose call sites that
  genuinely require a blocking wait, each with a justification.
- Fix the pipe listener faulting its task when cancelled during the retry
  backoff, and avoid disposing the cancellation source out from under it.

Adds a test project with regression coverage that drives the shutdown path
from a thread whose synchronization context cannot run posted callbacks. Both
new tests fail against the previous code and pass against this change.
The test project references the extension project, so a standalone test
workflow rebuilt the extension and its self-contained server publish a second
time on every PR. Passing test-project to the shared vsix-build workflow runs
the tests in the same job, reusing the build output.

Requires CodingWithCalvin/.github#76.
@CalvinAllen

Copy link
Copy Markdown
Contributor Author

🔧 CI consolidation

Dropped the standalone test.yml I initially added — it rebuilt the extension and its self-contained server publish a second time on every PR, since the test project references the extension project.

Tests now run from build.yml via a new optional test-project input on the shared workflow: CodingWithCalvin/.github#76.

⚠️ Merge order: that PR must merge first, otherwise build.yml here passes an input the reusable workflow does not yet accept and the run fails.

@CalvinAllen
CalvinAllen merged commit 2bf0a5c into main Aug 5, 2026
2 checks passed
@CalvinAllen
CalvinAllen deleted the fix/server/shutdown-deadlock branch August 5, 2026 21:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

MCP server preventing devenv.exe from exiting

1 participant