Module: Mutineer::FileSwap
- Defined in:
- lib/mutineer/file_swap.rb
Overview
#27 (U2): 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):
- 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.
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"
Class Method Summary collapse
-
.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
.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.
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).
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
# File 'lib/mutineer/file_swap.rb', line 69 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) backup_bytes = File.binread(backup) if !File.exist?(source_file) # A real user file that merely ends in our suffix, with no sibling to # restore — leave it untouched (never create a file from it). next elsif File.binread(source_file) == backup_bytes # Redundant backup (e.g. a crash between restore and unlink): nothing to # heal, just clear the orphan so the next run doesn't see a false race. File.unlink(backup) else File.binwrite(source_file, backup_bytes) File.unlink(backup) healed += 1 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.
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'lib/mutineer/file_swap.rb', line 40 def self.with(source_file, mutated) backup = source_file + BACKUP_SUFFIX # A backup already on disk means either a prior hard-killed run (restore_orphans # should have healed it at startup) or a SECOND mutineer run racing us on the # same file. The backup path is shared and unlocked, so proceeding would let # us capture the other run's mutant AS the "original" and permanently mutate # the tree. Refuse loudly rather than silently corrupt — and do it BEFORE # `created` is set, so the ensure below never touches a backup we don't own. raise ConcurrentRunError, backup if File.exist?(backup) original = File.binread(source_file) File.binwrite(backup, original) created = true File.binwrite(source_file, mutated) yield ensure if created File.binwrite(source_file, original) File.unlink(backup) if File.exist?(backup) end end |