Skip to content

fix: make StorOps actually work on macOS -- packaging, memory, and APFS duplicate counting - #24

Merged
tzzs merged 5 commits into
masterfrom
claude/skills-macos-performance-536dd5
Sep 20, 2026
Merged

tzzs merged 5 commits into
masterfrom
claude/skills-macos-performance-536dd5

Conversation

@tzzs

@tzzs tzzs commented Sep 20, 2026

Copy link
Copy Markdown
Owner

What this is

An audit of StorOps' runtime behaviour on macOS, and the fixes for what it
turned up. Short version: it did not meet expectations, and one defect made
an installed StorOps completely non-functional on every platform.

All numbers below are measured on a real Mac (macOS 27, APFS, no gdu on
PATH -- so the du backend is what runs, which is the default on any Mac
that has not gone out of its way to install gdu).

before after
scan ~ (157 GB / 2.44M files) 104.5 s 64.0 s
scan ~ peak RSS 2,613 MB 17.6 MB
scan ~ du output rows / isdir() calls 2,442,932 / ~2.44M ~75 / 0
scan / 164.4 s 98.5 s
scan / reported size of /System 95.0 G (double-counted) 72.9 G
cleanup plan 6.32 s / 9 probes 2.80 s / 15 probes
macOS path identification (spot check) 7/18 19/20
pip install storops every command errors works

Top-15 ordering is unchanged throughout; individual sizes match to within
the churn of live caches being written during the runs.

The five root causes

