|
| 1 | +// Package main implements devguard, the `task dev` concurrent-start |
| 2 | +// check (docs/goals/BACKLOG.md Standing #8b, owner-hit 2026-08-12 |
| 3 | +// evening: THREE concurrent mill.dev.app instances in the dock, a real |
| 4 | +// crash risk on a 16GB machine). Root cause: `task dev` run a second |
| 5 | +// time while a first session was already live -- the existing |
| 6 | +// orphan-sweep (Taskfile.yml's `dev:` task, goal 0029) unconditionally |
| 7 | +// kills whatever's on the Vite port and any leftover mill.dev.app |
| 8 | +// process before starting, which is exactly correct for a genuine |
| 9 | +// orphan (a SIGHUP'd terminal's leftover, goal 0029's own target) but |
| 10 | +// WRONG for a second concurrent `task dev`: it would kill the FIRST |
| 11 | +// session's live vite/app instead of refusing to start, leaving two |
| 12 | +// half-torn-down dev loops running against the same data files |
| 13 | +// (CLAUDE.md's own never-two-data-sharing-instances rule). |
| 14 | +// |
| 15 | +// devguard runs as the FIRST step of Taskfile.yml's `dev:` task, before |
| 16 | +// the destructive sweep steps: it checks for an already-running |
| 17 | +// `wails3 dev` process for THIS repo (the authoritative "is a session |
| 18 | +// already live" signal -- matched on the config path Taskfile.yml |
| 19 | +// always passes, not just the bare "wails3 dev" substring, so an |
| 20 | +// unrelated Wails project's own dev loop elsewhere on the machine never |
| 21 | +// false-positives) and exits non-zero, naming the conflicting PID, if |
| 22 | +// one is found. Task aborts the whole `dev:` task on the first failing |
| 23 | +// step (its own default), so the sweep below never runs in that case. |
| 24 | +// |
| 25 | +// A bare occupied Vite port with NO live `wails3 dev` process is |
| 26 | +// deliberately NOT a block condition here -- that's exactly the |
| 27 | +// orphaned-vite-from-a-SIGHUP'd-terminal case goal 0029's sweep already |
| 28 | +// exists to clean up safely; blocking on the port alone would break |
| 29 | +// that legitimate recovery path. The port is still checked and |
| 30 | +// reported as corroborating detail in the refusal message when a live |
| 31 | +// process IS found, per this item's own "check vite port + running |
| 32 | +// wails3 dev process" spec -- just not as an independent trigger. |
| 33 | +package main |
| 34 | + |
| 35 | +import ( |
| 36 | + "flag" |
| 37 | + "fmt" |
| 38 | + "os" |
| 39 | + "os/exec" |
| 40 | + "strconv" |
| 41 | + "strings" |
| 42 | +) |
| 43 | + |
| 44 | +// process is one line of `ps -axwwo pid=,command=` output. A plain |
| 45 | +// struct (not tied to exec.Cmd) so parsing/decision logic below is |
| 46 | +// unit-testable without actually running ps -- see guard_test.go. |
| 47 | +type process struct { |
| 48 | + pid int |
| 49 | + command string |
| 50 | +} |
| 51 | + |
| 52 | +// parseProcesses parses `ps -axwwo pid=,command=` output. Tolerant of |
| 53 | +// the leading whitespace ps pads the pid field with; skips any line |
| 54 | +// that doesn't start with a valid integer PID rather than failing the |
| 55 | +// whole scan over one malformed line. |
| 56 | +func parseProcesses(output string) []process { |
| 57 | + var procs []process |
| 58 | + for _, line := range strings.Split(output, "\n") { |
| 59 | + line = strings.TrimSpace(line) |
| 60 | + if line == "" { |
| 61 | + continue |
| 62 | + } |
| 63 | + fields := strings.SplitN(line, " ", 2) |
| 64 | + pid, err := strconv.Atoi(fields[0]) |
| 65 | + if err != nil { |
| 66 | + continue |
| 67 | + } |
| 68 | + command := "" |
| 69 | + if len(fields) == 2 { |
| 70 | + command = strings.TrimSpace(fields[1]) |
| 71 | + } |
| 72 | + procs = append(procs, process{pid: pid, command: command}) |
| 73 | + } |
| 74 | + return procs |
| 75 | +} |
| 76 | + |
| 77 | +// wailsDevMarker is the exact combined substring that identifies THIS |
| 78 | +// repo's own `wails3 dev` invocation, spelled precisely the way |
| 79 | +// Taskfile.yml's `dev:` task always invokes it (`wails3 dev -config |
| 80 | +// ./build/config.yml -port ...`). Deliberately ONE combined string, not |
| 81 | +// two independent "wails3 dev" + "build/config.yml" checks: a wails3 v3 |
| 82 | +// project's default scaffold always names its config `build/config.yml` |
| 83 | +// relative to ITS OWN root, so a bare AND of the two loose substrings |
| 84 | +// would false-positive on a DIFFERENT wails3 project's own dev loop |
| 85 | +// running elsewhere on the same machine (e.g. `-config |
| 86 | +// ./other-project/build/config.yml` contains "build/config.yml" too). |
| 87 | +// The combined "-config ./build/config.yml" substring only matches this |
| 88 | +// repo's own relative invocation path, confirmed against a real |
| 89 | +// false-positive this exact scenario produced in guard_test.go before |
| 90 | +// being tightened to this shape. |
| 91 | +const wailsDevMarker = "-config ./build/config.yml" |
| 92 | + |
| 93 | +// findWailsDevProcess returns the first process that looks like this |
| 94 | +// repo's own already-running `wails3 dev` supervisor, excluding |
| 95 | +// selfPID (devguard's own process never matches "wails3 dev" in |
| 96 | +// practice, but excluding it keeps the function correct regardless). |
| 97 | +// Returns nil if none is running. |
| 98 | +func findWailsDevProcess(procs []process, selfPID int) *process { |
| 99 | + for i := range procs { |
| 100 | + p := procs[i] |
| 101 | + if p.pid == selfPID { |
| 102 | + continue |
| 103 | + } |
| 104 | + if strings.Contains(p.command, "wails3 dev") && strings.Contains(p.command, wailsDevMarker) { |
| 105 | + return &p |
| 106 | + } |
| 107 | + } |
| 108 | + return nil |
| 109 | +} |
| 110 | + |
| 111 | +// parsePIDList parses `lsof -ti :<port>` output -- one PID per line, |
| 112 | +// empty when nothing is bound to the port. |
| 113 | +func parsePIDList(output string) []int { |
| 114 | + var pids []int |
| 115 | + for _, line := range strings.Split(output, "\n") { |
| 116 | + line = strings.TrimSpace(line) |
| 117 | + if line == "" { |
| 118 | + continue |
| 119 | + } |
| 120 | + if pid, err := strconv.Atoi(line); err == nil { |
| 121 | + pids = append(pids, pid) |
| 122 | + } |
| 123 | + } |
| 124 | + return pids |
| 125 | +} |
| 126 | + |
| 127 | +func joinInts(ints []int) string { |
| 128 | + strs := make([]string, len(ints)) |
| 129 | + for i, n := range ints { |
| 130 | + strs[i] = strconv.Itoa(n) |
| 131 | + } |
| 132 | + return strings.Join(strs, ", ") |
| 133 | +} |
| 134 | + |
| 135 | +// blockedMessage formats the refusal Taskfile.yml's `dev:` task prints |
| 136 | +// before exiting non-zero -- names the actual conflicting PID so the |
| 137 | +// owner can act on it directly (kill it, or find its terminal) instead |
| 138 | +// of guessing. Only called once devProc is known non-nil (main's own |
| 139 | +// gate); portPIDs is optional corroborating detail. |
| 140 | +func blockedMessage(devProc *process, portPIDs []int, port int) string { |
| 141 | + var b strings.Builder |
| 142 | + 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") |
| 143 | + fmt.Fprintf(&b, " wails3 dev is already running (PID %d).\n", devProc.pid) |
| 144 | + if len(portPIDs) > 0 { |
| 145 | + fmt.Fprintf(&b, " Vite dev port %d is also bound (PID %s).\n", port, joinInts(portPIDs)) |
| 146 | + } |
| 147 | + b.WriteString("Stop the existing session first (kill the PID above, or Ctrl-C its terminal) before starting a new one.") |
| 148 | + return b.String() |
| 149 | +} |
| 150 | + |
| 151 | +func main() { |
| 152 | + port := flag.Int("port", 9245, "the Vite dev-server port to check") |
| 153 | + flag.Parse() |
| 154 | + |
| 155 | + // Deliberately exec.Command, not exec.CommandContext: this is a |
| 156 | + // short-lived, one-shot CLI invocation (not a long-running server |
| 157 | + // request) with no cancellation source to plumb through -- |
| 158 | + // mirrors internal/adapters/procexec.go's own identical precedent |
| 159 | + // and reasoning. Args are fully static (ps) or a parsed int flag |
| 160 | + // formatted into a port spec (lsof, never untrusted/user-supplied |
| 161 | + // text), not a shell-injection-shaped input. |
| 162 | + psOutput, psErr := exec.Command("ps", "-axwwo", "pid=,command=").Output() //nolint:gosec,noctx // static args, one-shot CLI tool, no request context to plumb through |
| 163 | + // lsof exits non-zero with empty output when nothing is bound to |
| 164 | + // the port -- the common case, not a real error worth surfacing; |
| 165 | + // its error is deliberately ignored here, only the (possibly empty) |
| 166 | + // output matters. |
| 167 | + 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 |
| 168 | + |
| 169 | + if psErr != nil { |
| 170 | + // Fails OPEN, not closed: a missing/broken `ps` on the host is |
| 171 | + // a worse regression than the orphan-accumulation bug this |
| 172 | + // guards against -- never block every dev-loop start over a |
| 173 | + // tooling gap. |
| 174 | + fmt.Fprintf(os.Stderr, "devguard: couldn't list processes (%v) -- skipping the concurrent-start check\n", psErr) |
| 175 | + os.Exit(0) |
| 176 | + } |
| 177 | + |
| 178 | + procs := parseProcesses(string(psOutput)) |
| 179 | + devProc := findWailsDevProcess(procs, os.Getpid()) |
| 180 | + if devProc == nil { |
| 181 | + os.Exit(0) |
| 182 | + } |
| 183 | + portPIDs := parsePIDList(string(lsofOutput)) |
| 184 | + fmt.Fprintln(os.Stderr, blockedMessage(devProc, portPIDs, *port)) |
| 185 | + os.Exit(1) |
| 186 | +} |
0 commit comments