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:

  1. Thread 1 calls Tty.size. @size is not defined yet.
  2. Thread 1 runs @size = T.let(nil, ...). Now @size is defined with a value of nil.
  3. Thread 1 begins executing the shell command `/bin/stty size 2>/dev/null`.
  4. While Thread 1 is waiting for stty to finish, Thread 2 calls Tty.size.
  5. Thread 2 executes return @size if defined?(@size). Since @size was defined in step 2, Thread 2 immediately returns nil!

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.