September 09, 2026

Fixing a File Descriptor Leak in Homebrew's Cleanup Logic

How a missing block argument in File.open caused file descriptors to leak during brew cleanup.

While reviewing Homebrew’s codebase, I noticed an issue in how lockfiles were being inspected during brew cleanup. The cleanup command sweeps through cached lockfiles to determine if another process is currently holding an exclusive lock or if the file can be safely removed.

However, the implementation was leaking file descriptors on every single lockfile inspected.

The Bug

In Library/Homebrew/cleanup.rb, cleanup_lockfiles contained the following logic:

lockfiles.each do |file|
  next unless file.readable?
  next unless file.open(File::RDWR).flock(File::LOCK_EX | File::LOCK_NB)

  begin
    file.unlink
  ensure
    file.open(File::RDWR).flock(File::LOCK_UN) if file.exist?
  end
end

In Ruby, calling File.open (or Pathname#open) without a block returns an open File object. Because the return value wasn’t assigned to a variable or explicitly closed with .close, the underlying operating system file descriptor remained open until garbage collected (if at all).

Even worse, the ensure block called file.open(File::RDWR) again, opening a second unclosed file descriptor for the same file! In environments with many casks and formulae, this accumulated dozens of leaked file descriptors during cleanup.

The Fix

The standard and idiomatic Ruby solution is to pass a block to open. When given a block, Ruby automatically ensures that the file descriptor is closed when the block terminates, regardless of whether it exits normally or raises an exception:

lockfiles.each do |file|
  next unless file.readable?

  file.open(File::RDWR) do |lockfile|
    next unless lockfile.flock(File::LOCK_EX | File::LOCK_NB)

    begin
      file.unlink
    ensure
      lockfile.flock(File::LOCK_UN) if file.exist?
    end
  end
end

By passing File::RDWR to file.open with a block yielding lockfile, we obtain the lock on lockfile, unlink if possible, unlock, and let Ruby guarantee that the file handle is closed on all exit paths.

Summary

This fix was merged into Homebrew/brew in PR #21611. It’s a great reminder of how easy it is to overlook file descriptor leaks when working with file open calls without explicit lifecycle management.