fix: don't crash with EADDRINUSE when the OAuth callback port is already in use - #312
Open
TimChild wants to merge 1 commit into
Open
fix: don't crash with EADDRINUSE when the OAuth callback port is already in use#312TimChild wants to merge 1 commit into
TimChild wants to merge 1 commit into
Conversation
The callback server was created with app.listen() and no 'error' listener, so a failed bind surfaced as an unhandled 'error' event and killed the process. EADDRINUSE is an expected outcome here rather than an edge case: the callback port is derived deterministically from the server URL hash and then pinned into client_info.json's redirect_uris, so every concurrent instance for the same server computes the same port. The lockfile that coordinates instances treats locks older than MAX_LOCK_AGE (30 minutes) as stale, while real sessions run for much longer, so a later launch deletes the lock and tries to bind the port that the still-running instance holds. Attach an 'error' listener that retries once on an OS-assigned port, mirroring what findAvailablePort() already does, and move the startup log to the 'listening' event so it reports the port actually bound.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
setupOAuthCallbackServerWithLongPollcreates the callback server with noerrorlistener:A failed bind is therefore an unhandled
'error'event, and the whole process dies:Why EADDRINUSE is expected here, not an edge case
The callback port isn't arbitrary, so instances don't get lucky by landing on different ports:
calculateDefaultPort()derives it deterministically from the server URL hash (3335 + parseInt(hash.slice(0, 4), 16) % 45816), so every instance for a given server computes the same port.findExistingClientPort()then reads the port pinned inclient_info.json'sredirect_urisand uses it unconditionally — thefindAvailablePort()result computed alongside it is discarded on that path. So a first run is safe and every subsequent run reuses the fixed port.isLockValid()treats a lock older thanMAX_LOCK_AGE(30 min) as stale, while real sessions live for hours or days. Once the lock ages out, a later launch deletes it and binds the port itself — which a still-running instance is holding.Symptom: when the remote server is unauthenticated (or a token refresh fails), the second and later concurrent sessions crash instead of authenticating. Because the throw happens before the code exchange, no token is ever written, so a host that restarts its stdio child relaunches straight into the same crash.
The fix
Attach an
errorlistener that retries once on an OS-assigned port — the same EADDRINUSE→listen(0)patternfindAvailablePort()already uses a few lines below. The startup log moves to the'listening'event so it reports the port actually bound rather than the requested one. Non-EADDRINUSE errors, and a failure of the retry itself, are logged instead of thrown.coordinateAuthalready reads the bound port back off the server (server.address()) and writes that into the lockfile, so the fallback port propagates to the coordination path with no changes there. Verified: with the port squatted, the lockfile records the fallback port, not the requested one.Deliberately kept to the crash: no signature changes, no refactor. One behavioural note for reviewers — if the port changes, dynamic client registration re-registers with the new
redirect_uri, but aredirect_uripinned via--static-oauth-client-infowill no longer match, so auth fails with a clear server-side error rather than an unhandled crash. Happy to add a strict/opt-out path (fail with an actionable message instead of rebinding when the port was pinned or passed explicitly) if you'd prefer that.Verification
pnpm check(prettier + tsc) — clean.pnpm test:unit— 104 passed, including a regression test that binds the port first and asserts the callback server ends up on a different port. It fails onmainwith the rawEADDRINUSEand passes with the fix.pnpm build— success.cd test && pnpm test(e2e) — 2 passed, 1 failed:connects to Hugging Face MCP serverexpects amodel_searchtool that server no longer lists. Pre-existing — it fails identically on a cleanmaincheckout, unrelated to this change.Relationship to #262
#262 (fixing #253) targets the same crash and goes further: it threads the actual port back through the coordinator, adds
setCallbackPort, and adds astrictPortmode. If you'd rather take that one, this can be closed — no objection, and I'd rather the fix land in any form. Two things led me to open this anyway: it's ~25 lines confined to one function with no signature or call-site changes, and #262 currently failspnpm checkon prettier formatting insrc/lib/utils.ts, so CI is red as it stands.Found while implementing native MCP OAuth in a client, so I'm not blocked on this either way — filing it because the crash is easy to hit and the fix pattern already exists in the file.