Skip to content

feat: on-demand port discovery — report what's actually listening - #34

Open
hefgi wants to merge 4 commits into
mainfrom
feat/port-discovery
Open

hefgi wants to merge 4 commits into
mainfrom
feat/port-discovery

Conversation

@hefgi

@hefgi hefgi commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Why

ecluse assigns ports (port = base_port + slot × stride) and treats state.json as truth. What it couldn't do was tell you where a service actually ended up. When something bound the wrong port, ecluse status reported a bare ✗ down — technically correct, but it gives an agent nothing to act on.

That gap has a cost on record. In the 2026-06-09 cross-agent kill spiral, three parallel agents each saw "a process on a port near mine", inferred a stale leftover, and ran lsof -ti | xargs kill against a sibling's working service. Seven kills in seven minutes. whose-pid was added afterwards so ownership could be checked — but nothing surfaced the mismatch unprompted, so no one thought to check.

This adds the missing signal.

What

ecluse ls gains a LISTENING column; ecluse status gains ACTUAL beside EXPECTED.

$ ecluse ls
SLUG     MODE  SLOT  PORTS      LISTENING
feat-a   host  1     api=4010   4020 !
feat-b   host  2     api=4020   4020

$ ecluse status feat-a
SERVICE  TYPE     EXPECTED  ACTUAL  STATUS
api      native   4010      4020    ✗ wrong port 4020 (slot 2)

warning: service 'api' is listening on 4020 but ecluse assigned 4010; 4020 belongs
to slot 2 (session 'feat-b') — do not kill it, run: ecluse down feat-a
--keep-worktree && ecluse up feat-a

Because ports derive from the slot, the formula inverts — a discovered port maps back to the slot that owns it. When the wrong port is another slot's territory, ecluse names that slot and its session, and says not to kill it. That's the one fact whose absence started the spiral.

Design decisions

No daemon. Discovery runs when a command is invoked. There's no sidebar to keep live and no PTY to scrape, so a resident scanner would burn lsof every few seconds to populate a view nobody's looking at.

One snapshot, two forks. lsof -iTCP -sTCP:LISTEN + ps -axo pid=,ppid=, then the process-tree join happens in memory — cost is independent of session count. The per-PID approach already in sync.rs forks lsof -p once per process and pgrep -P once per tree level (30+ forks for 4 sessions, ~20-400ms each). Measured ~25ms per fork locally, which is what makes always-on affordable.

Read-only — assignment stays truth. A discovered port is never written back over the assigned one. Auto-adopting would make expected == actual and turn the row green, erasing the evidence that something bound the wrong port; it also wouldn't move the already-running process. This is the same trap as fix #4 in the incident (status used to report a child's discovered port and hid a wrong-slot spawn behind a green check). Lesson #1 there: "State files are truth. Process trees lie."

Only a missing assigned port counts as a mismatch. Dev servers open extra sockets (HMR, debug, inspector); those show up in LISTENING without flagging anything.

Docker excluded. The daemon publishes container ports, not a host process tree — find_docker_services already reports the real mapping.

Verified end-to-end

Reproduced the incident on a scratch repo (slot_stride = 10, so slot 1 → 4010, slot 2 → 4020), bound feat-a's service to 4020, and confirmed the output above plus exit 1.

Tests

80 new unit tests (457 → 537 in the bin suite). Covers the stateful lsof -F pn record format, ports listed once per file descriptor, bracketed IPv6 ([::1]:27017), IGNORED_PORTS (22/80/443), grandchild attribution (sh → pnpm → node → vite), sibling non-attribution, ppid-cycle tolerance, the owning_slot inversion incl. off-stride ports that belong to nobody, and that mismatch_hint never contains the word kill.

cargo fmt --check and cargo clippy --all-targets -- -D warnings are clean.

Also included

test(process): fixes a genuine flake in kill_nohup_kills_whole_process_group. It waited on child_pid_file.exists() then immediately parsed — the shell creates the file on redirect and writes the pid a moment later, so it could read empty and panic with ParseIntError { kind: Empty }. Always passed in isolation, only failed under full-suite load. Now waits on a successful parse.

Known pre-existing flake (not from this PR)

