Skip to content

fix: pre-flight port check and graceful shutdown to prevent EADDRINUSE#1091

Open
btbowman wants to merge 2 commits intomodelcontextprotocol:mainfrom
btbowman:fix/port-race-condition-and-graceful-shutdown
Open

fix: pre-flight port check and graceful shutdown to prevent EADDRINUSE#1091
btbowman wants to merge 2 commits intomodelcontextprotocol:mainfrom
btbowman:fix/port-race-condition-and-graceful-shutdown

Conversation

@btbowman
Copy link
Copy Markdown

Summary

Fixes a race condition where the MCP Inspector reports "PORT IS IN USE" even when ports are confirmed free, and leaves ports in CLOSE_WAIT state after Ctrl+C — causing the error to persist across restarts.

Three bugs identified and fixed:

  1. No pre-flight port availability check — Both the client (client/bin/client.js) and proxy server (server/src/index.ts) attempted to bind directly via server.listen() without first verifying the port was free. When a stale process held the port, users received a confusing EADDRINUSE error with no actionable guidance.

  2. Missing process.exit(1) in client EADDRINUSE handler — The client's error handler logged the error but never called process.exit(1), leaving the process hanging indefinitely. Users had to manually kill the process.

  3. No SIGINT/SIGTERM graceful shutdown — Neither the client nor server registered signal handlers, so Ctrl+C left TCP sockets in CLOSE_WAIT state. The OS wouldn't release the port, causing the next launch to fail with EADDRINUSE — even though lsof showed no process on the port.

Changes

client/bin/client.js

  • Added import net from "net"
  • Added checkPort() function that attempts a test bind before the real server.listen()
  • Moved port and host declarations above the check so they're available for the pre-flight test
  • Added process.exit(1) to the existing EADDRINUSE error handler
  • Added SIGINT/SIGTERM handlers with server.close() for graceful shutdown (3s force-exit timeout)
  • Added actionable lsof/kill instructions in error messages

server/src/index.ts

  • Added import net from "net" with other imports at the top of the file
  • Added typed checkPort() function (Promise<boolean>) before app.listen()
  • Added SIGINT/SIGTERM handlers with server.close() for graceful shutdown (3s force-exit timeout)

How to test

  1. Start the Inspector: npx @modelcontextprotocol/inspector
  2. Ctrl+C to stop — port should be released immediately
  3. Restart immediately — should start cleanly without EADDRINUSE
  4. Start Inspector, then in another terminal start a second instance on the same port — should get a clear error with lsof/kill fix instructions and exit cleanly

Test plan

  • Verify Inspector starts normally on default ports (6274/6277)
  • Verify Ctrl+C releases ports immediately (no CLOSE_WAIT)
  • Verify immediate restart after Ctrl+C works without EADDRINUSE
  • Verify clear error + exit when port is genuinely in use
  • Verify CLIENT_PORT / SERVER_PORT env var overrides still work
  • Verify Windows compatibility (no platform-specific APIs used)

Resolves #1090

🤖 Generated with Claude Code

…INUSE race condition

Three bugs caused the Inspector to report "PORT IS IN USE" even when ports
were free, and to leave ports in CLOSE_WAIT after Ctrl+C:

1. No pre-flight port availability check — both the client (client.js) and
   server (index.ts) attempted to bind directly without first verifying the
   port was free, leading to confusing EADDRINUSE errors from stale processes.

2. Missing process.exit(1) in client EADDRINUSE handler — the client's
   error handler logged the error but never exited, leaving the process
   hanging indefinitely.

3. No SIGINT/SIGTERM graceful shutdown — neither process registered signal
   handlers, so Ctrl+C left TCP sockets in CLOSE_WAIT state. The next launch
   would then fail with EADDRINUSE because the OS hadn't released the port.

Fixes:
- Add checkPort() pre-flight check to both client/bin/client.js and
  server/src/index.ts that tests port availability before attempting to bind
- Add process.exit(1) to the client's EADDRINUSE error handler
- Add SIGINT/SIGTERM handlers with server.close() for graceful shutdown
  in both client and server, with a 3-second force-exit timeout
- Provide actionable error messages with lsof/kill instructions

Resolves modelcontextprotocol#1090

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Author

@btbowman btbowman left a comment

Choose a reason for hiding this comment

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

Changes

Copilot AI review requested due to automatic review settings April 3, 2026 19:35
Copy link
Copy Markdown
Author

@btbowman btbowman left a comment

Choose a reason for hiding this comment

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

push

Copy link
Copy Markdown
Author

@btbowman btbowman left a comment

