September 06, 2026

Graceful Download Cancellation in Homebrew: From Thread Pools to curl Subprocesses

How I implemented cooperative cancellation and eliminated orphaned curl processes across two pull requests to Homebrew.

When downloading formulae and bottles in Homebrew, packages can be fetched concurrently via DownloadQueue. However, handling cancellation—such as when a user hits Ctrl+C or when an unrecoverable failure occurs—was historically abrupt.

Over the course of two pull requests (PR #21526 and PR #22201), I worked on replacing Homebrew’s blunt thread-killing logic with a robust, cooperative cancellation model that cleanly terminates both Ruby threads and running curl subprocesses.


Part 1: Moving to Cooperative Cancellation (PR #21526)

Homebrew’s DownloadQueue#cancel method previously relied on pool.kill, terminating worker threads indiscriminately. This had a longstanding comment in download_queue.rb:

# FIXME: Implement graceful cancellation of running downloads based on
#        https://ruby-concurrency.github.io/concurrent-ruby/master/Concurrent/Cancellation.html
#        instead of killing the whole thread pool.

Hard-killing threads with pool.kill can leave shared state and system resources in an inconsistent state. Cooperative cancellation, by contrast, relies on a shared flag that worker tasks periodically check at safe points. When cancellation is requested, workers voluntarily stop execution, clean up their resources, and exit cleanly.

Introducing CancelledDownloadError and AtomicBoolean

In PR #21526, I added a dedicated error class and an atomic flag to manage cancellation status thread-safely:

module Homebrew
  # Raised when a download is cancelled cooperatively.
  class CancelledDownloadError < StandardError; end

  class DownloadQueue
    # ...
    def initialize(...)
      # ...
      @cancelled = T.let(Concurrent::AtomicBoolean.new(false), Concurrent::AtomicBoolean)
    end

Whenever new items are enqueued, the flag is reset via @cancelled.make_false.

Checking Cancellation at Safe Gates

In the asynchronous worker block, we check @cancelled before and after fetching the resource:

@downloads_by_location[cached_location] ||= Concurrent::Promises.future_on(
  pool, RetryableDownload.new(downloadable, tries:, pour:),
  @cancelled, force, quiet, check_attestation
) do |download, cancelled, force, quiet, check_attestation|
  raise CancelledDownloadError if cancelled.true?

  download.clear_cache if force
  download.fetch(quiet:)
  raise CancelledDownloadError if cancelled.true?

  if check_attestation && downloadable.is_a?(Bottle)
    Utils::Attestation.check_attestation(downloadable, quiet: true)
  end
  create_symlinks_for_shared_download(cached_location)
end

Flipping the Cancellation Flag

When cancellation is triggered, we flip the atomic flag instead of killing the entire thread pool:

sig { void }
def cancel
  # Signal cooperative cancellation to all running downloads.
  @cancelled.make_true
end

We also rescued CancelledDownloadError in the download monitoring loops to prevent messy backtraces and allow the terminal UI to clear cleanly.


Part 2: Killing Orphaned curl Subprocesses (PR #22201)

While the cooperative cancellation flag prevented new downloads from starting after cancellation, a critical edge case remained when downloading large bottles.

Cooperative checks happen before download.fetch begins and after it completes. But during fetch, the worker thread is blocked waiting for an external curl process to finish. If a user pressed Ctrl+C while downloading a 2GB bottle, the worker thread remained stuck, and orphaned curl processes would continue running in the background, consuming network bandwidth.

Leveraging system_command Signal Propagation

In Homebrew’s Library/Homebrew/system_command.rb, command execution has built-in signal handling: when an Interrupt exception is raised on a Ruby thread executing system_command, it traps the interrupt and sends SIGINT to the child process PID (curl).

All we needed was a mechanism to deliver that Interrupt exception into the active worker threads.

Tracking Active Threads and Propagating Interrupts

In PR #22201, I updated DownloadQueue to track active download threads using a thread-safe Concurrent::Set:

require "concurrent/set"

class DownloadQueue
  def initialize(...)
    # ...
    @download_threads = T.let(Concurrent::Set.new, Concurrent::Set)
  end

Each worker thread registers itself while actively downloading and unregisters in an ensure block:

@download_threads.add(Thread.current)
begin
  download.clear_cache if force
  download.fetch(quiet:)
  raise CancelledDownloadError if cancelled.true?

  if check_attestation && downloadable.is_a?(Bottle)
    Utils::Attestation.check_attestation(downloadable, quiet: true)
  end
  create_symlinks_for_shared_download(cached_location)
rescue Interrupt
  raise CancelledDownloadError
ensure
  @download_threads.delete(Thread.current)
end

Finally, when cancel is called, we iterate through every active thread and raise Interrupt:

sig { void }
def cancel
  # Signal cooperative cancellation and interrupt any active download threads.
  # Raising Interrupt on the thread triggers the existing rescue Interrupt in
  # system_command.rb which sends SIGINT to the curl subprocess directly.
  @cancelled.make_true
  @download_threads.each { |thread| thread.raise(Interrupt) }
end

Outcome

By pairing cooperative atomic flags with thread interrupt propagation:

  1. Active curl subprocesses receive SIGINT immediately and abort clean without orphan background processes.
  2. Partial cache files are cleaned up.
  3. Pending downloads never begin.
  4. The terminal UI and progress spinners reset gracefully without thread pool destruction.

Both PR #21526 and PR #22201 were merged by Mike McQuaid, completing Homebrew’s download cancellation architecture.