DEV Community

Cover image for Crystal Minitest and the Shutdown Order Problem
Michael Nikitochkin
Michael Nikitochkin

Posted on

Crystal Minitest and the Shutdown Order Problem

TL;DR: Optimizing Minitest setup by moving initialization from before_setup to module-level broke logging: Log::AsyncDispatcher creates resources early, and they're cleaned up before tests run (which happens in at_exit). Solution: use DirectDispatcher for tests instead.

Story: The Optimization and Discovery

When setting up a Marten1 application to work with Minitest2, I moved all initialization instructions to before_setup hooks:

class Minitest::Test
  def before_setup
    Marten.setup if Marten.apps.app_configs.empty?    # Runs before EVERY test
    Marten::Spec.setup_databases
  end
end
Enter fullscreen mode Exit fullscreen mode

After tests were working reliably, I optimized: why run this repetitive setup for every test? If I moved it to module-level initialization, it would run once when test file loads:

require "minitest/autorun"
require "../src/project"

Marten.setup                      # Runs once at module load
Marten::Spec.setup_databases
Enter fullscreen mode Exit fullscreen mode

Tests passed initially. But when I ran with DEBUG=1 to enable verbose output, something broke:

DEBUG=1 crystal run test/users_test.cr
Enter fullscreen mode Exit fullscreen mode
Channel::ClosedError: Channel is closed
    /usr/share/crystal/src/channel.cr:142:8 in 'send'
    /usr/share/crystal/src/log/dispatch.cr:55:7 in 'dispatch'
Enter fullscreen mode Exit fullscreen mode

The logging channel was closed. I reverted to before_setup as a workaround, but I needed to understand: Why does when setup runs matter more than that it runs?

Investigation: The Key Discovery

Why Minitest Is Different

Minitest doesn't run tests during normal program execution. Instead:

# minitest/src/autorun.cr:8-11
at_exit do
  exit(Minitest.run(ARGV))  # Tests run during shutdown!
end
Enter fullscreen mode Exit fullscreen mode

This creates a timing problem. With module-level setup:

  1. Marten.setup initialize a Log3 instance with Log::AsyncDispatcher4 on booting
  2. Log::AsyncDispatcher spawns a background fiber with a channel
  3. Main program completes → Crystal runtime cleanup begins
  4. Channel may be closed by garbage collection (this is where my assumption lies)
  5. at_exit fires → Minitest runs tests
  6. Tests call Log.info → channel already closed → Channel::ClosedError

With before_setup, setup happens inside at_exit, so the Log instance is created during shutdown, not during normal execution.

How Log::AsyncDispatcher Works

# Simplified from: crystal/src/log/dispatch.cr
class Log::AsyncDispatcher
  @channel = Channel(Entry).new

  def initialize
    @fiber = spawn do
      loop do
        entry = @channel.receive
        write_entry(entry)
      end
    end
  end

  def dispatch(entry)
    @channel.send(entry)  # ← Assumes channel is open!
  end

  def close : Nil
    # TODO: this might fail if being closed from different threads
    unless @channel.closed?
      @channel.close
      @done.receive
    end
  end

  def finalize : Nil
    close  # ← Channel gets closed here during GC/shutdown
  end
end
Enter fullscreen mode Exit fullscreen mode

During shutdown, garbage collection calls finalize() -> close (), which closes the channel. But at_exit hooks run after some cleanup, creating the race condition.

Solution: The Fix

The issue isn't optimization itself—it's that Log::AsyncDispatcher isn't suitable for code that runs before at_exit. The solution is to use a dispatcher without background resources for tests:

# config/settings/test.cr
Marten.configure :test do |config|
  config.log_backend = Log::IOBackend.new(
    dispatcher: Log::DispatchMode::Direct  # No background fiber
  )

  if ENV.has_key?("DEBUG")
    config.debug = true
    config.log_level = Log::Severity::Trace
  end
end
Enter fullscreen mode Exit fullscreen mode

Dispatcher Options

Dispatcher Best For Pros Cons
AsyncDispatcher Production Non-blocking, efficient Unreliable at shutdown
SyncDispatcher Threaded tests Thread-safe, reliable Slight mutex overhead
DirectDispatcher Single-threaded tests Zero overhead, simple Not thread-safe

For tests with module-level init, Log::DirectDispatcher is ideal.

Conclusion

The optimization itself was sound—module-level initialization is faster. The issue was incompatibility with Log::AsyncDispatcher in a shutdown context.

More broadly, any code that relies on objects with finalize methods could be affected by garbage collection events and at_exit timing—background resources created during normal execution may be cleaned up before tests run.

The fix is simple (one configuration change) but reveals a broader principle:

Test infrastructure must account for shutdown order and resource cleanup timing, regardless of framework. Some libraries use at_exit handlers for cleanup in multi-threaded applications, and when tests run after these handlers, any finalized objects (channels, connections, files, caches) become inaccessible.


References


  1. Marten Framework 

  2. Minitest Crystal Port 

  3. Crystal Log Documentation 

  4. class Log::AsyncDispatcher 

Top comments (0)