ruby-concurrency/concurrent-ruby · error · ArgumentError

Cannot use both value and block as default value

Error message

Cannot use both value and block as default value

What it means

`Concurrent::ThreadLocalVar.new(default = nil, &default_block)` accepts exactly one default mechanism: a static value or a block evaluated per thread on first read there. The guard is `default && block_given?`, so a truthy default plus a block raises ArgumentError; `nil` (or `false`) together with a block is silently accepted with the block winning. Thread-identical twin of FiberLocalVar's constructor rule.

Source

Thrown at lib/concurrent-ruby/concurrent/atomic/thread_local_var.rb:53

  #
  #   t2 = Thread.new do
  #     v.value #=> 14
  #     v.value = 2
  #     v.value #=> 2
  #   end
  #
  #   v.value #=> 14
  class ThreadLocalVar
    LOCALS = ThreadLocals.new

    # Creates a thread local variable.
    #
    # @param [Object] default the default value when otherwise unset
    # @param [Proc] default_block Optional block that gets called to obtain the
    #   default value for each thread
    def initialize(default = nil, &default_block)
      if default && block_given?
        raise ArgumentError, "Cannot use both value and block as default value"
      end

      if block_given?
        @default_block = default_block
        @default = nil
      else
        @default_block = nil
        @default = default
      end

      @index = LOCALS.next_index(self)
    end

    # Returns the value in the current thread's copy of this thread-local variable.
    #
    # @return [Object] the current value
    def value
      LOCALS.fetch(@index) { default }

View on GitHub (pinned to 0b88d5ff75)

Solutions

  1. Choose one mechanism: `ThreadLocalVar.new { per_thread_default }` for computed defaults, `ThreadLocalVar.new(value)` for constants.
  2. In wrappers, forward only one: `blk ? ThreadLocalVar.new(&blk) : ThreadLocalVar.new(default)`.
  3. Note the asymmetry: `nil`/`false` plus a block does not raise — the block silently wins.

Example fix

// before
ctx = Concurrent::ThreadLocalVar.new({}) { { request_id: nil } }

// after
ctx = Concurrent::ThreadLocalVar.new { { request_id: nil } }
Defensive patterns

Strategy: validation

Validate before calling

def thread_local(default = nil, &blk)
  raise ArgumentError, 'default value or block, not both' if default && blk
  Concurrent::ThreadLocalVar.new(default, &blk)
end

Prevention

When it happens

Trigger: `Concurrent::ThreadLocalVar.new(0) { Thread.current.object_id }` — truthy integer plus block. Wrappers with signature `def var(default = nil, &blk)` forwarding both. Config APIs that pass a fallback value and always capture a block.

Common situations: Per-thread context objects (request IDs, DB connections) that started as a constant default and later gained a computed one; copy-paste initialization lines; sharing initializer code between ThreadLocalVar and FiberLocalVar.

Related errors


AI-assisted analysis of ruby-concurrency/concurrent-ruby@0b88d5ff75 (2026-08-21). Data as JSON: /api/errors/6a2f5fdf8ad4b845. Report an issue: GitHub.