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.