Module: Mutineer::FileSwap
- Defined in:
- lib/mutineer/file_swap.rb
Overview
Apply one whole-file mutant to the REAL source path for the external
(--test-command) backend, and guarantee the original is restored on every
exit path. A separate bundle exec subprocess has its own VM and cannot see
an in-process load, so the mutant must live on disk while its suite runs,
which makes leaving the file mutated the one genuinely dangerous failure mode.
Defense in depth, mirroring the tempfile-orphan discipline
(Runner.sweep_orphans, isolation.rb tempfiles):
- exclusive OS ownership (flock) is acquired before swap or recovery;
- the original bytes are held in memory AND written to a sibling backup;
- `ensure` restores from memory around every mutant;
- the backup survives a SIGKILL (which skips `ensure`), so `restore_orphans`
can self-heal a left-mutated tree on the next run's startup once the
kernel has released the dead owner's lock.
Only one mutant is in flight per file at a time (the external path is serial), so backups never collide.
Constant Summary collapse
- BACKUP_SUFFIX =
Suffix for the on-disk backup; fixed so
restore_orphansfinds it. ".mutineer-backup"- LOCK_DIR_NAME =
Subdirectory beside the source that holds flock files (stable across cwd and
cache_dir, so concurrent runs on one inode share one lock). "file-swap-locks"- OWNED =
This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.
Canonical source path => open lock File held by this process.
{}
Class Method Summary collapse
-
.acquire!(path) ⇒ void
private
Exclusive non-blocking flock for canonical
path. -
.canonical_path(path) ⇒ String
Real path when the file exists, otherwise
File.expand_path. -
.lock_file(path) ⇒ String
private
Flock path for a canonical source:
<dir>/.mutineer/file-swap-locks/<sha>. -
.owning(paths) { ... } ⇒ Object
Holds exclusive OS ownership of each source path for the duration of the block.
-
.release!(path) ⇒ void
private
Releases a lock acquired by FileSwap.acquire!.
-
.restore_one(backup, source_file) ⇒ Integer
private
Restores one backup if the sibling source exists.
-
.restore_orphans(dirs) ⇒ void
Startup/after-run self-heal: restore any source file left mutated by a prior interrupted run (a leftover
*.mutineer-backup), then remove the backup. -
.with(source_file, mutated) { ... } ⇒ Object
Writes
mutatedtosource_file, yields, then restores the original bytes on every exit path (normal return, exception, orensure).
Class Method Details
.acquire!(path) ⇒ void
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
This method returns an undefined value.
Exclusive non-blocking flock for canonical path.
137 138 139 140 141 142 143 144 |
# File 'lib/mutineer/file_swap.rb', line 137 def self.acquire!(path) file = File.open(lock_file(path), File::RDWR | File::CREAT, 0o644) unless file.flock(File::LOCK_EX | File::LOCK_NB) file.close raise ConcurrentRunError, path end OWNED[path] = file end |
.canonical_path(path) ⇒ String
Real path when the file exists, otherwise File.expand_path. Symlink
aliases of one inode share this identity for locks, backups, and ownership.
50 51 52 53 |
# File 'lib/mutineer/file_swap.rb', line 50 def self.canonical_path(path) = File.(path) File.exist?() ? File.realpath() : end |
.lock_file(path) ⇒ String
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Flock path for a canonical source: <dir>/.mutineer/file-swap-locks/<sha>.
Derived only from the source path so cwd and cache_dir cannot split a lock.
152 153 154 155 156 |
# File 'lib/mutineer/file_swap.rb', line 152 def self.lock_file(path) dir = File.join(File.dirname(path), ".mutineer", LOCK_DIR_NAME) FileUtils.mkdir_p(dir) File.join(dir, Digest::SHA256.hexdigest(path)) end |
.owning(paths) { ... } ⇒ Object
Holds exclusive OS ownership of each source path for the duration of the block. Re-entrant for paths this process already owns. Raises ConcurrentRunError when another process holds a path (non-blocking).
62 63 64 65 66 67 68 69 70 71 72 73 |
# File 'lib/mutineer/file_swap.rb', line 62 def self.owning(paths) acquired = [] Array(paths).map { |p| canonical_path(p) }.uniq.sort.each do |path| next if OWNED.key?(path) acquire!(path) acquired << path end yield ensure acquired.reverse_each { |path| release!(path) } end |
.release!(path) ⇒ void
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
This method returns an undefined value.
Releases a lock acquired by acquire!.
163 164 165 166 167 168 169 170 171 |
# File 'lib/mutineer/file_swap.rb', line 163 def self.release!(path) file = OWNED.delete(path) return unless file file.flock(File::LOCK_UN) file.close rescue StandardError nil end |
.restore_one(backup, source_file) ⇒ Integer
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Restores one backup if the sibling source exists. Returns 1 when bytes were written back, 0 when the backup was redundant or had no sibling.
180 181 182 183 184 185 186 187 188 189 190 191 192 |
# File 'lib/mutineer/file_swap.rb', line 180 def self.restore_one(backup, source_file) return 0 unless File.exist?(source_file) backup_bytes = File.binread(backup) if File.binread(source_file) == backup_bytes File.unlink(backup) 0 else File.binwrite(source_file, backup_bytes) File.unlink(backup) 1 end end |
.restore_orphans(dirs) ⇒ void
This method returns an undefined value.
Startup/after-run self-heal: restore any source file left mutated by a prior
interrupted run (a leftover *.mutineer-backup), then remove the backup.
Skips a backup whose source is owned by a live process. Prints one line to
stderr when it actually heals something, so a developer knows their working
tree was auto-restored (a file they did not touch).
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/mutineer/file_swap.rb', line 114 def self.restore_orphans(dirs) healed = 0 dirs.uniq.each do |dir| Dir.glob(File.join(dir, "*#{BACKUP_SUFFIX}")).each do |backup| source_file = backup.delete_suffix(BACKUP_SUFFIX) begin owning([source_file]) { healed += restore_one(backup, source_file) } rescue ConcurrentRunError next end end end return if healed.zero? warn "[mutineer] restored #{healed} source file(s) left mutated by a previous interrupted run." end |
.with(source_file, mutated) { ... } ⇒ Object
Writes mutated to source_file, yields, then restores the original bytes
on every exit path (normal return, exception, or ensure). Byte-exact:
binary read/write preserves encoding, newlines, and trailing bytes.
Acquires exclusive ownership first; a leftover backup with no live owner is
treated as the original (a prior hard-killed run), not a concurrent run.
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/mutineer/file_swap.rb', line 85 def self.with(source_file, mutated) path = canonical_path(source_file) created = false original = nil backup = path + BACKUP_SUFFIX owning([path]) do begin original = File.exist?(backup) ? File.binread(backup) : File.binread(path) File.binwrite(backup, original) created = true File.binwrite(path, mutated) yield ensure if created File.binwrite(path, original) File.unlink(backup) if File.exist?(backup) end end end end |