Skip to content

feat(filesystem): copy-on-write Overlay for sound, preview-gated filesystem effects#140

Merged
ivarvong merged 3 commits into
mainfrom
fs-overlay
Jul 2, 2026
Merged

feat(filesystem): copy-on-write Overlay for sound, preview-gated filesystem effects#140
ivarvong merged 3 commits into
mainfrom
fs-overlay

Conversation

@ivarvong

@ivarvong ivarvong commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Summary

  • Pyex.Filesystem.Overlay — the filesystem counterpart to Pyex.Storage.Overlay (feat(storage): copy-on-write Overlay backend for sound, preview-gated effects #138): a VFS.Mountable decorator with lower/upper layers and map-as-set whiteouts. Reads check upper first, fall through to lower; writes/mkdir land in upper; rm records a whiteout instead of touching lower. diff/1 reports %{added, modified, deleted}; commit/1 applies the diff onto lower (deletes first, then writes).
  • A StreamData differential property test (test/pyex/filesystem/overlay_test.exs) generates random write/mkdir/rm/rm -r sequences 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:
    1. mkdir/write_file delegated straight to upper, which doesn't have the ancestor chain when a directory only exists (implicitly or explicitly) in lowermkdir("/keep/c") failed :enoent even though /keep is clearly a directory in the merged view.
    2. write_file/mkdir cleared the whiteout at the exact path they touched — unnecessary for read correctness (every read already checks upper first), but wrong for commit/1: recreating a path as a different type than it had in lower left the old entry un-deleted, silently swallowed by an :eexist at commit.
    3. 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, because lower.stat alone has no idea what the overlay staged on top of it.
    4. Fixing (1) by forcing parents: true on the underlying upper call had its own side effect: VFS.Memory's parents: true permanently materializes every intermediate ancestor as an explicit directory, which then outlives removal of the original leaf and gets spuriously recreated at commit. Added explicit_dirs bookkeeping so commit/1 only recreates directories the caller genuinely targeted.
  • Two coverage gaps the hand-written tests never touched: materialize/2 (via a small addition to the existing test/support/counting_fs.ex fixture + new tests in vfs_threading_test.exs, mirroring that file's established threading-proof pattern) and walk/3 — the VFS.Skeleton-composed default, which neither diff/1 nor commit/1 ever calls on the overlay itself (only on upper alone), so a direct test was the only way to prove it dispatches correctly through this module's own stat/readdir.

Why this exists / where it came from

vfs's own SPEC.md documents 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 in pyex first, under pyex'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:

  • whiteouts and explicit_dirs are maps-as-sets (not MapSet) in the struct — same fix as Storage.Overlay.deletes.
  • capabilities/1 can't dynamically derive from Mountable.capabilities(upper) — any op combining a protocol-dispatched MapSet with a fresh one triggered call_without_opaque on 1.20 locally. Fixed by returning a static {:read, :write, :mkdir}, mirroring VFS.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-formatted
  • mix compile --warnings-as-errors
  • mix test — 6193 tests, 303 properties, 0 failures
  • The new differential property test additionally stress-run at 2000 iterations across 16 different random seeds (32,000+ generated op sequences) with zero failures, beyond the max_runs: 100 committed for CI
  • mix dialyzer — clean locally (1.20.0-rc.3)
  • CI green on 1.19/1.20 (watching after push)

🤖 Generated with Claude Code

https://claude.ai/code/session_019NokzcR7BiAigPgC78zpk9

ivarvong and others added 3 commits July 1, 2026 09:25
…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
@ivarvong
ivarvong merged commit fdb6fee into main Jul 2, 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