Skip to content

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

Merged
hefgi merged 4 commits into
mainfrom
feat/port-discovery
Oct 2, 2026
Merged

hefgi merged 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   -
feat-c   host  3     api=4030   4030 24678

$ 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. lsof -iTCP -sTCP:LISTEN + ps -axo pid=,ppid=, then the process-tree join happens in memory, so the scan doesn't grow with session count. Identifying each session's root processes still costs a few cheap pid-file checks and tmux lookups per session; status now uses the same snapshot instead of its per-service lsof/pgrep calls. 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/ACTUAL without flagging anything, in both ls and status.

Per-service attribution. status blames a port only on the tmux window or pid file of the service that holds it (never a sibling pane), and drops pid files whose pid was recycled (start-time token check). If lsof is missing, discovery is off with a one-time warning.

Docker excluded. The daemon publishes container ports, not a host process tree — find_docker_services already reports the real mapping. Only natively spawned services are compared, so hybrid sessions' Docker ports never trigger a !.

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

65 new unit tests. 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, that mismatch_hint never contains the word kill, hybrid Docker-port exclusion, sibling-pane non-blame, and recycled-pid rejection.

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

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.

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.

hefgi added 4 commits October 2, 2026 15:59
`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.
Adds src/discover.rs: one snapshot of every listening TCP socket (`lsof`)
plus the process table (`ps`), with the process-tree join done in memory.
`ecluse status` now classifies every native service against that shared
snapshot instead of running `lsof`/`pgrep` per service.

- EXPECTED/ACTUAL columns: ACTUAL lists what the service's own process
  tree is listening on.
- `wrong port <n>` is reported only when the tree does not hold the
  assigned port but listens on another one. Extra sockets (HMR,
  inspector) on a healthy service are listed, never flagged.
- When the wrong port falls in another slot's territory, the warning
  names that slot and session, says not to kill it, and points at
  `down --keep-worktree` + `up`.
- Ports are attributed only to the service's own tree: its
  token-verified pid file, or for legacy tmux sessions its own window's
  pane. A recycled pid gets nothing, and a sibling pane's port is never
  blamed on a service.
- Wrong-owner detection uses the same snapshot.
- A missing `lsof` disables discovery with a one-time warning instead of
  silently reporting nothing.

Discovery is read-only: state.json stays the source of truth and a
discovered port is never written back.
`ecluse ls` shows the ports each session's process trees are listening
on, from one shared snapshot for all sessions. Roots are the session's
token-verified pid files plus its tmux pane pids. A trailing `!` marks a
session where an assigned port is not held.

Only the assigned ports of native services that ecluse spawns are
compared. In hybrid mode, port_overrides also holds the Docker service
ports, which the Docker daemon holds and the session's process tree
never does, so comparing them flagged every hybrid session. Extra
sockets (HMR, inspector) are listed but never flagged.

`--json` adds `listening_ports` and `port_mismatch` per session.
Covers the LISTENING and ACTUAL columns and the cross-slot warning in
the README, the commands and ports reference, the changelog and the
agent skill.

The skill entry sits in the existing cross-agent-collision
troubleshooting section, because that is the failure this reporting is
meant to prevent. It shows the wrong-port table an agent will see and
explains that the process on ACTUAL is the agent's own, sitting on
another slot's port. The fix is `down --keep-worktree` + `up`, not
`kill`.

The cost of discovery is described accurately: one `lsof` and one `ps`
no matter how many sessions there are, plus the per-session pid-file and
tmux lookups.
@hefgi
hefgi force-pushed the feat/port-discovery branch from 467c393 to eeb3af0 Compare October 2, 2026 15:01
@hefgi
hefgi merged commit bd4c575 into main Oct 2, 2026
2 checks passed
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