ruby-concurrency/concurrent-ruby · error · Concurrent::IllegalOperationError

Recursive call to #value during evaluation of the Delay

Error message

Recursive call to #value during evaluation of the Delay

What it means

Concurrent::Delay runs its block exactly once, on the first call to #value (or #deref/#wait). Under the object's re-entrant monitor the evaluating thread sets @evaluation_started (delay.rb:84); a second call to #value made from inside the Delay's own block on the same thread reaches the elsif at delay.rb:91 while the state is still incomplete, and the library raises Concurrent::IllegalOperationError instead of deadlocking the monitor or returning a half-computed value.

Source

Thrown at lib/concurrent-ruby/concurrent/delay.rb:92

    # @return [Object] the current value of the object
    #
    # @!macro delay_note_regarding_blocking
    def value(timeout = nil)
      if @executor # TODO (pitr 12-Sep-2015): broken unsafe read?
        super
      else
        # this function has been optimized for performance and
        # should not be modified without running new benchmarks
        synchronize do
          execute = @evaluation_started = true unless @evaluation_started
          if execute
            begin
              set_state(true, @task.call, nil)
            rescue => ex
              set_state(false, nil, ex)
            end
          elsif incomplete?
            raise IllegalOperationError, 'Recursive call to #value during evaluation of the Delay'
          end
        end
        if @do_nothing_on_deref
          @value
        else
          apply_deref_options(@value)
        end
      end
    end

    # Return the value this object represents after applying the options
    # specified by the `#set_deref_options` method. If the delayed operation
    # raised an exception, this method will raise that exception (even when)
    # the operation has already been executed).
    #
    # @param [Numeric] timeout the maximum number of seconds to wait
    # @return [Object] the current value of the object
    # @raise [Exception] when `#rejected?` raises `#reason`

View on GitHub (pinned to 0b88d5ff75)

Solutions

  1. Remove the self-reference: compute the recursive step with plain arguments/methods and let the Delay block only orchestrate the top level.
  2. Replace recursion with iteration inside the block (accumulate into a local variable).
  3. Model multi-step dependencies with Concurrent::Promises future chains (then/flat), which compose acyclic dependencies cleanly.
  4. If re-entry is possible but unexpected, rescue Concurrent::IllegalOperationError at the call site and surface a domain-specific cycle error.

Example fix

# before
fact = Concurrent::Delay.new { fact.value * 1 } # re-enters itself on first #value

# after
def compute(n) = n.zero? ? 1 : n * compute(n - 1)
fact = Concurrent::Delay.new { compute(5) }
Defensive patterns

Strategy: try-catch

Try / catch

begin
  delay.value
rescue Concurrent::IllegalOperationError => e
  raise DependencyCycleError, "Delay block re-enters the Delay: #{e.message}"
end

Prevention

When it happens

Trigger: The Delay's block dereferences the same Delay: d = Concurrent::Delay.new { d.value }; a memoization cache where computing key A re-enters cache[A].value; two lazily-initialized resources whose blocks dereference each other; a callback invoked synchronously inside the block calls delay.value again.

Common situations: Converting recursive algorithms (tree walks, fibonacci, dependency resolution) to lazy memoized form with Delay; building dependency graphs that accidentally contain a cycle; refactors that pass the Delay instance into its own block; self-referential singleton initialization.

Related errors


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