down_during_slow_up_does_not_resurrect_the_session fails intermittently — a locked-worktree race in down-during-up. Reproduced on clean main at the same rate (2 of 6 runs there, 2 of 4 here). Left alone as out of scope; worth its own issue.

Docs

README, docs/src/commands.md, docs/src/ports.md, skills/ecluse/SKILL.md (under the existing cross-agent-collision troubleshooting entry, since that's the failure this defuses), and a CHANGELOG entry.

Adds `discover::PortSnapshot` — one point-in-time view of every listening
TCP port on the host plus the process table needed to attribute those ports
to a process tree.

Two subprocess calls total (`lsof -iTCP -sTCP:LISTEN` + `ps -axo pid=,ppid=`),
independent of session count. The existing per-PID approach in sync.rs forks
`lsof -p <pid>` once per process and `pgrep -P` once per tree level — 30+ forks
for 4 sessions at 20-400ms each. The snapshot joins the trees in memory instead,
which is what makes discovery cheap enough to run on every command invocation
rather than needing a background daemon.

Discovery is read-only and never rewrites state. A discovered port that
disagrees with the assigned one is evidence of a bug (typically an external
task runner re-reading .env.local instead of .env.ecluse), not a better value
to adopt — trusting discovery is what hid a wrong-slot spawn behind a green
check in the 2026-06-09 cross-agent kill spiral.

Parses the stateful `lsof -F pn` record format, dedupes a port listed once per
file descriptor, handles bracketed IPv6 (`[::1]:27017`), filters system ports
(22/80/443), and cycle-guards the descendant walk so a torn `ps` read can't
hang it.
`ecluse ls` gains a LISTENING column showing the ports each session's
process trees are actually bound to, with a trailing `!` when an assigned
port is missing from that set. `ecluse status` gains an ACTUAL column beside
EXPECTED, and a service that is alive on the wrong port now reads
`✗ wrong port 4020 (slot 2)` instead of a bare `✗ down`.

When the discovered port falls inside another slot's territory
(`port = base_port + slot × stride` inverted), the warning names the owning
slot and session and explicitly forbids killing it:

  service 'api' is listening on 4020 but ecluse assigned 4010; 4020 belongs
  to slot 2 (session 'feat-b') — do not kill it, run: ecluse down feat-a
  --keep-worktree && ecluse up feat-a

That is the fact whose absence started the 2026-06-09 kill spiral: three
agents each saw "a process on a port near mine", inferred a stale leftover,
and ran `lsof -ti | xargs kill` against a sibling's working service. A bare
"down" invites a theory; naming the owner ends it.

Discovery is read-only — state.json remains truth and nothing is written
back. Only a MISSING assigned port counts as a mismatch, so the extra
sockets a dev server opens (HMR, debug, inspector) don't flag one. Docker
services are excluded: the daemon publishes their ports, not a host process
tree, and find_docker_services already reports the real mapping.

`--json` gains listening_ports/port_mismatch on `ls` sessions and
actual_port/port_mismatch/conflicting_slot/hint on `status` services, so an
agent can branch on the mismatch without parsing a table. A mismatch trips
the existing non-zero exit, making `ecluse status --quiet` usable as a gate.
`kill_nohup_kills_whole_process_group` waited on `child_pid_file.exists()`
then immediately parsed the contents. The shell creates the file when it sets
up the `> file` redirect and writes the pid a moment later, so the wait could
return while the file was still empty — `parse().unwrap()` then panicked with
`ParseIntError { kind: Empty }`.

The window is small enough that the test always passed in isolation and only
failed under full-suite load, which is the worst failure mode to leave in a
tool whose premise is reliability under parallelism.

Waits on a successful parse instead, and reports the path when it times out.
Covers the new LISTENING / ACTUAL columns and the cross-slot warning across
README, the commands and ports reference, and the agent skill.

The skill entry goes under the existing cross-agent-collision troubleshooting
section, since that's the failure this reporting exists to defuse: it now
shows the wrong-port table an agent will actually see and states plainly not
to kill the process on the ACTUAL port.

Emphasises throughout that discovery is read-only — assignment stays truth,
and a mismatch means something bound the wrong port rather than that the
assigned port was wrong.
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.

down/shutdown/flush select the mode handler from current config, not session.mode — mode change strands containers

1 participant