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_orphans finds 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

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.

Parameters:

  • path (String) —

    canonical source path.

Raises:



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.

Parameters:

  • path (String) —

    source path, relative or absolute.

Returns:

  • (String) —

    canonical absolute path.



50
51
52
53
# File 'lib/mutineer/file_swap.rb', line 50

def self.canonical_path(path)
  expanded = File.expand_path(path)
  File.exist?(expanded) ? File.realpath(expanded) : expanded
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.

Parameters:

  • path (String) —

    canonical source path.

Returns:

  • (String) —

    lock file path.



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).

Parameters:

  • paths (Array<String>) —

    source file paths to own.

Yields:

  • the block to run while ownership is held.

Returns:

  • (Object) —

    the block's return value.



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!.

Parameters:

  • path (String) —

    expanded source path.



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.

Parameters:

  • backup (String) —

    path to the *.mutineer-backup file.

  • source_file (String) —

    corresponding source path.

Returns:

  • (Integer) —

    1 if healed, otherwise 0.



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).

Parameters:

  • dirs (Array<String>) —

    directories to sweep for orphaned backups.



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.

Parameters:

  • source_file (String) —

    path to the real source file.

  • mutated (String) —

    mutated source text to write for the duration.

Yields:

  • the block to run while the mutant is on disk.

Returns:

  • (Object) —

    the block's return value.



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