Codegraff v0.2.4: from one replace surface to a daemon that doesn't hang

Rach Pradhan · 8 min read

muonry 0.2.4 is out.

Five releases since 0.2.0, all on the same major. Replace runs 4.92× faster than sed and stays correct on read-only files. Parallel meta_search no longer hangs. The memo file stops growing past 4 MiB instead of silently dropping reads at 16. The frecency cache stops at 5000 paths instead of climbing forever. And muonry --changelog now prints the release timeline inline, so agents can tell you what changed without leaving the terminal.

This post walks the whole 0.2.0 → 0.2.4 arc. The first half is the unified-replace story, unchanged from 0.2.0. The second half is what happened next.

TL;DR

  • 0.2.0. One replace surface across CLI and MCP. In-place by default, 4.92× faster than sed, mode and xattr preserved.
  • 0.2.1. Memo file auto-compacts past 4 MiB. Frecency tracker caps at 5000 entries with LRU eviction.
  • 0.2.2. Per-iteration ArenaAllocator protects the line read and JSON parse from any future leak.
  • 0.2.3. cancel_flag and truncated_any move to a per-call SearchCtx, ending the parallel-meta_search hang. Arena now spans the full dispatch path.
  • 0.2.4. muonry --changelog prints this list inline. MCP initialize advertises the version and update hint to agents.

One replace surface (0.2.0)

Thirteen feature dimensions, identical between CLI and MCP. Multi-path iszigrep -R NEW OLD dir1 dir2on the CLI side and apaths:[]array on the MCP side. Regex is--regexorregex:true. Backref expansion works even for trivially-literal patterns; the engine auto-falls-back when the replacement contains\1through\9, instead of splicing the digits verbatim.

Same atomic_write flag. Same backup behavior. Same type filter. Same hidden-files toggle. The two front-ends are now the same tool accessed by two client shapes.

The numbers (0.2.0)

On 500 small source files in 10 subdirectories, replacing everyfoowithX:

  • zigrep -R (in-place, parallel walker): 16 ms
  • python3 os.walk + read/replace/write: 39 ms (2.37× slower)
  • find -exec sed -i '': 83 ms (4.92× slower)
zigrep -R 0.2.4
baseline
zigrep -R (in-place, parallel)baseline
16 ms
python3 os.walk2.44× slower
39 ms
find -exec sed -i ''5.19× slower
83 ms

500 small source files in 10 subdirectories. Replace every foo with X. Median of 12 hyperfine runs each, --prepare resets the tree per run.

The same workload run against earlier zigrep releases tells the story. v0.1.15 and v0.1.19 both used a tmp+rename atomic write per file and walked serially. 0.2.0 made in-place the default and added a parallel walker, and only those two changes. The result on the real repo:

current
prior releases
v0.1.15atomic-write era
baseline
74 ms
v0.1.19last 0.1.x release
baseline
74 ms
v0.2.4in-place + parallel walker (since 0.2.0)
3.70× faster than v0.1.19
20 ms

Same binary on the same workload: recursive replace across the zigrepper source tree (1917 files, ~28 MiB of code). Median of 9 runs each, 2-run warmup, per-iter watchdog at 5 s. Pre-0.2.0 releases used a tmp+rename atomic write per file; 0.2.0 made in-place the default and added a parallel walker. 0.2.1 → 0.2.4 kept the replace path unchanged, so those two changes are still the entire gap.

Search hasn't regressed either. Six of eight common patterns (literalfn,TODO, single-class\d+,[a-z]+, and anchored^pub fn) run faster than ripgrep on the same tree. Two patterns still trail rg by about 25%:foo|bar|bazalternation (no Teddy yet) and\bfn \w+word-boundary regex (engine-bound, not wrapper-bound).

zigrep 0.2.4
ripgrep 15.1.0
fn (literal)1.50× faster
16.2 ms
24.3 ms
TODO (literal)1.22× faster
20.2 ms
24.7 ms
\d+1.41× faster
26.8 ms
37.9 ms
[a-z]+1.33× faster
35.2 ms
46.7 ms
^pub fn1.01× faster
24.9 ms
25.2 ms
std\.mem\.indexOf1.12× faster
23.4 ms
26.1 ms
foo|bar|baz1.27× slower
31.3 ms
24.7 ms
\bfn \w+1.24× slower
31.3 ms
25.3 ms

