Skip to content

Answer "is this port bound" without a tool, and stop misreading curl (AP-2) - #115

Merged
DKoenig9 merged 1 commit into
mainfrom
feat/windows-dev-ports
Sep 22, 2026
Merged

DKoenig9 merged 1 commit into
mainfrom
feat/windows-dev-ports

Conversation

@DKoenig9

Copy link
Copy Markdown
Contributor

AP-2 of the lt dev Windows 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: lsof

listenSnapshot served two different questions from one lsof call and returned an empty map when lsof was missing. That is indistinguishable from "nothing is bound":

  • every component classified as crashedlt dev up restarted a healthy stack
  • reclaimPort reclaimed nothing, silently, and the respawn landed on an occupied port

probePorts splits the two questions because they have different failure modes:

answered by can it fail?
bound a TCP connect to 127.0.0.1 from Node no — a connect either succeeds or it does not
owners lsof, or netstat -ano + tasklist yes, and ownersUnavailable says so

So a caller can now say "I could not tell" instead of acting on an absence of evidence. reclaimPort warns instead of pretending; 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. netstat/tasklist were chosen because both ship with Windows and neither needs PowerShell.

2. isPidAlive read EPERM as dead

process.kill(pid, 0) has two failures that mean opposite things:

  • ESRCH — no such process
  • EPERMthe process exists, this user may not signal it

Catching both as "dead" made classifyComponentHealth report a running component as dead, so lt dev up killed and restarted it. Reproducible on any POSIX machine: 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

On the laptop, lt dev up refused with "caddy daemon is not running" while Caddy was listening on :2019 and answering 200.

/dev/null is an ordinary file path on Windows — the null device is NUL. 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.

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 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.1 in both places now, for consistency with the reverse-proxy upstreams. localhost was 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}", saw 200, and concluded it was harmless. -w prints 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 lint clean.
  • Jest: 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 — the first match happened to be the right one anyway.

checkPortInUse is deleted; doctor was its only caller.

Draft until the block is reviewed as a whole.

🤖 Generated with Claude Code

https://claude.ai/code/session_01N8cvaEziSrKGHv3Jcp59JH

…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
@DKoenig9
DKoenig9 marked this pull request as ready for review September 22, 2026 08:44
@DKoenig9
DKoenig9 merged commit 778e928 into main Sep 22, 2026
2 checks passed
@DKoenig9
DKoenig9 deleted the feat/windows-dev-ports branch September 22, 2026 08:44
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