Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions internal/claude/procs.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"os/exec"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -101,6 +103,155 @@ func matchesClaudeComm(comm string) bool {
return name == "claude"
}

// ListeningPortsByCwd returns real (unencoded) cwd → sorted distinct TCP
// ports with a listening socket held by a process working there — the
// captain's `make local PORT=xxxx` e2e server running as a sibling shell in
// a worktree, found the same way LiveClaudeCwds finds claude processes: two
// bounded lsof probes (system-wide listeners, then those pids' cwds), each
// under procTimeout so a wedged process table never hangs the TUI's refresh.
//
// ok=false means a probe itself failed — the caller MUST NOT treat that as
// "no servers": a racing lsof is not evidence a server died, so on failure
// nothing may be attached OR cleared based on this result. (lsof exits
// non-zero both on real failure and when zero sockets match the filter;
// they're indistinguishable here, and both correctly attach nothing.)
func ListeningPortsByCwd() (map[string][]int, bool) {
ctx, cancel := context.WithTimeout(context.Background(), procTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "lsof", "-nP", "-iTCP", "-sTCP:LISTEN", "-Fpn").Output()
if err != nil {
return map[string][]int{}, false
}
portsByPid := parseLsofListenPorts(string(out))
if len(portsByPid) == 0 {
return map[string][]int{}, true // probe succeeded; genuinely zero listeners
}
pidStrs := make([]string, 0, len(portsByPid))
for pid := range portsByPid {
pidStrs = append(pidStrs, strconv.Itoa(pid))
}
ctx2, cancel2 := context.WithTimeout(context.Background(), procTimeout)
defer cancel2()
cwdOut, err := exec.CommandContext(ctx2, "lsof", "-a", "-p", strings.Join(pidStrs, ","), "-d", "cwd", "-Fn").Output()
if err != nil {
return map[string][]int{}, false
}
return joinPortsByCwd(portsByPid, parseLsofPidCwds(string(cwdOut))), true
}

// parseLsofListenPorts parses `lsof -nP -iTCP -sTCP:LISTEN -Fpn` output —
// interleaved "p<pid>"/"f<fd>"/"n<addr>" lines, one "n" per listening
// socket — into pid → sorted distinct ports. One server commonly listens
// twice per port (IPv4 "*:3000" + IPv6 "[::]:3000"), so ports dedup per
// pid. An addr whose port doesn't parse (e.g. "*:*") attaches nothing —
// never a guessed port — and an unparseable "p" line orphans the "n" lines
// under it rather than crediting them to the previous pid.
func parseLsofListenPorts(out string) map[int][]int {
ports := make(map[int][]int)
pid := -1
for _, line := range strings.Split(out, "\n") {
if len(line) < 2 {
continue
}
switch line[0] {
case 'p':
id, err := strconv.Atoi(line[1:])
if err != nil {
pid = -1
continue
}
pid = id
case 'n':
if pid < 0 {
continue
}
port, ok := listenAddrPort(line[1:])
if !ok {
continue
}
if !slices.Contains(ports[pid], port) {
ports[pid] = append(ports[pid], port)
}
}
}
for _, ps := range ports {
sort.Ints(ps)
}
return ports
}

// listenAddrPort extracts the port from an lsof listen-socket name field —
// "*:3000", "127.0.0.1:3000", "[::1]:3000" — the text after the LAST colon
// (IPv6 addrs contain colons of their own). ok=false for anything that
// isn't a valid port number.
func listenAddrPort(addr string) (int, bool) {
idx := strings.LastIndexByte(addr, ':')
if idx < 0 {
return 0, false
}
port, err := strconv.Atoi(addr[idx+1:])
if err != nil || port < 1 || port > 65535 {
return 0, false
}
return port, true
}

// parseLsofPidCwds parses `lsof -a -p <pids> -d cwd -Fn` output into
// pid → cwd path — the same record shape parseLsofCwds reads, but keyed by
// pid instead of counted, because the ports join needs to know WHICH
// process's cwd each listener has. An unparseable "p" line orphans the "n"
// under it (never credited to the previous pid), same as
// parseLsofListenPorts.
func parseLsofPidCwds(out string) map[int]string {
cwds := make(map[int]string)
pid := -1
for _, line := range strings.Split(out, "\n") {
if len(line) < 2 {
continue
}
switch line[0] {
case 'p':
id, err := strconv.Atoi(line[1:])
if err != nil {
pid = -1
continue
}
pid = id
case 'n':
if pid < 0 {
continue
}
cwds[pid] = line[1:]
}
}
return cwds
}