1. rules/*.yaml were not in the wheel. package-data patterns can
only match files already inside a package directory, and rules/ lives at
the repo root -- so storops = ["rules/*.yaml"] matched nothing and an
installed StorOps died with "could not locate the rules/ directory" on
every command. Invisible to CI because pip install -e . leaves the
repo-root rules/ reachable through core/rules.py's development-layout
candidate. rules/ stays where it is (SKILL.md, README.md and
rules/README.md all point at that path); it is mapped in as the
storops.rules package instead.

2. requires-python = ">=3.11" while a stock macOS ships 3.9 as
python3 and RHEL 9 ships 3.9. The whole suite passes unmodified on 3.9,
so this was a hard stop exactly where StorOps is most useful -- a machine
nobody has set a modern toolchain up on. Floor lowered to 3.9.

3. BSD du's -a and -d depth are mutually exclusive
(usage: du [-a | -s | -d depth]), so the BSD branch always ran -a at
unbounded depth -- even for scan/inspect, which only ever want
directories one level down. 2.44M output rows and an os.path.isdir() per
row, to produce 15. Both flavors report directories only when -a is
omitted and accept a native depth limit, so the fix is to stop asking for
file-level rows the caller never wanted. The listing was also captured
whole via capture_output=True and then splitlines()'d; it is streamed
now. StorOps is most likely to run on a machine that is already out of disk
and under memory pressure, so holding 300 MB of text was the wrong trade.

4. APFS firmlinks made scan / wrong. /Users and
/System/Volumes/Data/Users are literally the same directory -- same
st_dev, same st_ino, neither a symlink -- because / is the sealed
System volume with firmlinks projecting the writable Data volume into it.
du / walks the user's entire home twice. du -x cannot help: a volume
group reports one st_dev throughout, so there is no device boundary to
stop at. Darwin's fcntl(F_GETPATH) is the only thing that reports a
firmlink's real identity (os.path.realpath does not resolve them); a
pre-pass uses it to find all 18 firmlink entry points under / in 0.07 s
and prunes them before du is handed them. The Data volume's contribution
drops from du's 309 G to its actual 3.5 G.

5. CI never ran the configuration real users have. The macOS job
installs gdu, so BSD du was never under test -- which is how all of the
above shipped. Now: a 3.9/3.13 matrix, a second macOS job that asserts gdu
is absent and that get_scan_backend() really picked Du, and a job that
builds the wheel and runs commands from a venv where the source tree is
unreachable.

Design notes for the reviewer

Depth-1 children are sized as independent du -s processes. This is
what makes alias pruning possible at all (du has no way to be told "you
already counted that directory under another name"), and it also removes
the single-threaded-du bottleneck: 99.9 s → 66.0 s at 8 workers. 16
workers gave nothing back (70.7 s) -- the walk is bound by filesystem
metadata I/O, not CPU. Falls back to a single du -d 1 past 512 children,
where process spawns stop paying for themselves.

Known trade-off, documented at the call site: N separate du runs
cannot dedupe a hardlinked file the way one run can. Checked against
du -d 1 on a real home directory, top-15 ordering was identical and every
size matched within live-cache churn. This is why it is a depth-1-only path
rather than the default everywhere.

Accepted limitation: search still walks at arbitrary depth through
plain du, so on a macOS root it can still double-count. Pruning covers
scan/inspect.

SKILL.md gains a rule telling the agent not to present macOS
per-directory sizes as something that should add up to the volume's used
space: firmlinks are pruned now, but APFS block sharing (file clones, Time
Machine local snapshots) remains, and no per-path size can attribute shared
blocks to one path.

Two rules are deliberately never offered for deletion despite sitting
next to things that are. Xcode Archives are shipping artifacts and the only
way to symbolicate crash reports from a released build (deletable: false).
CoreSimulator Devices are classified high risk, so cleanup plan identifies
but never proposes them -- deleting those directories by hand leaves
CoreSimulator's own device index dangling, and xcrun simctl delete unavailable is the right tool.

Not in this PR

Six rules (macos-user-ssh, macos-user-documents-desktop, their Linux and
Windows counterparts, and downloads-folder) set category: unknown to
force a CHECK recommendation. recommended_action() branches on that
category first and returns "No identification rule matched this path", so
asking "is it safe to delete ~/.ssh?" reports that nothing matched and hides
the rule's own consequence text ("can lock the user out of every host it has
keys for"). The outcome is still safe -- nothing gets deleted -- but the
explanation the agent relays is wrong, and SKILL.md tells the agent to relay
Category and Consequence verbatim. Fixing it means touching risk.py, whose
docstring says the safety model has "no room for creative changes", so it is
left for a focused change of its own.

Verification

229 tests pass on Python 3.9, 3.12 and 3.13. The wheel installs into a clean
3.9 venv and identify / scan / cleanup plan all work end to end through
the installed console script.

🤖 Generated with Claude Code

tzzs and others added 5 commits September 20, 2026 20:01
… 3.9

Two defects that only ever surfaced in an installed StorOps, and were
therefore invisible to every test: CI installs with `pip install -e .`,
which leaves the repo-root rules/ directory reachable via core/rules.py's
development-layout candidate.

The wheel shipped no rule files at all. `[tool.setuptools.package-data]`
declared `storops = ["rules/*.yaml"]`, but package-data patterns can only
match files already inside a package directory and rules/ lives at the
repo root -- so the pattern matched nothing, and every command in an
installed storops died with "could not locate the rules/ directory".
rules/ stays at the root (SKILL.md and README.md point at it by that
path, and rules/README.md documents it as where to add rules); it is now
mapped in as the `storops.rules` package instead, which needs an explicit
`packages` list because `packages.find` cannot discover a package outside
`where`. test_packaging.py keeps that list from going stale.

requires-python demanded 3.11 for no reason: a stock macOS ships 3.9 as
`python3`, RHEL 9 ships 3.9, and the whole suite passes unmodified there.
StorOps is most useful on a machine nobody has set a modern toolchain up
on yet, so that was a hard stop exactly where it hurt most.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured on a real Mac (macOS 27, APFS, no gdu, so the du backend is what
runs), `storops scan ~` over a 157GB/2.44M-file home directory:

                        before      after
  wall clock            104.5s      64.0s
  peak RSS            2,613 MB     17.6 MB
  du output rows     2,442,932        ~75
  os.path.isdir()     ~2.44M           0

and `storops scan /`: 164.4s -> 98.5s, with /System dropping from 95.0G
to 72.9G as the duplicate counting went away. Top-15 ordering is
unchanged throughout; sizes match to within the churn of live caches
being written during the runs.

Three separate causes.

BSD du's -a and -d depth are mutually exclusive (`usage: du [-a | -s | -d
depth]`), so the BSD branch always ran `-a` at unbounded depth -- even
for scan/inspect, which only ever want directories one level down. Both
flavors report directories only when `-a` is omitted AND accept a native
depth limit, so the fix is simply not to ask for file-level rows when the
caller did not want them. That alone is the 2.44M rows -> ~75.

The whole listing was captured with subprocess.run(capture_output=True)
and then splitlines()'d, holding hundreds of MB of text plus the list
built from it. Now streamed line by line through Popen. StorOps is most
likely to be run on a machine that is already out of disk and under
memory pressure, so that was exactly the wrong trade.

`/Users` and `/System/Volumes/Data/Users` are literally the same
directory -- same st_dev, same st_ino, neither one a symlink -- because
`/` is the sealed System volume with firmlinks projecting the writable
Data volume into it. `du /` therefore walks the user's entire home twice,
and `du -x` cannot help: a volume group reports one st_dev throughout, so
there is no device boundary to stop at. Darwin's fcntl(F_GETPATH) is the
only thing that reports a firmlink's real identity (os.path.realpath does
not resolve them); a pre-pass uses it to find all 18 firmlink entry
points under `/` in 0.07s and prunes them before du is handed them. The
Data volume's contribution drops from du's 309G to its actual 3.5G.

Sizing depth-1 children as independent `du -s` processes is what makes
the pruning possible and also removes the single-threaded-du bottleneck:
99.9s -> 66.0s at 8 workers (16 gave nothing back, the walk being bound
by filesystem metadata I/O). Known trade-off, documented at the call
site: N separate du runs cannot dedupe a hardlink the way one run can.

SKILL.md gains a rule about macOS totals: firmlinks are now pruned, but
APFS block sharing (clones, Time Machine local snapshots) remains and no
per-path size can attribute shared blocks to one path, so the agent must
not present per-directory sizes as something that should add up to the
volume's used space.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sizing one probe path means walking its whole subtree, so a cleanup plan
is almost entirely time spent waiting on filesystem metadata -- and all
of it was serialized. Measured on a real Mac: 6.32s for nine probes, down
to 2.80s.

Opt-in per backend rather than assumed. The Windows native backend resets
per-call state on `self` (its warnings list) and already splits its own
walk across threads internally, so sizing several paths at once through
it would both clobber that state and oversubscribe the disk. DuBackend
and GduBackend declare `path_size_is_concurrent`; anything that does not
keeps the sequential path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On a Mac used for development the largest reclaimable directories were
all classified unknown/critical, which meant StorOps could surface tens
of GB in a scan and had nothing to say about any of it. Identification of
typical macOS paths goes from 7/18 to 19/20 in a spot check.

Added: Xcode DerivedData, DeviceSupport and Archives; CoreSimulator
Devices and Caches; the Homebrew cache; ~/Library/Logs. Plus Gradle and
Cargo, which were missing from the rule base on every platform.

Two of these are deliberately never offered for deletion despite sitting
beside things that are. Xcode Archives are shipping artifacts and the
only way to symbolicate crash reports from a released build, so they are
deletable: false. CoreSimulator Devices are classified high risk -- they
are identified, but `cleanup plan` will not propose them, because
deleting those directories by hand leaves CoreSimulator's own device
index dangling; `xcrun simctl delete unavailable` is the right tool and
the rule's notes say so.

Also corrects rules/README.md and macos.yaml's header, both of which
still claimed the ai-models/applications/caches rule files were
Windows-token-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every defect fixed in this branch was invisible to CI, and each one had
the same shape: the job did not exercise the configuration real users
run.

The macOS job installs gdu, so BSD `du` -- the backend on any Mac that
has not gone out of its way to install gdu -- was never under test. That
is where macOS' own quirks live. A second macOS job now asserts gdu is
absent and that get_scan_backend() really did pick Du.

Only 3.11 was tested, while the declared floor is now 3.9 (a stock macOS
`python3`) and 3.13 is what a new machine gets. Both are in the matrix;
the middle of the range is left to those two to bracket.

`pip install -e .` leaves the repo-root rules/ directory reachable, so no
job could tell whether the built distribution actually shipped the rule
files. It did not. A third job builds the wheel, installs it into a venv
where the source tree cannot be reached, and runs real commands through
the installed console script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tzzs
tzzs merged commit ec4484e into master Sep 20, 2026
8 checks passed
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