waiter-pgrep-matches-its-own-command-line · v1.0.0 · 2026-09-17 · sha256 d36ceeb23abdec61
waiter-pgrep-matches-its-own-command-line v1.0.0A
Immutable. This exact content is served forever at /api/v1/blob/d36ceeb23abdec61.
--- name: waiter-pgrep-matches-its-own-command-line description: | You are about to trust a process search on a machine several sessions share: a wait loop that spins until some pattern stops matching, a liveness check before deleting a worktree or killing a run, or a "is the other session still going?" question answered from `ps`. The harness runs every shell command inside a wrapper shell whose argv carries the whole command text, so your pattern is present in the argv of the shell doing the searching, and in every other session's wrapper that merely mentions the same words. Use when a wait loop never exits, when a check reports something busy that you believe is idle, when you are about to kill or skip something because a process list said it was in use, or when you are deciding from `ps` whether a peer session is alive. The search finds the searcher, and a bare count cannot tell you which hit is the real work. version: 1.0.0 date: 2026-09-17 author: wan-huiyan disable-model-invocation: true --- # A Waiter's Process Search Matches Its Own Command Line ## Problem You want to know whether something is still running — a peer session's test leg, a build, your own backgrounded gate run — so you search the process list for it. **The search is itself a process, and on this harness its command line contains the pattern you are searching for.** Every shell command the Bash tool runs is wrapped in a shell invoked as `/bin/zsh -c '<the whole command text>'`. That text includes your pattern. So: - `pgrep -f "<pattern>"` matches the wrapper shell running that very `pgrep`. - A wait loop never exits. `until ! pgrep -f "xcodebuild test -scheme X"; do sleep 30; done` is waiting for itself to stop existing. - On a shared machine it also matches **every other session's wrapper** that merely mentions the same words — including a session that only printed the command, or grepped for it, and never ran it. **Measured, 2026-09-17.** A session checked whether its own gate legs were still going (the suite names below are generalised; the shape is what matters): ``` pgrep -fl 'pytest (unit|api|docs|e2e)' | head -3 ``` The first line it returned was not a test process: ``` 31853 /bin/zsh -c source /Users/me/.claude/shell-snapshots/snapshot-zsh-….sh 2>/dev/null || true && … ``` A wrapper shell, carrying the pattern in its own argv. With `| head -3` and no `-l`, that line is indistinguishable from a real hit — and with `pgrep -q` or `| wc -l` it is invisible. **It fails in both directions, and the noisy one is not the dangerous one.** | Reading | What it does to you | |---|---| | False BUSY | The waiter never exits. A cleanup skips a worktree whose owner died hours ago. You decline to start a leg because "another session is running it". | | False ALIVE | You report a run still in progress after it finished, because what matched was a wrapper — or a PID the OS has since recycled. | | False "two of mine" | Double counting: the work plus the shell that went looking for it. | The false-busy readings are the ones nothing corrects, because a waiter that is still waiting looks exactly like a job that is still running. ## Context / Trigger Conditions Reach for this when: 1. a wait loop has not exited long after the thing it waits for should have finished — especially if the log it should be following stopped growing; 2. a liveness check says busy while nothing is producing output (the log's mtime is not moving, CPU is idle); 3. you are about to kill, delete or skip something because a process search said it was in use; 4. you are deciding whether a peer session is alive from `ps` alone; 5. a bracketed pattern is in play and the regex has more than one alternative; 6. a timing gate went red on a machine several sessions share. ## Solution ### Step 1 — Print what matched; never trust a count ```bash pgrep -fl "<pattern>" # -l, so you can SEE each hit ps -o pid,ppid,etime,command -p <pid> # then read the ones you kept ``` A hit whose command begins `/bin/zsh -c` is a wrapper, not the work. A bare `pgrep -q`, `| wc -l` or `| head -3` cannot make that distinction, and each of them turns a wrapper into a confident yes. ### Step 2 — The bracket trick protects exactly one alternative `grep -E "[x]codebuild"` stops that one word matching the grep's own line. It does nothing for the rest of the pattern: a waiter's command line usually also carries the command it is queued to run next, so it can still self-match on a **different alternative of the same regex**. Either give every alternative the same treatment, or stop matching text altogether (Steps 4 and 5). ### Step 3 — Filter on `args`, never on `comm` `ps -eo comm,args` truncates `comm` for long interpreter paths, so a filter on that first field can match **zero** processes while five real ones are running — and zero reads as "all clear". Match on `args`. ### Step 4 — Decide ownership by working directory, not by argv text ```bash lsof -a -p <pid> -d cwd -Fn # where the process actually is ps -o lstart= -p <pid> # when it started ``` Argv *shape* — `-q -m not slow` versus `-m not slow -q` — is a fact about someone's typing habits, not about ownership. On a fleet doing the same work, a command line you would swear is yours belongs to a peer. The cwd says which worktree it is in, and a process that started at a moment you launched nothing cannot be yours: your own transcript records when you launched what. ### Step 5 — For status, use a PID you recorded, not a pattern Capture the PID at launch (`$!`, or read it once from `ps` and keep it), then ask `ps -p <pid>` from then on. Pair it with the start time: a PID can be **recycled** by the OS, so a bare "the PID still exists" is not "my job is still running" — `ps -o lstart=,command= -p <pid>` settles both at once. ### Step 6 — Kill by PID, never by pattern `pkill -f "<anything>"` is safe on a shared machine exactly until a second session runs a command whose line is a superset or a subset of yours. Specificity buys nothing against a fleet doing the same work. Identify read-only first (Steps 1 and 4), then kill the one PID you have traced to your own launch. Better still, do not kill: a contended run is slower, never wrong, and a stale one exits on its own. ### Step 7 — Prefer a signal the work itself writes The reliable completion signal is not a process list. Have the run write a sentinel as its last line, and watch that file's contents and mtime. It cannot match itself, it survives the waiter being killed, and it distinguishes "finished" from "died" — which no process search can do, because both look like absence. ## Verification **Run the waiter's condition once while the resource is definitely busy and confirm it reports busy. A waiter that cannot report busy is not a waiter** — and a condition that can only ever say "clear" passes every test you were going to run. Then the self-match test, which takes one command: ```bash pgrep -fl "<pattern>" # everything that matched pgrep -fl "<pattern>" | grep -v ' /bin/zsh -c ' # with wrappers removed ``` If the second is empty and the first was not, every hit was a shell, and the answer you were about to act on was about your own command. ## Example **The gate check above.** The session read the first three hits of `pgrep -fl 'pytest (unit|api|docs|e2e)'` to decide whether its legs were still running. Hit one was a `/bin/zsh -c` wrapper carrying the pattern. The question it was asking — *is the leg still going?* — was answerable from the log file it was already tailing, whose last line and mtime say both whether the run is alive and how far it has got. **The loop that cannot end.** `until ! pgrep -f "xcodebuild test -scheme X"; do sleep 30; done` never exits on this harness. The waiting shell's own argv contains `xcodebuild test -scheme X`, so the condition it is polling is guaranteed true for as long as the waiter exists. Nothing errors, nothing times out; the session simply sleeps until something else kills it. ## Notes - **`| grep -v pgrep` does not fix this.** It removes the `pgrep` process itself. It does not remove the wrapper shell that carries the pattern — those are two different processes, and only the second one looks like a real hit. - **A slow leg on a shared machine is contention before it is your diff.** Two sessions' test legs inflate each other's wall clock; one measurement on 2026-09-02 recorded a **6.8× slowdown with identical pass counts** — pure machine load. Read `uptime` and the process list before filing a timing failure against your branch. - **Absence is not death.** A process search that finds nothing cannot tell "finished", "killed" and "never started" apart. Only an artefact the work wrote can. - Sister skills: `git-auto-maintenance-recurring-worktree-index-lock` decides whether a lock's holder is alive and prints a process search to do it; `safe-bulk-worktree-branch-cleanup` asks the same question before removing a worktree; `subagent-external-wait-orchestrator-takeover` is the layer above — whether the polling should be happening in that session at all. ## References - `pgrep(1)`, `pkill(1)` — pattern matching is against the full argv with `-f`, which is what makes the searcher a candidate. - `ps(1)` — `comm` is truncated; `args` is not. - `lsof(8)` — `-d cwd` is how you learn which checkout a process is working in.