// joinPortsByCwd joins the two probes: listener pids' ports, keyed by those
// pids' real cwd paths. A listener whose pid has no cwd record (process
// exited between the two probes, or lsof couldn't resolve it) contributes
// nothing — an unattributable port is never attached anywhere. Ports dedup
// per cwd (two servers in one dir can share a port across restarts' TIME_WAIT
// races, and a dir can host several pids listening on the same port family).
func joinPortsByCwd(portsByPid map[int][]int, cwdByPid map[int]string) map[string][]int {
byCwd := make(map[string][]int)
for pid, ports := range portsByPid {
cwd, ok := cwdByPid[pid]
if !ok || cwd == "" {
continue
}
for _, p := range ports {
if !slices.Contains(byCwd[cwd], p) {
byCwd[cwd] = append(byCwd[cwd], p)
}
}
}
for _, ps := range byCwd {
sort.Ints(ps)
}
return byCwd
}

// parseLsofCwds parses `lsof -a -p <pids> -d cwd -Fn` output: interleaved
// "p<pid>"/"f<fdtype>"/"n<path>" lines, one "n<path>" per live process (the
// path of its "cwd" fd) — counting how many processes share each cwd.
Expand Down
100 changes: 100 additions & 0 deletions internal/claude/procs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,103 @@ func TestParseLsofCwds_IgnoresNonNLines(t *testing.T) {
t.Errorf("got %+v, want {/tmp/x: 1}", counts)
}
}

func TestParseLsofListenPorts(t *testing.T) {
// real captured shape of `lsof -nP -iTCP -sTCP:LISTEN -Fpn`: p/f/n
// records, one n per listening socket. pid 100 listens on 3000 twice
// (IPv4 "*:3000" + IPv6 "[::]:3000" — the common dual-stack server,
// must dedup to ONE 3000) plus 8080; pid 200 on a loopback addr.
out := "p100\nf23\nn*:3000\nf24\nn[::]:3000\nf25\nn*:8080\n" +
"p200\nf10\nn127.0.0.1:5173\n"

ports := parseLsofListenPorts(out)
if len(ports) != 2 {
t.Fatalf("got %d pids, want 2: %+v", len(ports), ports)
}
if len(ports[100]) != 2 || ports[100][0] != 3000 || ports[100][1] != 8080 {
t.Errorf("ports[100] = %v, want [3000 8080] (dual-stack 3000 deduped, sorted)", ports[100])
}
if len(ports[200]) != 1 || ports[200][0] != 5173 {
t.Errorf("ports[200] = %v, want [5173]", ports[200])
}
}

func TestParseLsofListenPorts_UnparseableAddrAttachesNothing(t *testing.T) {
// an addr with no valid port ("*:*", garbage) must attach nothing —
// never a guessed port. Honesty rule: ambiguous → nothing.
out := "p100\nf23\nn*:*\nf24\nngarbage\n"
if ports := parseLsofListenPorts(out); len(ports) != 0 {
t.Errorf("got %+v, want empty — unparseable addrs must not attach", ports)
}
}

func TestParseLsofListenPorts_BadPidLineOrphansItsRecords(t *testing.T) {
// an unparseable "p" line must orphan the n-lines under it, NOT credit
// them to the previous pid.
out := "p100\nf23\nn*:3000\npNOPE\nf24\nn*:9999\n"
ports := parseLsofListenPorts(out)
if len(ports) != 1 || len(ports[100]) != 1 || ports[100][0] != 3000 {
t.Errorf("got %+v, want {100: [3000]} — 9999 belongs to an unparseable pid", ports)
}
}

func TestParseLsofListenPorts_Empty(t *testing.T) {
if ports := parseLsofListenPorts(""); len(ports) != 0 {
t.Errorf("got %+v, want empty", ports)
}
}

func TestListenAddrPort(t *testing.T) {
cases := []struct {
addr string
port int
ok bool
}{
{"*:3000", 3000, true},
{"127.0.0.1:8080", 8080, true},
{"[::1]:5173", 5173, true}, // port is after the LAST colon — IPv6 addrs contain their own
{"[::]:80", 80, true},
{"*:*", 0, false},
{"no-colon", 0, false},
{"*:0", 0, false}, // 0 is not a real listening port
{"*:99999", 0, false}, // out of range
{"", 0, false},
}
for _, c := range cases {
port, ok := listenAddrPort(c.addr)
if port != c.port || ok != c.ok {
t.Errorf("listenAddrPort(%q) = (%d, %v), want (%d, %v)", c.addr, port, ok, c.port, c.ok)
}
}
}