Recursive search across the zigrepper repo (1763 files). Median of 10 hyperfine runs each. The two slower patterns are nanoregex engine-bound (alternation lacks a Teddy SIMD path; Pike VM word-boundary is a known regression vs rg's regex-automata).

What in-place keeps (0.2.0)

The default mode is O_WRONLY|O_TRUNC on the original path. Two syscalls (open and write) instead of the four a tmp+rename pattern costs. That speed is the obvious win. The quieter win is what survives.

  • Permissions stay. A 0754 file ends 0754. A 0444 file is refused withfiles_changed:0, because O_WRONLY on a read-only file fails with EACCES. sed -i'' silently overwrites it via rename.
  • Extended attributes stay. com.apple.quarantine, Finder tags, ACLs are all bound to the inode, so an in-place write keeps them. sed strips them on every rewrite.
  • Hard links and symlinks stay. Modify one path of a hard-link pair and both see the change. Modify a symlink path and the target gets written, the link stays a link.
Propertyzigrep -R (default)--atomic-writesed -i ''python3
Mode (chmod bits)✓✓✓✓
Extended attributes✓✓✕✓
Inode preserved✓✕✕✓
Hard links share update✓✕✕✓
Symlink stays a link✓✕✕✓
0444 refused (not silently overwritten)✓✓✕✕
Crash-safe mid-write✕✓✓✕

Audited 2026-05-20 with explicit before/after snapshots of stat -f "%Lp %i" and xattr -l on a file with mode 0754 and a user xattr. Hard-link and symlink behavior verified on macOS APFS.

When --atomic-write (0.2.0)

The cost of in-place is that a mid-write SIGKILL leaves a truncated file. For workloads that need crash safety (config files, lockfiles, anything where half-written is worse than not-written),--atomic-write(CLI) oratomic_write:true(MCP) switches to tmp+rename.

On macOS the new file inherits mode, xattr, and ACL from the original viafcopyfile(COPYFILE_METADATA)before the rename lands. Linux falls back to fchmod-only for now. Atomic changes the inode (an inherent property of rename), so hard-link siblings don't see the change and symlinks get replaced with regular files, exactly the trade you make when you opt in. A leftover .zigrep.tmp from a SIGINT'd run is swept up by the next walk so nothing accumulates.

Default · in-place
open(path, O_WRONLY|O_CREAT|O_TRUNC)
write(fd, new_content)
close(fd)
3 syscalls · same inode · mode & xattr untouched
Opt-in · --atomic-write
open(path, O_RDONLY)
fstat(fd → mode, xattrs)
open(path.zigrep.tmp, O_WRONLY|O_CREAT)
fcopyfile(src_fd → tmp_fd, COPYFILE_METADATA)
write(tmp_fd, new_content)
close(tmp_fd)
rename(tmp → path (atomic))
close(fd)
8 syscalls · new inode · metadata copied via fcopyfile

Cross-platform CI (0.2.0)

CI had been red on main since May 11. Tracking the failure down turned up eleven tools whosecio.zighad hardcoded macOS open(2) flag values,O_CREAT=0x200on macOS aliasesO_TRUNCon Linux, so "create file" calls truncated nothing and returned ENOENT.

Same pattern in muonry's sigbus guard, which extern'dsigsetjmp, a glibc macro that aliases the actual exported symbol__sigsetjmp. Fixed with a comptime-conditional shadow extern. Both Linux and macOS now build, test, and ship green.

Memo + frecency caps (0.2.1)

After 0.2.0 shipped, two latent unboundedness bugs surfaced in long-running daemons.

The memo file grew without limit. Every memo store / error / plan appended a JSONL line. Past 16 MiB the read cap kicked in and memo list silently returned empty.

Memo file lifecycle

appendstore / error / plan
growing…
2.0 MiB
3.5 MiB
4 MiB trigger
↓ compactIfNeeded()
rewrite atomically (tmp + rename)keep newest, drop oldest
2 MiB (newest entries that fit)

Before 0.2.1: past the 16 MiB read cap, every memo listsilently returned empty. Now: append → check size → if >4 MiB, rewrite keeping the newest entries that fit in 2 MiB. Tunable via ZIGMEMO_COMPACT_TRIGGER / ZIGMEMO_COMPACT_TARGET.

The frecency tracker had no LRU cap. Tracker.files was a hashmap keyed by every path the daemon had ever seen. Per-path timestamps were capped at 100 (good), but the path count was unbounded. Long sessions across multiple monorepos accumulated per-path state forever.

Tracker.files at 5001 entries

src/a.ziglast hit: now
src/b.ziglast hit: 12 m ago
src/c.ziglast hit: 1 h ago
node_modules/lodash/…last hit: 2 d ago
older/x.ziglast hit: 4 d ago
older/y.ziglast hit: 10 d ago

Pre-0.2.1: every distinct path lived in Tracker.files forever. A daemon traversing millions of unique paths grew per-path state unbounded. Now: cap at 5000. When the next path arrives at 5001, evict the entry whose most recent access timestamp is oldest. The newly-added entry has nowas its most-recent ts, so it's never the victim.

Per-request arena (0.2.2 → 0.2.3)

The 0.2.0 leak fixes ended on a defensive question: even with every individual leak plugged, the loop in runPipeMode passed init.gpa straight to handlers. A future missed-free would still become a permanent per-request leak.

The fix is a per-iteration arena allocator, reset at the bottom of every loop:

One iteration of the pipe loop

Before (gpa)

read line
parse JSON
dispatch handler
⚠ missed free → leak persists

After (per-request arena)

read line → arena
parse JSON → arena
dispatch handler → arena
arena.reset() ↓
✓ everything reclaimed

Each loop iteration owns an arena. Line, JSON tree, handler scratch, batch-worker scratch all allocate from it. arena.reset() at iteration end with retain_with_limit = 1 MiBreclaims everything that didn't escape the iteration. A future leak inside any handler is now a single-request bump, not a permanent climb.

0.2.2 shipped the wrapper around std.heap.ArenaAllocator and wired it into line + JSON parse only. The first attempt at widening it to handler dispatch hung. See the next section. 0.2.3 ran it all the way through dispatch + worker-thread scratch once the hang was rooted out.

The meta_search hang (0.2.3)

Wiring the arena through dispatch in 0.2.2 hung meta_search for 30 seconds against a non-existent path. A second-pass audit by a Sonnet 4.6 subagent found the real cause, and it had nothing to do with the arena.

handler_search.zig had two module-level atomic flags, cancel_flag and truncated_any. Every handle() reset to false at the top and set to true via a defer block at the end. When handler_meta_search spawned N concurrent variant threads, they raced:

Pre-0.2.3 race on a module-level cancel_flag

Variant A (done)

t0handle() entry
t1search runs
t2defer: cancel_flag = true
t3join watchdog A
t4defer: cancel_flag = false ←

Variant B (concurrent)

t0handle() entry
t2watchdog B fires: cancel = true
t4A's reset clobbers true → false
t5B's search never sees cancel
...hangs to 30s budget

Fix: per-call SearchCtx

Each handle() allocates a fresh SearchCtx{ cancel, truncated_any } on its stack. Threadlocal ctx_ptr routes search-loop helpers to it without changing every signature. The watchdog gets the pointer through its spawn args and signals its ctx, not whichever sibling happened to share the singleton.

The race was latent in the gpa path too. meta_search has always been concurrent. The arena's reset cycle made the timing windows wider and exposed it as a hang instead of an occasional aborted search.

Same audit surfaced three smaller fixes shipped in 0.2.3: lazy frecency init dropped its mutex during disk I/O (double-checked-lock); handler_symbol's path-too-long bail-out deinits its snapshot; file_cache.getWithIndex detects a peer racing through the miss path and frees its duplicate.

Retrieval-quality check

Six representative search / faster_search / meta_search / batch queries return byte-identical responses between the 0.2.2 and 0.2.3 binaries. The concurrency refactor has zero impact on output semantics.

--changelog + update hints (0.2.4)

The story above lived in commit messages and todo.md. 0.2.4 makes it discoverable from the terminal.

Three surfaces, one source of truth (src/version.zig)

--version

$ muonry --version
muonry 0.2.4
Run `muonry --changelog`…
Run `muonry update` to…

--changelog

$ muonry --changelog
muonry 0.2.4 (2026-05-21)
• new --changelog flag…
• version hints in CLI…
• MCP initialize advertises…

MCP initialize

{
serverInfo: { version: "0.2.4" },
instructions: "muonry v0.2.4…
run muonry --changelog…"
}

The CLI surfaces (--version, --changelog) and the MCP initialize response all read the same VERSION and CHANGELOG constants from src/version.zig. Agents reading the MCP instructions field on connect can surface "you're on 0.2.4. Run `muonry --changelog` to see what's new" to the user.

Same source of truth as the page you're reading. When the next release ships, updating src/version.zig updates the binary, the CLI hints, the MCP initialize response, and (manually) this post.

Upgrade

Same installer, fresh version:

curl -fsSL https://codegraff.com/install.sh | sh

Already on muonry? Just:

muonry update

All 13 tools (zigrep, zigread, zigpatch, zigcreate, zigdiff, zigmemo, zigpar, muonry, zigedit, ziggram, zigtools, zigauth, mcp-zip) are notarized on macOS and cross-compiled to linux-x86_64.--versionnow reports 0.2.4 across the toolchain.