Skip to content

fix(bin): resolve BSD stat by path on Darwin (Homebrew coreutils silently kills the watcher) - #2906

Open
ashield wants to merge 1 commit into
kunchenguid:mainfrom
ashield:fix/darwin-bsd-stat-resolution
Open

fix(bin): resolve BSD stat by path on Darwin (Homebrew coreutils silently kills the watcher)#2906
ashield wants to merge 1 commit into
kunchenguid:mainfrom
ashield:fix/darwin-bsd-stat-resolution

Conversation

@ashield

@ashield ashield commented Aug 24, 2026

Copy link
Copy Markdown

On a Mac with Homebrew coreutils installed, /opt/homebrew/opt/coreutils/libexec/gnubin precedes /usr/bin on PATH. uname still reports Darwin, but stat is GNU coreutils 9.x.

Every [ "$(uname)" = Darwin ] && stat -f ... branch then hits precisely the failure bin/fm-watch.sh already documents for the stat -f || stat -c fallback form — GNU stat -f is filesystem stat, so it writes a partial filesystem dump to stdout before failing, and arithmetic under set -u aborts on the stray token.

The existing comment diagnoses the hazard correctly but keys the decision on the OS rather than on which stat binary wins. uname cannot answer that question.

Reproduction

Against main, with gnubin ahead of /usr/bin:

mtime returned: [  File: "bin/fm-watch.sh"
    ID: 100000d0000001a Namelen: ?       Type: apfs
Block size: 4096       Fundamental block size: 4096
Blocks: Total: 242837545  Free: 54564770   Available: 54564770
Inodes: Total: 2201063258 Free: 2182590800]
arithmetic under set -u: bash: File: unbound variable

With this change, same PATH:

mtime returned: [1787532376]
arithmetic under set -u: 0

Observed impact

Found on a real remote second-mate host, not synthetically. Every fm-watch.sh cycle exited 1 within a second, beacon_age stayed pinned at 999999, and .claude-autoarm-epoch eventually recorded outcome=failed-suppressed:

arm_pid=79260  watcher_pid=79278  exit_code=1  reason=nonzero-exit  beacon_age=999999
arm_pid=79613  watcher_pid=79627  exit_code=1  reason=nonzero-exit  beacon_age=999999
... 18 consecutive identical rows

The failure mode is quiet, which is what makes it worth fixing: the agent still answers direct messages, so the home looks healthy. Only event-driven supervision is dead — the fleet discovers it when routed work stops being noticed.

It is also easy to miss when debugging over SSH, because a non-interactive SSH PATH usually has no gnubin. Arming by hand returns exit_code=0 while the GUI-session agent keeps failing.

The change

Resolves BSD stat by path at all 39 call sites across 23 files in bin/, overridable via FM_BSD_STAT for hosts that keep BSD stat elsewhere:

"${FM_BSD_STAT:-/usr/bin/stat}" -f %m "$1"
  • stat -c (GNU) branches are untouched.
  • The two probe-style sites (fm-test-isolation-proof.sh, fm-x-lib.sh) behave identically on Linux, where /usr/bin/stat is the same binary bare stat already resolved to.
  • fm-fleet-snapshot.sh is already stat_style-guarded.
  • Extended the fm-watch.sh comment to record why OS detection is necessary but not sufficient.

Verification

  • bin/fm-lint.sh clean (ShellCheck 0.11.0 pinned, actionlint 1.7.12, 3 workflows valid)
  • fm-watch-arm.test.sh, fm-watcher-lock.test.sh, fm-watch-checkpoint.test.sh all pass

`uname` tells you the OS, not which `stat` wins on PATH. On a Mac with
Homebrew coreutils, /opt/homebrew/opt/coreutils/libexec/gnubin precedes
/usr/bin, so `uname` reports Darwin while `stat` is GNU coreutils 9.x.

Every `[ "$(uname)" = Darwin ] && stat -f ...` branch then hits exactly the
failure bin/fm-watch.sh already documents for the `stat -f || stat -c`
fallback form: GNU `stat -f` is *filesystem* stat, so it writes a partial
filesystem dump to stdout before failing, and arithmetic under `set -u`
aborts on the stray token.

Reproduced against origin/main with gnubin ahead of /usr/bin:

    mtime returned: [  File: "bin/fm-watch.sh"
        ID: 100000d0000001a Namelen: ?       Type: apfs
    Block size: 4096       Fundamental block size: 4096
    ...]
    arithmetic under set -u: bash: File: unbound variable

With the fix, same PATH:

    mtime returned: [1787532376]
    arithmetic under set -u: 0

Observed impact: on such a host every `fm-watch.sh` cycle exits 1 within a
second, `beacon_age` stays 999999, and autoarm eventually records
`outcome=failed-suppressed`. Event-driven supervision is silently dead
while the agent still answers direct messages, so a fleet only discovers it
when routed work stops being noticed.

Resolves BSD stat by path at all 39 call sites across 23 files in bin/,
overridable via FM_BSD_STAT for hosts that keep BSD stat elsewhere. The
`stat -c` (GNU) branches are untouched, and the two probe-style sites
behave identically on Linux, where /usr/bin/stat is the same binary bare
`stat` already resolved to.
@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

The PR appears safe to merge; the changed calls consistently select the intended BSD stat implementation on Darwin while preserving existing Linux behavior and explicit override support.

No concrete changed-code failure remains after checking the child-shell boundary, override propagation, platform branches, and repository rules.

Reviews (1): Last reviewed commit: "fix(bin): resolve BSD stat by path on Da..." | Re-trigger Greptile

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.

1 participant