func TestParseLsofPidCwds(t *testing.T) {
// same record shape parseLsofCwds reads, but keyed by pid for the
// ports join.
out := "p100\nfcwd\nn/x/worktree-a\np200\nfcwd\nn/x/worktree-b\n"
cwds := parseLsofPidCwds(out)
if len(cwds) != 2 || cwds[100] != "/x/worktree-a" || cwds[200] != "/x/worktree-b" {
t.Errorf("got %+v, want {100: /x/worktree-a, 200: /x/worktree-b}", cwds)
}
}

func TestJoinPortsByCwd(t *testing.T) {
portsByPid := map[int][]int{
100: {3000},
200: {8080},
300: {9999}, // no cwd record (exited between probes) — must attach nowhere
}
cwdByPid := map[int]string{
100: "/x/worktree-a",
200: "/x/worktree-a", // second server in the same dir — ports merge
}

byCwd := joinPortsByCwd(portsByPid, cwdByPid)
if len(byCwd) != 1 {
t.Fatalf("got %d cwds, want 1: %+v", len(byCwd), byCwd)
}
got := byCwd["/x/worktree-a"]
if len(got) != 2 || got[0] != 3000 || got[1] != 8080 {
t.Errorf("got %v, want [3000 8080] (merged across pids, sorted)", got)
}
}
61 changes: 61 additions & 0 deletions internal/claude/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,16 @@ func DiscoverLoops(now time.Time, within time.Duration) ([]domain.Loop, error) {
live, liveOK := LiveClaudeCwds()
loops = applyLiveness(loops, live, liveOK, historyDir, now, within)

// PORTS surfacing (pivot §11): attach observed listening TCP ports to
// loops whose cwd applyLiveness just verified live. Gated — the probe
// (2 more lsof calls) only runs when at least one loop is CwdVerified,
// since applyPorts can attach to no one else anyway; a fleet with no
// live process pays nothing new.
if anyCwdVerified(loops) {
ports, portsOK := ListeningPortsByCwd()
loops = applyPorts(loops, ports, portsOK)
}

// Keep metricsCache bounded to sessions actually present in this scan —
// otherwise it grows forever as old sessions age out of the window or
// get deleted, over a long-running fleetops process.
Expand Down Expand Up @@ -612,6 +622,57 @@ func applyLiveness(loops []domain.Loop, live map[string]int, ok bool, historyDir
return out
}

// anyCwdVerified reports whether at least one loop has a live-process-
// verified cwd — the gate for the ports probe in DiscoverLoops: applyPorts
// attaches to CwdVerified loops only, so with none the 2 extra lsof calls
// would be pure cost for a guaranteed no-op.
func anyCwdVerified(loops []domain.Loop) bool {
for _, l := range loops {
if l.CwdVerified {
return true
}
}
return false
}

// applyPorts attaches observed listening TCP ports (ListeningPortsByCwd) to
// each loop whose cwd hosts a listener — the PORTS surfacing enrichment
// (pivot §11), run right after applyLiveness because it needs the healed
// real cwd. Display-only metadata: State/Stall are never touched here, and
// a port's disappearance is never a StallKind.
//
// Honesty rules, mirroring applyLiveness's own discipline:
// - ok=false (a probe failed) attaches nothing — a racing lsof is not
// evidence about any server, and Ports stays nil ("nothing observed"),
// which renders as nothing rather than as a claim.
// - only CwdVerified loops can match, and the match is EXACT string
// equality between the listener's real lsof cwd and the loop's healed
// real Cwd (both come from the same lsof -d cwd probe shape, so equal
// directories yield equal strings). Deliberately NOT the encodeCwd
// round-trip applyLiveness uses for its own matching: encodeCwd is
// many-to-one ("/x/foo-bar" vs "/x/foo.bar"), and matching through it
// could attach one dir's server to the other dir's loop — exactly the
// ambiguity CwdVerified's collision guard exists to refuse. Verified
// loops have the real path, so the lossless comparison is available and
// exact-only (a server in a SUBdirectory of a loop's cwd attaches
// nowhere — v1, per §11: `make local` runs at the worktree root).
// - a collided ProjectDir needs no second guard here: applyLiveness
// already leaves its loops CwdVerified=false.
func applyPorts(loops []domain.Loop, portsByCwd map[string][]int, ok bool) []domain.Loop {
if !ok {
return loops
}
for i := range loops {
if !loops[i].CwdVerified {
continue
}
if ports := portsByCwd[loops[i].Cwd]; len(ports) > 0 {
loops[i].Ports = ports
}
}
return loops
}

// mostRecentActuationIsKill reports whether sessionID's most recent
// actor=human actuation event within `within` of now was a kill that was
// CONFIRMED DISPATCHED.
Expand Down
Loading