Choose a reason for hiding this comment

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

.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses port-binding flakiness in the MCP Inspector by adding a pre-flight port availability check and introducing SIGINT/SIGTERM shutdown handling so ports are released promptly and EADDRINUSE failures are clearer and non-hanging.

Changes:

  • Added a net-based checkPort() pre-flight check before listen() in both the client and proxy server.
  • Added SIGINT/SIGTERM handlers to close servers on shutdown (with a force-exit timeout).
  • Improved EADDRINUSE handling in the client by ensuring the process exits after logging.

Reviewed changes

Copilot reviewed 1 out of 2 changed files in this pull request and generated 4 comments.

File Description
server/src/index.ts Adds port pre-check before app.listen() and installs graceful shutdown handlers.
client/bin/client.js Adds port pre-check before server.listen(), ensures exit on bind error, and installs graceful shutdown handlers.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +828 to +838
function checkPort(targetHost: string, targetPort: number): Promise<boolean> {
return new Promise((resolve) => {
const tester = net.createServer();
tester.once("error", () => {
resolve(false);
});
tester.once("listening", () => {
tester.close(() => resolve(true));
});
tester.listen(targetPort, targetHost);
});
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

checkPort() currently resolves false for any listen error, which can misreport other failure modes (e.g., EACCES for privileged ports, invalid host) as "PORT IS IN USE". Capture the error and only treat EADDRINUSE as "in use"; for other err.code values either rethrow or surface a more accurate message. Also consider calling tester.close() in the error path to avoid leaving a handle around in edge cases.

Copilot uses AI. Check for mistakes.
Comment on lines 864 to 870
server.on("error", (err) => {
if (err.message.includes(`EADDRINUSE`)) {
console.error(`❌ Proxy Server PORT IS IN USE at port ${PORT} ❌ `);
} else {
console.error(err.message);
}
process.exit(1);
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

The EADDRINUSE detection is using err.message.includes('EADDRINUSE'). Node provides err.code === 'EADDRINUSE', which is more reliable than parsing the message string (which can vary). Consider switching to err.code checks here (and optionally include the original error for non-EADDRINUSE cases).

Copilot uses AI. Check for mistakes.
Comment on lines +875 to +883
function shutdown() {
server.close(() => {
process.exit(0);
});
setTimeout(() => process.exit(0), 3000);
}

process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

shutdown() calls server.close() unconditionally. In Node, calling close() when the server isn't listening (or calling it twice if multiple signals fire) can throw ERR_SERVER_NOT_RUNNING. Add a guard (e.g., let shuttingDown = false + if (!server.listening) process.exit(0)) and consider clearing the force-exit timeout when close completes.

Copilot uses AI. Check for mistakes.
Comment on lines +841 to +846
const portFree = await checkPort(HOST, PORT);
if (!portFree) {
console.error(`❌ Proxy Server PORT IS IN USE at port ${PORT} ❌ `);
console.error(
`💡 To fix: run "lsof -ti:${PORT} | xargs kill -9" to free the port, or set SERVER_PORT to use a different port.`,
);
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

The remediation text suggests running lsof ... | xargs kill -9, which is Unix-specific and conflicts with the stated goal of Windows compatibility. Consider making the message platform-aware (process.platform) or providing a more platform-neutral suggestion (e.g., "close the process using the port" + link/docs), with Windows equivalents where applicable.

Suggested change
const portFree = await checkPort(HOST, PORT);
if (!portFree) {
console.error(`❌ Proxy Server PORT IS IN USE at port ${PORT} ❌ `);
console.error(
`💡 To fix: run "lsof -ti:${PORT} | xargs kill -9" to free the port, or set SERVER_PORT to use a different port.`,
);
function getPortInUseRemediation(targetPort: number): string {
if (process.platform === "win32") {
return `💡 To fix: close the process using port ${targetPort} (for example, run "netstat -ano | findstr :${targetPort}" to find the PID, then "taskkill /PID <PID> /F"), or set SERVER_PORT to use a different port.`;
}
return `💡 To fix: close the process using port ${targetPort} (for example, run "lsof -ti:${targetPort} | xargs kill -9"), or set SERVER_PORT to use a different port.`;
}
const portFree = await checkPort(HOST, PORT);
if (!portFree) {
console.error(`❌ Proxy Server PORT IS IN USE at port ${PORT} ❌ `);
console.error(getPortInUseRemediation(PORT));

Copilot uses AI. Check for mistakes.
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.

Race condition: 'PORT IS IN USE' error caused by Inspector's own process

2 participants