feat(filesystem): copy-on-write Overlay for sound, preview-gated filesystem effects#140
Merged
Conversation
…system effects
Pyex.Filesystem.Overlay is the filesystem counterpart to Pyex.Storage.Overlay:
a VFS.Mountable decorator that stages writes/mkdir/rm in an upper layer,
falls through to lower for reads, and masks lower with whiteouts until
commit/1 applies the diff for real. Same staging shape, applied to Ctx's
:filesystem capability instead of :storage.
This was previously a documented-but-unshipped pattern in vfs's SPEC.md
("CoW overlay -- the agent staging pattern") -- it lands here first, under
real usage, before being a candidate for promotion to a stock vfs impl.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019NokzcR7BiAigPgC78zpk9
…a differential property test
Adds a StreamData property test that generates random write/mkdir/rm/rmtree
sequences and asserts the overlay, staged then committed, is byte-for-byte
identical to applying the same sequence directly -- both per-op (does each
operation succeed/fail identically on both paths?) and on final state (does
a full walk of the committed tree match?). It found three real bugs in the
hand-written-test-passing implementation within the first ~40 runs:
1. mkdir delegated straight to `upper`, which doesn't have the ancestor
chain when a directory only exists (implicitly or explicitly) in
`lower` -- `mkdir("/keep/c")` failed with :enoent even though `/keep`
is clearly a directory in the merged view. write_file had the mirror
bug for ancestor-is-a-file checks. Fixed by validating conflicts
against the overlay's own merged stat/exists view instead of trusting
`upper` alone.
2. write_file/mkdir cleared the whiteout at the exact path they touched.
Unnecessary for read correctness (every read already checks `upper`
before ever consulting whiteouts), but wrong for commit/1: recreating
a path as a *different type* than it had in `lower` (a file replaced
by a directory) left the whiteout cleared, so apply_deletes skipped
removing the old entry and apply_writes' mkdir silently treated the
stale file's :eexist as "already a directory, done." Fixed by never
clearing a whiteout on write success -- commit's delete-before-write
ordering already handles same-path recreation correctly on its own.
3. `lower`-only *implicit* directories (VFS.Memory's model: a directory
exists only because it has descendants -- the same model a real S3
prefix follows) stayed "visible" through the overlay even after every
descendant was staged as removed, because `lower.stat` alone has no
idea what the overlay staged on top of it. Fixed stat/2 (and folded
exists?/2 into it) to verify at least one descendant survives in the
merged view before trusting a lower-reported directory.
4. mkdir's fix for (1) forces `parents: true` on the underlying `upper`
call to bridge missing ancestors -- but VFS.Memory's own `parents:
true` permanently materializes every intermediate ancestor as an
*explicit* directory, which then outlives removal of the original
leaf and gets spuriously recreated on `lower` at commit. Added
`explicit_dirs` (tracking which paths a caller actually targeted,
distinct from incidental ancestor-bridging side effects) so commit
only recreates directories the caller genuinely asked for.
Also closes two coverage gaps the hand-written tests never touched:
`materialize/2` (via a `test/support/counting_fs.ex` addition + new
threading tests in vfs_threading_test.exs, mirroring that file's existing
CountingFS-based proof pattern) and `walk/3`, the VFS.Skeleton-composed
default (neither diff/1 nor commit/1 ever calls walk on the overlay
itself, only on `upper` alone -- a direct test was the only way to
exercise it dispatching through this module's own stat/readdir).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019NokzcR7BiAigPgC78zpk9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Pyex.Filesystem.Overlay— the filesystem counterpart toPyex.Storage.Overlay(feat(storage): copy-on-write Overlay backend for sound, preview-gated effects #138): aVFS.Mountabledecorator withlower/upperlayers and map-as-set whiteouts. Reads checkupperfirst, fall through tolower; writes/mkdirland inupper;rmrecords a whiteout instead of touchinglower.diff/1reports%{added, modified, deleted};commit/1applies the diff ontolower(deletes first, then writes).StreamDatadifferential property test (test/pyex/filesystem/overlay_test.exs) generates randomwrite/mkdir/rm/rm -rsequences and asserts the overlay, staged then committed, is byte-for-byte identical to applying the same sequence directly — per-op (does each operation succeed/fail identically?) and on final committed state (does a full walk of the tree match?). It found three real bugs in the implementation that 3 hand-written example tests had missed, within the first ~40 generated runs:mkdir/write_filedelegated straight toupper, which doesn't have the ancestor chain when a directory only exists (implicitly or explicitly) inlower—mkdir("/keep/c")failed:enoenteven though/keepis clearly a directory in the merged view.write_file/mkdircleared the whiteout at the exact path they touched — unnecessary for read correctness (every read already checksupperfirst), but wrong forcommit/1: recreating a path as a different type than it had inlowerleft the old entry un-deleted, silently swallowed by an:eexistat commit.lower-only implicit directories (VFS.Memory's model — the same model a real S3 prefix follows: a directory exists only because it has descendants) stayed "visible" through the overlay even after every descendant was staged as removed, becauselower.statalone has no idea what the overlay staged on top of it.parents: trueon the underlyinguppercall had its own side effect:VFS.Memory'sparents: truepermanently materializes every intermediate ancestor as an explicit directory, which then outlives removal of the original leaf and gets spuriously recreated at commit. Addedexplicit_dirsbookkeeping socommit/1only recreates directories the caller genuinely targeted.materialize/2(via a small addition to the existingtest/support/counting_fs.exfixture + new tests invfs_threading_test.exs, mirroring that file's established threading-proof pattern) andwalk/3— theVFS.Skeleton-composed default, which neitherdiff/1norcommit/1ever calls on the overlay itself (only onupperalone), so a direct test was the only way to prove it dispatches correctly through this module's ownstat/readdir.Why this exists / where it came from
vfs's ownSPEC.mddocuments this exact pattern ("CoW overlay — the agent staging pattern") but deliberately ships it as a worked example, not a stock impl — the promotion criterion the spec names is real usage across consumers. This lands it inpyexfirst, underpyex's test suite and dual-version (1.19/1.20) Dialyzer, before it's a candidate for promotion upstream.Dialyzer note
Two spots needed the same "avoid opaque MapSet friction" treatment already used in
Pyex.Storage.Overlay, one level further than a struct field:whiteoutsandexplicit_dirsare maps-as-sets (notMapSet) in the struct — same fix asStorage.Overlay.deletes.capabilities/1can't dynamically derive fromMountable.capabilities(upper)— any op combining a protocol-dispatchedMapSetwith a fresh one triggeredcall_without_opaqueon 1.20 locally. Fixed by returning a static{:read, :write, :mkdir}, mirroringVFS.Memory's own static (zero-friction)capabilities/1.Local Dialyzer (1.20.0-rc.3) is clean. Per the project's own caution, this doesn't prove clean on CI's 1.19 — watching CI to confirm.
Test plan
mix format --check-formattedmix compile --warnings-as-errorsmix test— 6193 tests, 303 properties, 0 failuresmax_runs: 100committed for CImix dialyzer— clean locally (1.20.0-rc.3)🤖 Generated with Claude Code
https://claude.ai/code/session_019NokzcR7BiAigPgC78zpk9