Skip to content

fix(sdk-runner): refuse to start a managed server on a port already in use - #585

Open
panyam wants to merge 2 commits into
modelcontextprotocol:mainfrom
panyam:fix/sdk-runner-port-preflight
Open

panyam wants to merge 2 commits into
modelcontextprotocol:mainfrom
panyam:fix/sdk-runner-port-preflight

Conversation

@panyam

@panyam panyam commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

I hit this in my own conformance runs. A leftover server from an earlier run still held the fixture's port. The new server failed to bind, and the suite came back green against the old one.

sdk --mode server can do the same thing. withManagedServer spawns the server, then polls the url until anything answers. A leftover process answers first.

  • Usually the new server dies on EADDRINUSE, and the run ends partway through with Server exited with code 1 before tests completed. The error doesn't mention the port.
  • If the command doesn't exit on a failed bind, the run grades the leftover to the end.

The fix probes the url once before spawning. It counts the port as free only when the connect is refused. If something answers, or accepts the connection and then stalls, the runner refuses with an error. The error names the url and says why the port looks held.

Tests in src/sdk-runner/managed-server.test.ts:

  • held port: refuses, and the graded function never runs. On main this test fails with promise resolved "undefined" instead of rejecting, so the run went ahead against the leftover.
  • stalled leftover: a process accepts the socket and never answers. It gets the same refusal. Before the second commit, this run ended with Server exited with code 1 after about 1s.
  • free port: starts the server and runs as before.

Related to #114, which wants the runner to own server lifecycle for all SDKs.

…ady answers

withManagedServer spawned the SDK's server and then polled its url until
something answered. A leftover process on that port answered first, so
the run graded the leftover. The new server usually died on bind and
ended the run partway through with "Server exited with code 1". A
command that does not exit on a failed bind graded the stranger to the end.

Probe the url once before spawning, and refuse with a message that names
the url when anything answers.
@pkg-pr-new

pkg-pr-new Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npx https://pkg.pr.new/@modelcontextprotocol/conformance@585

commit: 88c9ea8

@JosephDoUrden JosephDoUrden left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ran npx vitest run src/sdk-runner/managed-server.test.ts: 2/2 pass. With index.ts from main, the held-port test fails as described. I also put a leftover on ::1 and on 127.0.0.1 against localhost urls, and all 6 combinations were refused.

One gap: answers() (index.ts:44) treats a timeout as a free port. A leftover that accepts the connection but stalls past 1s gets through, and the run ends with the old Server exited with code 1 after about 1s. Treating only ECONNREFUSED as free would cover it.

Looks good to me otherwise.

A leftover that accepts the connection and then stalls made the probe
time out. The old check read a timeout as a free port, so the run went
ahead and later failed with "Server exited with code 1".

The probe now counts the port as free only on ECONNREFUSED and the
other nobody-listening codes. A localhost url reports one error for
::1 and one for 127.0.0.1, and both must say nobody is listening. The
error message now says why the port looks held.
@panyam panyam changed the title fix(sdk-runner): refuse to start a managed server on a port that already answers fix(sdk-runner): refuse to start a managed server on a port already in use Nov 13, 2026
@panyam

panyam commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, good catch. I reproduced it with a leftover that accepts the socket and never answers. The run ended with Server exited with code 1 after about 1s, just like you said.

Fixed in 88c9ea8. The probe now counts the port as free only on ECONNREFUSED (plus EADDRNOTAVAIL, ENETUNREACH and EHOSTUNREACH). A localhost url reports one error for ::1 and one for 127.0.0.1, and both must say nobody is listening. A timeout or a reset counts as held, and the error now says which one it was.

I added a test for the stalled leftover. It fails on the first commit with the old Server exited with code 1, and passes now.

This branch has not been deployed

No deployments
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