Answer "is this port bound" without a tool, and stop misreading curl (AP-2) - #115
Merged
Merged
Conversation
…ading curl (AP-2)
Three probes in `lt dev` could not answer on Windows, and each failed in the
direction that looks like a working answer.
## 1. Port binding: `lsof` was the only mechanism
`listenSnapshot` served two different questions from one `lsof` call — "is
something listening?" and "which process?" — and returned an EMPTY MAP when lsof
was missing. That is indistinguishable from "nothing is bound": every component
classified as `crashed`, `lt dev up` restarted a healthy stack, and `reclaimPort`
silently reclaimed nothing before respawning onto an occupied port.
`probePorts` splits the two:
- `bound` — a TCP connect to 127.0.0.1 from Node. No external tool, identical on
every platform, and it cannot fail open.
- `owners` + `ownersUnavailable` — the attribution, which genuinely needs a tool
(`lsof`, or `netstat -ano` + `tasklist` on Windows; both ship with the OS and
neither needs PowerShell). A caller can now say "I could not tell" instead of
acting on an absence of evidence: `reclaimPort` warns rather than pretending,
and `doctor` reports an unattributable port as its own state — not "free", and
not proof that Caddy is blocked.
Both owner branches and the no-tool path are exercised from any host, through an
injected `capture`. A branch only the other platform runs is an unchecked branch.
## 2. `isPidAlive` read EPERM as dead
`process.kill(pid, 0)` has two failures that mean opposite things: `ESRCH` (gone)
and `EPERM` (**exists**, may not be signalled). Catching both as "dead" made
`classifyComponentHealth` report a running component as `dead` — so `lt dev up`
killed and restarted it. Reproducible anywhere: `process.kill(1, 0)` throws EPERM.
Pre-existing and platform-independent; Windows only makes it likelier, where an
elevated process is ordinary.
## 3. `curl -o /dev/null` reported a running Caddy as down
`lt dev up` refused with "caddy daemon is not running" against a Caddy that was
listening on :2019 and answering 200. `/dev/null` is an ordinary file path on
Windows — the null device is `NUL` — so curl completed the request, received the
226-byte answer, failed to WRITE it, and exited **23**. The exit code was all the
detector looked at.
```
curl.exe -fsS -o NUL http://localhost:2019/config/ → exit 0
curl.exe -fsS -o /dev/null http://localhost:2019/config/ → exit 23
```
`httpStatus` does the request with Node's own client: no external binary, no null
device, no exit code to misread. One probe for all three sites that had it —
`waitForHttp`, `caddy.ts#caddyDaemonRunning`, `dev-service.ts#pingCaddyAdmin` —
two of which carried the identical bug.
The admin URL is now `127.0.0.1` in both places, for consistency with the
reverse-proxy upstreams. `localhost` was measured to resolve correctly on that
machine, so this is tidiness, not the fix.
## The lesson, recorded in CLAUDE.md
A first measurement of the `/dev/null` flag ran `curl -s -o /dev/null -w
"%{http_code}"`, saw `200` and concluded it was harmless — `-w` prints the status
regardless, and the **exit code was never in that measurement**. A correct
diagnosis was withdrawn on the strength of it. **When a check consumes an exit
code, the counter-test consumes the exit code.**
`checkPortInUse` is deleted — `doctor` was its only caller.
Tests: 77 suites / 1190 tests (`git-commands`, `dev-service-e2e` excluded — real
network, real launchctl). Mutations, each turning exactly one test red: EPERM read
as dead; `ownersUnavailable` pinned to false; the `LISTENING` filter dropped from
the netstat parser. The third only bit after the fixture was reordered to put the
ESTABLISHED row first — before that it passed for the wrong reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8cvaEziSrKGHv3Jcp59JH
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.
AP-2 of the
lt devWindows port (inventory:windows-support/cli-lt-dev-windows.md). Builds on #114.Three probes could not answer on Windows — and each failed in the direction that looks like a working answer.
1. Port binding had exactly one mechanism:
lsoflistenSnapshotserved two different questions from onelsofcall and returned an empty map when lsof was missing. That is indistinguishable from "nothing is bound":crashed→lt dev uprestarted a healthy stackreclaimPortreclaimed nothing, silently, and the respawn landed on an occupied portprobePortssplits the two questions because they have different failure modes:boundownerslsof, ornetstat -ano+tasklistownersUnavailablesays soSo a caller can now say "I could not tell" instead of acting on an absence of evidence.
reclaimPortwarns instead of pretending;doctorreports an unattributable port as its own state — not "free", and not proof that Caddy is blocked.Both owner branches and the no-tool path are exercised from any host, through an injected
capture. A branch only the other platform runs is an unchecked branch.netstat/tasklistwere chosen because both ship with Windows and neither needs PowerShell.2.
isPidAlivereadEPERMas deadprocess.kill(pid, 0)has two failures that mean opposite things:ESRCH— no such processEPERM— the process exists, this user may not signal itCatching both as "dead" made
classifyComponentHealthreport a running component asdead, solt dev upkilled and restarted it. Reproducible on any POSIX machine:process.kill(1, 0)throwsEPERM.Pre-existing and platform-independent. Windows only makes it likelier, where an elevated process is ordinary.
3.
curl -o /dev/nullreported a running Caddy as downOn the laptop,
lt dev uprefused with "caddy daemon is not running" while Caddy was listening on :2019 and answering 200./dev/nullis an ordinary file path on Windows — the null device isNUL. curl completed the request, received the 226-byte answer, failed to write it, and exited 23 ("client returned ERROR on write"). The exit code was all the detector looked at.httpStatusdoes the request with Node's own client — no external binary, no null device, no exit code to misread. One probe now serves all three sites that had it:waitForHttp,caddy.ts#caddyDaemonRunning,dev-service.ts#pingCaddyAdmin. Two of them carried the identical bug.The admin URL is
127.0.0.1in both places now, for consistency with the reverse-proxy upstreams.localhostwas measured to resolve correctly on that machine — this is tidiness, not the fix.The lesson, recorded in CLAUDE.md
A first measurement of that flag ran
curl -s -o /dev/null -w "%{http_code}", saw200, and concluded it was harmless.-wprints the status regardless — the exit code was never in that measurement, and it is the only thing the code consults. A correct diagnosis was withdrawn on the strength of it.When a check consumes an exit code, the counter-test consumes the exit code. Measuring the other channel proves nothing and reads exactly like proof.
Checks
tsc --noEmit,npm run lintclean.git-commands,dev-service-e2eexcluded — real network, real launchctl).EPERMread as dead;ownersUnavailablepinned tofalse; theLISTENINGfilter dropped from the netstat parser.The third only bit after the fixture was reordered to put the
ESTABLISHEDrow first. Before that it passed for the wrong reason — the first match happened to be the right one anyway.checkPortInUseis deleted;doctorwas its only caller.Draft until the block is reviewed as a whole.
🤖 Generated with Claude Code
https://claude.ai/code/session_01N8cvaEziSrKGHv3Jcp59JH