On Windows, a port another program holds exclusively (SO_EXCLUSIVEADDRUSE)
or one inside an excluded port range fails listen() with EACCES rather than
EADDRINUSE. The `codegraph ui` port fallback only moved on after EADDRINUSE,
so it stopped at the first such port. The error then blamed the POSIX
privileged-port rule ("Ports below 1024 usually need elevated privileges")
for ports like 4747 or 49912. Confirmed on Windows 11: a .NET listener on
0.0.0.0:P with ExclusiveAddressUse makes Node's listen(P, '127.0.0.1') fail
with EACCES.
Fix:
- shouldTryNextPort moves past EADDRINUSE on every platform. It moves past
EACCES at any port on Windows, which has no privileged ports, and from
1024 up elsewhere.
- Below 1024 off Windows, EACCES is the privileged-port rule, and the next
port falls under it too. The walk stops there with the message it always
gave, rather than trying 20 privileged ports.
- describeBindFailure takes the platform:
- Windows names the exclusive hold or reserved range and how to list the
ranges (netsh int ipv4 show excludedportrange protocol=tcp).
- POSIX keeps the privileged-port text below 1024. Above it, it says the
system refused permission.
- A walk that ran out says "in use or reserved" (Windows) or "in use or
not allowed" when any port was refused rather than in use.
- Both helpers take the platform as a parameter, like browserOpenCommand,
so the tests check every platform's answer on any host.
This builds on PR #2319 by @sx4im, which advanced on EACCES, used the
Windows wording and added the issue's listen-spy test. That PR advanced on
EACCES on every platform and printed "Windows refused port N ... netsh" on
Linux and macOS too. On POSIX it would also walk 20 privileged ports and
end with "Ports 80-99 are all in use or reserved". This change restricts
both to where they are true. The changelog entry goes in the viewer-launch
file, because the viewer is not released yet.
Verification:
- __tests__/ui-server.test.ts gains three kinds of test:
- The issue's end-to-end test: port N is taken and N+1 is refused through
a listen spy, so the server must land past N+1 and serve 200.
- A pinned-port message test.
- Unit tests of the decision and the messages for win32, linux and darwin.
- Results on __tests__/ui-server.test.ts:
- Main source: 9 failed / 40 passed. The walk fails with "Not allowed to
listen on port 57423. Ports below 1024 usually need elevated privileges".
- With the fix: 49 passed / 2 skipped (existing POSIX-gated tests).
- Built CLI on Windows, with 0.0.0.0:<port> held exclusively:
- `codegraph ui` while 4747 is held: main exits 1 with the privileged-port
message. With the fix it serves on 4748 (GET / 200).
- `codegraph ui --port <held>`: prints the Windows message.
- A 3-port walk over held ports reports "in use or reserved".
- Re-listening on the same server after a real EACCES works on Node 22.22
and 24.16.
- tsc --noEmit is clean and npm run build succeeds. The 20 viewer test
files pass (439 passed, 5 skipped).
- Full suite: 5732 passed, 10 failed, 319 skipped. The 10 failures are
timeouts and EBUSY teardown under load in function-ref,
git-index-currency, index-daemon-rebuild and rust-self-owner. All four
files pass when run alone.
Issue #2299 reported by @ijbranch.
Co-authored-by: Saim Shafique <msaimshafique4664@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Save trail on the trail bar writes the walk to .codegraph/ui/trails/ as one
JSON file, listed on the empty screen and on Entry points above the derived
suggestions, reopened at the symbol you left with the whole path restored.
A hop is stored by qualified name, kind and file — never by node id, which
contains a start line and so changes the first time anybody edits above the
symbol. Every hop is re-resolved against the current index on the way out and
each row says what became of it: still here, moved to another file, now
ambiguous, or gone. A hole is never stitched over: the row opens the longest
run of CONSECUTIVE resolved hops and says which ones those are, because the
trail is a path and a skipped hop would draw a call that does not exist.
This is the first write the viewer makes, and the boundary moved with it:
POST/DELETE answer under /api/ only, must carry X-CodeGraph-UI and
application/json (neither of which a cross-origin form can produce without a
preflight this server answers none of), and --read-only refuses both while
still listing what is there. The blanket "read-only" claim is retired from the
banner, the README, the CLI help and the docs site in favour of the narrower
true one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CreateProcess — which node's spawn uses without a shell — only launches a real
.exe, so a `.cmd`/`.bat` browser shim (how most Windows wrappers are written)
silently launched nothing. Routing the override through `cmd /c`, the way the
default `start` opener already goes, makes .exe, .cmd and .bat all work and
keeps node's per-argument quoting so a path with spaces survives.
Caught on the Windows VM.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the `codegraph ui [path]` command (alias `web`) and `src/ui-server/`, a
`node:http` server with no framework and no new dependency.
The command reads an index that already exists — it never creates one, so a
missing index prints the same friendly guidance the MCP tools give instead of
a stack trace, and a sensitive system directory is refused up front.
Security is the substance here, not the routing. The server binds 127.0.0.1
only, answers GET and HEAD only, and sends no CORS headers ever. The realistic
attack on a process that serves your source code from a local port is DNS
rebinding, so every request must carry a loopback `Host` (on our port) and, if
it carries an `Origin` at all, a loopback one — anything else is 403 before
the filesystem is touched. Every path resolves through the engine's existing
`validatePathWithinRoot` chokepoint, which already handles `../` traversal and
in-tree symlinks pointing out of the root (#527); `..` segments are refused
outright so a traversal attempt gets a 404 rather than the SPA shell.
`PathRefusalError` moves from `mcp/tools.ts` into the dependency-free
`errors.ts` (re-exported from its old home, so class identity and every
`instanceof` check are unchanged) — that is what lets a non-MCP read sink
enforce the same refusal without importing the MCP tool graph.
Assets come from `dist/viewer/` resolved relative to `__dirname`, the way
`db/index.ts` finds `schema.sql`. Hashed assets are cached immutably,
`index.html` never. Port 4747, or the next free one — an explicit `--port`
stays explicit rather than silently moving. `--no-open` skips the browser, and
`CODEGRAPH_BROWSER` picks one (or `none` to suppress it), which is also what
makes "did it open a browser" testable end to end.
`resolveProjectFile` and the `/api/` handler seam are the boundary CG-42's
JSON API plugs into; `/api/*` 404s as JSON so a typo'd endpoint never returns
the app shell.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>