diff --git a/.claude/skills/run-mill/SKILL.md b/.claude/skills/run-mill/SKILL.md index 8a843a35..475c5c0f 100644 --- a/.claude/skills/run-mill/SKILL.md +++ b/.claude/skills/run-mill/SKILL.md @@ -193,6 +193,29 @@ real desktop build has no automatable hook for these: four visible×focused combinations) — only the real-window wiring around it is manual-only. +**Manual-only, a different class of gap: `task dev`'s own concurrent- +start guard and per-rebuild reap (docs/goals/BACKLOG.md Standing #8, +`internal/devguard`, `Taskfile.yml`'s `dev:` task, `build/config.yml`'s +`dev_mode.executes`).** `internal/devguard`'s own decision logic (does +a `ps`/`lsof` snapshot show an existing session) is unit-tested +directly (`guard_test.go`) and was verified live against a genuinely +running `task dev` session during this item's own build (correctly +named the real PID and refused). What CI structurally cannot prove: +CI never runs `task dev` itself (no live file watcher, no real Go +recompile-and-relaunch cycle — the exact reasoning +`.claude/rules/testing.md`'s "Dev-loop timing checks" entry already +gives for `BuildIdentityBadge`'s go-stale state), so (a) the +PER-REBUILD reap in `build/config.yml` actually preventing orphan +accumulation across SEVERAL real Go-triggered rebuilds within one live +session, and (b) a genuine second `task dev` terminal invocation +actually refusing to start (not just the guard binary run standalone), +both stay real desktop-mode manual checks: start `task dev`, touch a +watched `.go` file several times in a row and confirm `ps aux | grep +mill.dev.app` never shows more than one instance after each relaunch, +then (in a second terminal, same repo) run `task dev` again and +confirm it exits immediately naming the first session's PID rather +than launching a second window. + Verification for all five stays a real desktop-mode manual check: launch via `task dev`, set a summon hotkey in Settings, press it from another app, confirm the panel appears floating/frameless above diff --git a/Taskfile.yml b/Taskfile.yml index 3700edde..84097c33 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -116,7 +116,28 @@ tasks: its only flags are -config/-port/-nocolour/-s), so there's no manual override -- the fingerprint IS the mechanism, which is why not wiping bin/ (above) matters. + + A second concurrent `task dev` REFUSES to start (internal/devguard, + docs/goals/BACKLOG.md Standing #8b), naming the already-running + PID instead of silently killing the first session's own vite/app. + build/config.yml's dev_mode.executes also reaps any leftover + mill.dev.app before each Go-rebuild relaunch WITHIN one session + -- the one residual, CI-unprovable gap (a real live wedge/rebuild + timing under an actually-running watcher) is named explicitly in + the manual-only registry (.claude/skills/run-mill/SKILL.md), not + silently assumed fixed. cmds: + # Concurrent-start guard (docs/goals/BACKLOG.md Standing #8b, + # owner-hit 2026-08-12 evening: THREE concurrent mill.dev.app + # instances, a real crash risk on a 16GB machine). Runs BEFORE the + # destructive sweep below: a second `task dev` invoked while a + # first is already live must REFUSE to start, not silently kill + # the first session's own vite/app the way the sweep would -- + # internal/devguard checks for an already-running `wails3 dev` + # process for this repo and exits non-zero naming its PID, which + # aborts this whole task before the sweep steps ever run (Task's + # own default: stop on the first failing cmd). + - go run ./internal/devguard -port {{.VITE_PORT}} # Defensive orphan sweep (researched root cause, SPEC §3.8): the # `wails3 dev` supervisor only traps SIGINT+SIGTERM, not SIGHUP, so # closing the terminal tab (rather than Ctrl-C) kills the supervisor @@ -125,7 +146,10 @@ tasks: # `task dev` has no memory of it and launches a SECOND window. This # kills any such leftover before starting, so a fresh `task dev` # always begins from exactly one live instance. `|| true`: a clean - # start (no orphan) is the normal case, not an error. + # start (no orphan) is the normal case, not an error. Only reached + # once the guard above has already confirmed no LIVE `wails3 dev` + # is running, so anything found here is safely presumed a genuine + # orphan, never a second session's own live process. - pkill -f "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}" || true # Same failure mode, the vite side (goal 0029, hit live tonight): # a supervisor killed via SIGHUP can leave the vite dev server diff --git a/build/config.yml b/build/config.yml index 338f6f7c..819198fc 100644 --- a/build/config.yml +++ b/build/config.yml @@ -56,6 +56,33 @@ dev_mode: type: blocking - cmd: wails3 task common:dev:frontend type: background + # Per-rebuild reap (docs/goals/BACKLOG.md Standing #8a, owner-hit + # 2026-08-12 evening: THREE concurrent mill.dev.app instances, + # traced live to this exact gap). `type: blocking` steps re-run on + # EVERY reload cycle (refresh's own process.ProcessManager.Reload, + # github.com/atterpac/refresh -- confirmed directly against the + # vendored source, not assumed), unlike `background` above (first + # run only). The `primary` step below (wails3 task run) is SUPPOSED + # to have its own previous instance killed automatically by + # refresh's ProcessManager before each restart (a process-group + # SIGKILL) -- but a live-running dev session was directly observed + # with two concurrent mill.dev.app processes from the SAME session, + # one of them orphaned into a foreign process group refresh's own + # tracking never reaped (root cause not fully pinned down: possibly + # a globally-installed `wails3` CLI binary built against a + # different `refresh` version than what this repo's own go.mod + # pins for the app itself). Rather than patch a vendored third-party + # dependency this repo doesn't own, this step is an independent, + # pattern-based reap -- the same `pkill -f` shape Taskfile.yml's own + # dev: task already uses for its start-of-session sweep -- that + # runs regardless of whether refresh's own internal kill succeeds, + # so an orphan can no longer survive past the NEXT rebuild cycle + # even when refresh's own tracking misses it. Matches the exact + # path Taskfile.yml's darwin:run task builds + # (bin/mill.dev.app/Contents/MacOS/mill); `|| true` since the + # common case (nothing stale yet) isn't an error. + - cmd: pkill -f "bin/mill.dev.app/Contents/MacOS/mill" || true + type: blocking - cmd: wails3 task run type: primary diff --git a/docs/SPEC.md b/docs/SPEC.md index b8c203df..9dfed2a3 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -4213,6 +4213,22 @@ recorded as a real design input (`OPEN`), never silently dropped. incidents: `task dev`'s start sweep also clears an orphaned vite-port listener (`lsof -ti :9245`), and a non-blocking pre-start disk check warns (never blocks) below 2GB free, naming `go clean -cache`. + **Update (2026-08-13, BACKLOG.md Standing #8): the start-of-session + sweep above only ever ran ONCE, at `task dev` startup — orphans could + still accumulate WITHIN one long-running session, one per Go-rebuild + cycle, directly caught live (a running session had two concurrent + `mill.dev.app` processes, one orphaned into a foreign process group + `atterpac/refresh`'s own tracking never reaped — root cause not fully + pinned to one line in a vendored dependency this repo doesn't own).** + Two mechanical fixes: `build/config.yml`'s `dev_mode.executes` gained + a `type: blocking` reap step (`pkill -f` the `.dev.app` binary, + re-runs every reload cycle, confirmed against the vendored engine + source) right before the `primary` run step, backstopping refresh's + own kill regardless of whether it succeeds; and `internal/devguard` (a + real, unit-tested Go package) runs as `Taskfile.yml`'s `dev:` task's + first step, refusing a second concurrent `task dev` by naming the + already-running PID instead of letting the existing sweep silently + kill a genuinely live first session's own vite/app. ## 9.5 Platform kernel & extension contract diff --git a/docs/goals/BACKLOG.md b/docs/goals/BACKLOG.md index e7c634e3..0176ad71 100644 --- a/docs/goals/BACKLOG.md +++ b/docs/goals/BACKLOG.md @@ -202,7 +202,7 @@ live-review material, interleaved during owner reviews, not a lane.** 5. [x] Workflow pins/favorites (tech debt, split from goal 0015's remainder 2026-08-12) — DELIVERED 2026-08-13: `pinnedWorkflowIds: string[]` + `togglePinnedWorkflow` on `shared/store.ts`'s existing zustand `persist` (same localStorage tier as `activeWorkTabKey`, goal 0033's precedent — no new Go surface). `app/workflowFrecency.ts`'s new `sortWorkflowsByPinnedAndFrecency` partitions pinned (in pin-order) above the existing frecency-sorted unpinned tail, reusing `sortWorkflowsByFrecency` rather than a second algorithm. A Primer `PinIcon` `IconButton` trailing-visual pin toggle on both the Quick Panel's and ⌘K palette's workflow rows (muted outline unpinned, accent-colored "filled" once pinned) — found and fixed a real Primer interaction bug along the way: `ActionList.Item`'s own `TrailingVisual` wraps children in a `VisualWrap` span with `pointer-events: none` (trailing visuals are decorative-only by the library's own convention), which silently ate every click on the toggle until `pointer-events: auto` was added back on the button itself. Vitest covers the pinned-above-frecency/pin-order/unpinned-id-dropped/no-mutation cases; `quick-panel.spec.ts` gained a full pin→sort→unpin→revert→reload-persists e2e case. 6. [x] ⌘?/⌘/ multi-binding keybinding alias (tech debt, split from goal 0015's remainder 2026-08-12) — DELIVERED 2026-08-13: `Command` grew an optional `extraBindings: KeyCombo[]` alongside `defaultBinding` (`shared/commands.ts`, backward-compatible); `shared/keybinding.ts`'s `keyFromEventCode` gained `/` support (shift-independent, same as every other key — the Shift mod is what distinguishes ⌘/ from ⌘?, both on the physical Slash key). `palette.open` carries both as `extraBindings`, checked against the full registry + `RESERVED_COMBOS` first (no collision — nothing else uses `/`). `dispatchCommandForEvent` checks a command's effective (override-aware) primary plus its extras every dispatch; extras themselves are deliberately NOT override-checked this pass (Settings' recorder-based rebinding UI still edits only the primary). `views/KeyboardShortcutsSection.tsx` renders extras as read-only secondary `KeyComboChip`s next to the primary's click-to-rebind button. Vitest covers dispatch-matches-either-binding + override-doesn't-disable-extras + no-extraBindings-backward-compat; `keymap.spec.ts` gained both a live ⌘//⌘⇧+/ → palette-opens case and a Settings-renders-the-two-read-only-chips case. 7. [ ] [0021 — MCP dogfood gap closure](0021-mcp-dogfood-gap-closure.md) Phase 2: orchestrator-driven live MCP probing against the locked-down-enterprise use cases (the mandate names the orchestrator as the prober — self-driveable, exploratory; produces the next ranked gap list). Phase 3 judgments that need the owner surface as they're found. -8. [ ] Dev-loop instance guards (tech debt, owner-hit 2026-08-12 evening: THREE concurrent `mill.dev.app` instances in the dock, real crash risk on the 16GB machine) — two confirmed root causes, both get mechanical fixes: (a) `wails3 dev`'s Go-rebuild cycle relaunches the app WITHOUT killing the previous instance, so orphans accumulate one per rebuild during heavy agent waves — extend goal 0029's start-sweep into a per-rebuild reap (kill any existing `bin/mill.dev.app` process before the new launch; find the right hook in the Taskfile's dev target or wails3 dev's own lifecycle); (b) `task dev` ran twice concurrently (two backgrounded watchers, each with its own app+vite) — the dev target refuses to start when an instance is already running (vite-port 9245 check + wails3-dev process check, clear message naming the existing PID). This makes the standing never-two-data-sharing-instances rule ENFORCED instead of remembered. +8. [x] Dev-loop instance guards (tech debt, owner-hit 2026-08-12 evening: THREE concurrent `mill.dev.app` instances in the dock, real crash risk on the 16GB machine) — DELIVERED 2026-08-13, both mechanical fixes landed: (a) per-rebuild reap — `build/config.yml`'s `dev_mode.executes` gained a `type: blocking` `pkill -f "bin/mill.dev.app/Contents/MacOS/mill" || true` step right before the `primary` `wails3 task run` step (blocking steps re-run every reload cycle, confirmed directly against the vendored `github.com/atterpac/refresh` engine source — its own `Primary` case already SHOULD kill-then-restart via a process-group SIGKILL, but a live `task dev` session running during this item's own investigation was caught red-handed with two concurrent `mill.dev.app` processes, one orphaned into a foreign process group refresh's own tracking never reaped; root cause not fully pinned to one line since it's in a vendored third-party dependency, so this reap is an independent, pattern-based backstop rather than a patch to code this repo doesn't own — same shape Taskfile.yml's own start-of-session sweep already used); (b) `task dev` now refuses a second concurrent start — `internal/devguard` (a real Go package, `package main`, unit-tested: `guard_test.go` covers process-list parsing, the wails3-dev-process matcher incl. a real false-positive it caught and fixed against a differently-pathed sibling project, port-PID parsing, and message formatting) runs as the FIRST step of `Taskfile.yml`'s `dev:` task, checks for an already-running `wails3 dev` process for this exact repo, and exits non-zero naming the conflicting PID (+ any port occupancy as corroborating detail) before the destructive sweep steps can run — verified live against a genuinely running session (correctly detected + refused, naming the real PID). Manual-only registry entry added (`.claude/skills/run-mill/SKILL.md`) for what CI structurally can't prove: real per-rebuild-orphan-prevention across several live Go-triggered rebuilds, and a genuine second-terminal `task dev` invocation actually refusing to start. 9. [ ] Dock-bounce on parked approvals (small, unlocked by wails beta.6's Flash() gaining macOS support via NSApp requestUserAttention — PR #44's changelog finding; Mill calls Flash nowhere today) — the attention stack (goal 0023/ADR-0032's away-user layer) gains a one-shot dock bounce when an approval parks while the user is away; kernel attention-layer surface per ADR-0035 (same class as the dock badge), NOT a new composition path. Tiny: one call site in the existing NotifyPendingApproval flow + manual-only registry entry (real dock behavior isn't CI-testable). 10. [ ] resizable-table.spec.ts drag-timing flake, PROPER fix (3 confirmed recurrences POST-hardening: PR #24 original, #43's run, #44's run — the expect.poll hardening from PR #33's wave was insufficient) — the parallel-worker drag-timing race needs a structural fix: serialize the spec (test.describe.configure mode serial in its own worker), or replace the synthesized drag with keyboard-based column resize if the component supports it, or a deterministic wait on the drag handle's post-layout geometry. Not another timeout bump — three strikes means the approach changes. 11. [ ] 0030 second-pass linters (gocritic/prealloc/contextcheck/sqlclosecheck — named future work in goal 0028) + `.ls-lint.yml` gains a root `node_modules` ignore (gap found 2026-08-12: a stray root node_modules broke root-file-naming; tiny, rides this or any PR). diff --git a/internal/devguard/guard.go b/internal/devguard/guard.go new file mode 100644 index 00000000..9c3ecb60 --- /dev/null +++ b/internal/devguard/guard.go @@ -0,0 +1,186 @@ +// Package main implements devguard, the `task dev` concurrent-start +// check (docs/goals/BACKLOG.md Standing #8b, owner-hit 2026-08-12 +// evening: THREE concurrent mill.dev.app instances in the dock, a real +// crash risk on a 16GB machine). Root cause: `task dev` run a second +// time while a first session was already live -- the existing +// orphan-sweep (Taskfile.yml's `dev:` task, goal 0029) unconditionally +// kills whatever's on the Vite port and any leftover mill.dev.app +// process before starting, which is exactly correct for a genuine +// orphan (a SIGHUP'd terminal's leftover, goal 0029's own target) but +// WRONG for a second concurrent `task dev`: it would kill the FIRST +// session's live vite/app instead of refusing to start, leaving two +// half-torn-down dev loops running against the same data files +// (CLAUDE.md's own never-two-data-sharing-instances rule). +// +// devguard runs as the FIRST step of Taskfile.yml's `dev:` task, before +// the destructive sweep steps: it checks for an already-running +// `wails3 dev` process for THIS repo (the authoritative "is a session +// already live" signal -- matched on the config path Taskfile.yml +// always passes, not just the bare "wails3 dev" substring, so an +// unrelated Wails project's own dev loop elsewhere on the machine never +// false-positives) and exits non-zero, naming the conflicting PID, if +// one is found. Task aborts the whole `dev:` task on the first failing +// step (its own default), so the sweep below never runs in that case. +// +// A bare occupied Vite port with NO live `wails3 dev` process is +// deliberately NOT a block condition here -- that's exactly the +// orphaned-vite-from-a-SIGHUP'd-terminal case goal 0029's sweep already +// exists to clean up safely; blocking on the port alone would break +// that legitimate recovery path. The port is still checked and +// reported as corroborating detail in the refusal message when a live +// process IS found, per this item's own "check vite port + running +// wails3 dev process" spec -- just not as an independent trigger. +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "strconv" + "strings" +) + +// process is one line of `ps -axwwo pid=,command=` output. A plain +// struct (not tied to exec.Cmd) so parsing/decision logic below is +// unit-testable without actually running ps -- see guard_test.go. +type process struct { + pid int + command string +} + +// parseProcesses parses `ps -axwwo pid=,command=` output. Tolerant of +// the leading whitespace ps pads the pid field with; skips any line +// that doesn't start with a valid integer PID rather than failing the +// whole scan over one malformed line. +func parseProcesses(output string) []process { + var procs []process + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.SplitN(line, " ", 2) + pid, err := strconv.Atoi(fields[0]) + if err != nil { + continue + } + command := "" + if len(fields) == 2 { + command = strings.TrimSpace(fields[1]) + } + procs = append(procs, process{pid: pid, command: command}) + } + return procs +} + +// wailsDevMarker is the exact combined substring that identifies THIS +// repo's own `wails3 dev` invocation, spelled precisely the way +// Taskfile.yml's `dev:` task always invokes it (`wails3 dev -config +// ./build/config.yml -port ...`). Deliberately ONE combined string, not +// two independent "wails3 dev" + "build/config.yml" checks: a wails3 v3 +// project's default scaffold always names its config `build/config.yml` +// relative to ITS OWN root, so a bare AND of the two loose substrings +// would false-positive on a DIFFERENT wails3 project's own dev loop +// running elsewhere on the same machine (e.g. `-config +// ./other-project/build/config.yml` contains "build/config.yml" too). +// The combined "-config ./build/config.yml" substring only matches this +// repo's own relative invocation path, confirmed against a real +// false-positive this exact scenario produced in guard_test.go before +// being tightened to this shape. +const wailsDevMarker = "-config ./build/config.yml" + +// findWailsDevProcess returns the first process that looks like this +// repo's own already-running `wails3 dev` supervisor, excluding +// selfPID (devguard's own process never matches "wails3 dev" in +// practice, but excluding it keeps the function correct regardless). +// Returns nil if none is running. +func findWailsDevProcess(procs []process, selfPID int) *process { + for i := range procs { + p := procs[i] + if p.pid == selfPID { + continue + } + if strings.Contains(p.command, "wails3 dev") && strings.Contains(p.command, wailsDevMarker) { + return &p + } + } + return nil +} + +// parsePIDList parses `lsof -ti :` output -- one PID per line, +// empty when nothing is bound to the port. +func parsePIDList(output string) []int { + var pids []int + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if pid, err := strconv.Atoi(line); err == nil { + pids = append(pids, pid) + } + } + return pids +} + +func joinInts(ints []int) string { + strs := make([]string, len(ints)) + for i, n := range ints { + strs[i] = strconv.Itoa(n) + } + return strings.Join(strs, ", ") +} + +// blockedMessage formats the refusal Taskfile.yml's `dev:` task prints +// before exiting non-zero -- names the actual conflicting PID so the +// owner can act on it directly (kill it, or find its terminal) instead +// of guessing. Only called once devProc is known non-nil (main's own +// gate); portPIDs is optional corroborating detail. +func blockedMessage(devProc *process, portPIDs []int, port int) string { + var b strings.Builder + b.WriteString("task dev is already running -- Mill's own never-two-data-sharing-instances rule (CLAUDE.md) forbids a second concurrent dev loop.\n") + fmt.Fprintf(&b, " wails3 dev is already running (PID %d).\n", devProc.pid) + if len(portPIDs) > 0 { + fmt.Fprintf(&b, " Vite dev port %d is also bound (PID %s).\n", port, joinInts(portPIDs)) + } + b.WriteString("Stop the existing session first (kill the PID above, or Ctrl-C its terminal) before starting a new one.") + return b.String() +} + +func main() { + port := flag.Int("port", 9245, "the Vite dev-server port to check") + flag.Parse() + + // Deliberately exec.Command, not exec.CommandContext: this is a + // short-lived, one-shot CLI invocation (not a long-running server + // request) with no cancellation source to plumb through -- + // mirrors internal/adapters/procexec.go's own identical precedent + // and reasoning. Args are fully static (ps) or a parsed int flag + // formatted into a port spec (lsof, never untrusted/user-supplied + // text), not a shell-injection-shaped input. + psOutput, psErr := exec.Command("ps", "-axwwo", "pid=,command=").Output() //nolint:gosec,noctx // static args, one-shot CLI tool, no request context to plumb through + // lsof exits non-zero with empty output when nothing is bound to + // the port -- the common case, not a real error worth surfacing; + // its error is deliberately ignored here, only the (possibly empty) + // output matters. + lsofOutput, _ := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", *port)).Output() //nolint:gosec,noctx // port is a parsed int flag, not untrusted input; one-shot CLI tool + + if psErr != nil { + // Fails OPEN, not closed: a missing/broken `ps` on the host is + // a worse regression than the orphan-accumulation bug this + // guards against -- never block every dev-loop start over a + // tooling gap. + fmt.Fprintf(os.Stderr, "devguard: couldn't list processes (%v) -- skipping the concurrent-start check\n", psErr) + os.Exit(0) + } + + procs := parseProcesses(string(psOutput)) + devProc := findWailsDevProcess(procs, os.Getpid()) + if devProc == nil { + os.Exit(0) + } + portPIDs := parsePIDList(string(lsofOutput)) + fmt.Fprintln(os.Stderr, blockedMessage(devProc, portPIDs, *port)) + os.Exit(1) +} diff --git a/internal/devguard/guard_test.go b/internal/devguard/guard_test.go new file mode 100644 index 00000000..3ea0cc3d --- /dev/null +++ b/internal/devguard/guard_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "strings" + "testing" +) + +func TestParseProcesses(t *testing.T) { + t.Run("parses well-formed lines", func(t *testing.T) { + output := " 123 wails3 dev -config ./build/config.yml -port 9245\n456 /bin/zsh -c echo hi\n" + procs := parseProcesses(output) + if len(procs) != 2 { + t.Fatalf("expected 2 processes, got %d", len(procs)) + } + if procs[0].pid != 123 || procs[0].command != "wails3 dev -config ./build/config.yml -port 9245" { + t.Errorf("unexpected first process: %+v", procs[0]) + } + if procs[1].pid != 456 || procs[1].command != "/bin/zsh -c echo hi" { + t.Errorf("unexpected second process: %+v", procs[1]) + } + }) + + it := "skips blank lines and a line with no valid integer PID, without failing the whole scan" + t.Run(it, func(t *testing.T) { + output := "\n \nnotapid some command\n789 real process\n" + procs := parseProcesses(output) + if len(procs) != 1 || procs[0].pid != 789 { + t.Fatalf("expected exactly the one valid-PID line, got %+v", procs) + } + }) + + t.Run("a bare PID with no command still parses, with an empty command", func(t *testing.T) { + procs := parseProcesses("42\n") + if len(procs) != 1 || procs[0].pid != 42 || procs[0].command != "" { + t.Fatalf("unexpected result: %+v", procs) + } + }) + + t.Run("empty input parses to zero processes", func(t *testing.T) { + if procs := parseProcesses(""); len(procs) != 0 { + t.Fatalf("expected zero processes, got %+v", procs) + } + }) +} + +func TestFindWailsDevProcess(t *testing.T) { + t.Run("matches this repo's own wails3 dev invocation", func(t *testing.T) { + procs := []process{ + {pid: 1, command: "/bin/zsh -c some unrelated thing"}, + {pid: 2, command: "wails3 dev -config ./build/config.yml -port 9245"}, + } + found := findWailsDevProcess(procs, 999) + if found == nil || found.pid != 2 { + t.Fatalf("expected to find PID 2, got %+v", found) + } + }) + + t.Run("requires the -config marker too -- a bare 'wails3 build'/'wails3 task run' never matches", func(t *testing.T) { + procs := []process{ + {pid: 1, command: "wails3 build DEV=true"}, + {pid: 2, command: "wails3 task run"}, + } + if found := findWailsDevProcess(procs, 999); found != nil { + t.Fatalf("expected no match, got %+v", found) + } + }) + + t.Run("an unrelated project's own wails3 dev (different config path) never false-positives", func(t *testing.T) { + procs := []process{ + {pid: 1, command: "wails3 dev -config ./other-project/build/config.yml -port 5173"}, + } + if found := findWailsDevProcess(procs, 999); found != nil { + t.Fatalf("expected no match across a different config path, got %+v", found) + } + }) + + t.Run("excludes selfPID even if it happened to match (defensive)", func(t *testing.T) { + procs := []process{ + {pid: 42, command: "wails3 dev -config ./build/config.yml -port 9245"}, + } + if found := findWailsDevProcess(procs, 42); found != nil { + t.Fatalf("expected selfPID to be excluded, got %+v", found) + } + }) + + t.Run("returns nil when nothing is running", func(t *testing.T) { + if found := findWailsDevProcess(nil, 999); found != nil { + t.Fatalf("expected nil, got %+v", found) + } + }) +} + +func TestParsePIDList(t *testing.T) { + t.Run("parses one PID per line", func(t *testing.T) { + pids := parsePIDList("111\n222\n") + if len(pids) != 2 || pids[0] != 111 || pids[1] != 222 { + t.Fatalf("unexpected result: %+v", pids) + } + }) + + t.Run("empty output (nothing bound to the port) parses to zero PIDs", func(t *testing.T) { + if pids := parsePIDList(""); len(pids) != 0 { + t.Fatalf("expected zero PIDs, got %+v", pids) + } + }) + + t.Run("skips a malformed line rather than failing the whole scan", func(t *testing.T) { + pids := parsePIDList("333\nnotapid\n444\n") + if len(pids) != 2 || pids[0] != 333 || pids[1] != 444 { + t.Fatalf("unexpected result: %+v", pids) + } + }) +} + +func TestBlockedMessage(t *testing.T) { + t.Run("names the conflicting PID", func(t *testing.T) { + msg := blockedMessage(&process{pid: 282, command: "wails3 dev -config ./build/config.yml -port 9245"}, nil, 9245) + if !strings.Contains(msg, "PID 282") { + t.Errorf("expected message to name PID 282, got: %s", msg) + } + }) + + t.Run("includes the port PIDs as corroborating detail when present", func(t *testing.T) { + msg := blockedMessage(&process{pid: 282}, []int{111, 222}, 9245) + if !strings.Contains(msg, "111, 222") { + t.Errorf("expected message to list port PIDs, got: %s", msg) + } + if !strings.Contains(msg, "9245") { + t.Errorf("expected message to name the port, got: %s", msg) + } + }) + + t.Run("omits the port line entirely when nothing else is bound to it", func(t *testing.T) { + msg := blockedMessage(&process{pid: 282}, nil, 9245) + if strings.Contains(msg, "also bound") { + t.Errorf("expected no port line when portPIDs is empty, got: %s", msg) + } + }) +}