September 15, 2026
Hunting Down a Race Condition in Homebrew's TTY Size Caching
How a premature variable assignment caused Homebrew to miscalculate terminal widths under concurrent calls.
In Homebrew, interactive commands such as multi-threaded bottle downloads and progress spinners need to know the width of your terminal window to format output lines correctly. Terminal dimensions are queried and cached via Homebrew::Tty.size.
However, under concurrent calls from multiple threads, a subtle race condition in Tty.size could cause Homebrew to mistake an unfinished terminal size check for a failed check, leading to broken terminal wrapping or misplaced progress bars.
The Race Condition
Here is how Tty.size was previously implemented in Library/Homebrew/utils/tty.rb:
def size
return @size if defined?(@size)
@size = T.let(nil, T.nilable([Integer, Integer]))
height, width = `/bin/stty size 2>/dev/null`.presence&.split&.map(&:to_i)
@size = [height, width] if height && width
@size
end
Notice what happens when multiple threads call Tty.size:
- Thread 1 calls
Tty.size.@sizeis not defined yet. - Thread 1 runs
@size = T.let(nil, ...). Now@sizeis defined with a value ofnil. - Thread 1 begins executing the shell command
`/bin/stty size 2>/dev/null`. - While Thread 1 is waiting for
sttyto finish, Thread 2 callsTty.size. - Thread 2 executes
return @size if defined?(@size). Since@sizewas defined in step 2, Thread 2 immediately returnsnil!
Thread 2 treated terminal size detection as permanently unavailable, falling back to default widths or breaking line wrapping.
The Fix
The fix was straightforward: avoid defining and assigning @size until the computation is completely finished. By keeping intermediate values in local variables, @size remains undefined until the final result is ready:
def size
return @size if defined?(@size)
height, width = `/bin/stty size 2>/dev/null`.presence&.split&.map(&:to_i)
size = [height, width] if height && width
@size = T.let(size, T.nilable([Integer, Integer]))
end
Now, any other thread checking defined?(@size) will either wait or compute its own value without ever observing a half-initialized @size.
Writing a Concurrency Test
To guarantee this regression won’t happen again, I added a multi-threaded unit test in Library/Homebrew/test/utils/tty_spec.rb using Ruby’s Queue for deterministic thread synchronization:
it "does not expose an unfinished size to another thread" do
probe_started = Queue.new
release_probe = Queue.new
allow(described_class).to receive(:`).with("/bin/stty size 2>/dev/null").and_invoke(
lambda { |_command|
probe_started << true
release_probe.pop
"40 160"
},
->(_command) { "40 160" },
)
probing_thread = Thread.new { described_class.size }
probe_started.pop
expect(described_class.size).to eq([40, 160])
ensure
release_probe&.push(true)
probing_thread&.value
end
Before the fix, this test failed reliably because the second thread observed nil. With the fix, both threads consistently receive [40, 160].
Outcome
The PR was reviewed and merged into Homebrew/brew in PR #23804. Concurrency bugs can be notoriously elusive, but synchronizing tests and deferring state publication makes fixing them very satisfying.