Skip to content

fix: reuse the local API endpoint so restarts keep the same port - #31

Open
serrade wants to merge 1 commit into
standardagents:mainfrom
serrade:fix/local-api-port-reuse
Open

fix: reuse the local API endpoint so restarts keep the same port#31
serrade wants to merge 1 commit into
standardagents:mainfrom
serrade:fix/local-api-port-reuse

Conversation

@serrade

@serrade serrade commented Jul 30, 2026

Copy link
Copy Markdown

Problem

Restarting the app usually moved the local API to a different port, which breaks anything configured against it: editors, gateways, scripts.

The listener is created without endpoint reuse:

let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort)
let listener = try NWListener(using: parameters)

Sockets left over from the previous run keep the endpoint claimed for a while after the process exits, so the rebind fails. start(preferredPort:fallbackLimit:) then walks to the next port, and AppModel persists the fallback as the new preference:

let activePort = try server.start(preferredPort: requestedPort, fallbackLimit: Self.portFallbackLimit)
settings.port = activePort

So the drift is sticky: each restart that hits a lingering socket moves the port permanently. Locally this walked 8789 to 8793 to 8794 over a few restarts.

Fix

Set allowLocalEndpointReuse on the listener parameters. The port is reclaimed immediately after a restart. A port genuinely held by another live listener still fails to bind, so the existing fallback continues to protect startup.

Verification

  • swift build clean; Package macOS smoke green on my fork.
  • Manually: restarted the app repeatedly, including after sending traffic so there were sockets to linger, and the API stayed on its configured port across every restart. Before the change the same sequence moved the port and rewrote the saved preference.

Note on overlap

This is the public API listener in LocalAPIServer, which is a different port from the SDK bridge port that #27 works on (8792-8892, CursorSDKBridgeServer). The two changes do not touch the same file. #29 edits start(port:) nearby for LAN mode; whichever of us lands second has a trivial rebase.

NWListener was created without endpoint reuse, so sockets lingering from
a previous run made the rebind fail. start(preferredPort:fallbackLimit:)
then walked to the next port, and AppModel persisted that fallback as the
new preference. Every restart therefore moved the local API, breaking
gateways, editors and scripts configured against the old port.

Setting allowLocalEndpointReuse lets the listener reclaim its configured
port immediately. A genuinely occupied port still fails to bind, so the
existing fallback keeps protecting startup.
@serrade

serrade commented Jul 30, 2026

Copy link
Copy Markdown
Author

Follow-up on my own PR, because the description here promises more than the change delivers.

Endpoint reuse fixes the cross-process case, and I can confirm that part: a graceful quit and relaunch now holds the port. What it does not cover is the app competing with itself, and that turns out to be the more common path.

NWListener.cancel() only requests teardown. stop() returns as soon as it has called it, so the rebind inside start(port:) can still find its own dying listener on the port, fall through to the next candidate, and persist that as the new port. AppModel.restartServer() does exactly this, and it runs whenever settings are saved while the server is up — so the symptom this PR is meant to remove survives in the case users hit most often.

Measured by calling LocalAPIServer.start(preferredPort:fallbackLimit:) against a probe port thirty times in a row, twelve rebinds drifted by one. Waiting for the listener to report .cancelled before returning from stop() takes that to ninety of ninety across three runs. Six iterations happened to alternate pass/fail, which reads as something deterministic, so the larger sample seemed worth taking before claiming a mechanism.

I have that written as a small follow-up: a semaphore signalled from the .cancelled and .failed branches of the state handler, waited on in stop() with a two-second bound, plus a test that rebinds the same port six times and asserts it never moves. Happy to push it onto this branch, open it as a separate PR after this lands, or leave it entirely — your call. I did not want to add commits to a PR under review without asking.

Worth noting either way: whichever way this goes, the one-line change here is still correct and still needed. The wait alone would not help if the previous process had left the socket behind.

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.

1 participant