Skip to content

[Bug] WorkflowHandle#result silently returns nil for a still-running workflow when the history long poll expires #499

Description

@tardoe

What are you really trying to do?

Running an e2e test suite against
Temporalio::Testing::WorkflowEnvironment.start_time_skipping: start a
workflow that waits on an in-workflow timer, await handle.result (letting
time skipping fast-forward the timer), then assert on the workflow's side
effects. We were debugging a long-standing "flaky" CI failure in which the
workflow appeared to complete cleanly but its database writes were missing —
and traced it to result returning nil while the workflow was still
running.

Describe the bug

Client::WorkflowHandle#result can return nil for a workflow that is still
running, instead of blocking until the workflow closes or raising. The
caller cannot distinguish this from a workflow that genuinely completed with
a nil result.

Mechanism (code identical in 1.4.1, 1.5.0 and 1.6.0):

result waits for the close event by calling .next on the history-events
enumerator inside Kernel#loop
(lib/temporalio/client/workflow_handle.rb):

  loop do                                                                                                                
    event = fetch_history_events(                                                                                        
      wait_new_event: true,                                                                                              
      event_filter_type: ...CLOSE_EVENT,                                                                                 
      ...                                                                                                                
    ).next                                                                                                               
    case event.event_type                                                                                                
    ...                                                                                                                  
  end                                                                                                                    

The enumerator behind it
(Internal::Client::Implementation#fetch_workflow_history_events) terminates
whenever a GetWorkflowExecutionHistory response carries an empty
next_page_token:

loop do                                                                                                                
  resp = @client.workflow_service.get_workflow_execution_history(req, ...)                                             
  resp.history&.events&.each { |event| yielder << event }                                                              
  break if resp.next_page_token.empty?                                                                                 
  req.next_page_token = resp.next_page_token                                                                           
end                                                                                                                    

The time-skipping test server answers an expired wait_new_event long poll
(~20s) with no events and an empty next_page_token when the workflow
has not closed. The enumerator therefore ends without yielding, .next
raises StopIteration — and Kernel#loop silently rescues
StopIteration
and returns the enumerator block's value (nil, since the
inner break carries no value). So result returns nil with the workflow
still running. The raise Error, "Unknown close event type..." guard is
unreachable on this path.

Downstream effect: any test (or application code) awaiting a workflow that
takes longer than one long-poll window to close proceeds with nil and reads
state the workflow has not written yet — in a CI suite this presents as a
maddening load-sensitive flake with a "workflow completed cleanly, no errors,
side effects missing" signature.

Suggested fix: treat enumerator exhaustion during a close-event wait as "no
event yet" and re-poll, e.g.:

event = begin                                                                                                          
  fetch_history_events(...).next                                                                                       
rescue StopIteration                                                                                                   
  next # the long poll expired without a close event — poll again                                                      
end                                                                                                                    

(or have the paging enumerator re-issue a wait_new_event request when the
response is empty with an empty token). Either way, result should never
return a value that did not come from a close event.

Minimal Reproduction

Self-contained, public API only. auto_time_skipping_disabled is used to
legitimately pin the clock so the timer cannot fire within the long-poll
window — any situation where the workflow takes >~20s of wall time to close
triggers the same path.

# frozen_string_literal: true                                                                                          
                                                                                                                       
require "temporalio/testing"                                                                                           
require "temporalio/workflow"                                                                                          
require "temporalio/worker"                                                                                            
                                                                                                                       
# A workflow that waits on a timer far longer than the history long poll.                                              
class SleepingWorkflow < Temporalio::Workflow::Definition                                                              
  def execute                                                                                                          
    Temporalio::Workflow.sleep(3600)                                                                                   
    "done"                                                                                                             
  end                                                                                                                  
end                                                                                                                    
                                                                                                                       
env = Temporalio::Testing::WorkflowEnvironment.start_time_skipping                                                     
worker = Temporalio::Worker.new(                                                                                       
  client: env.client,                                                                                                  
  task_queue: "repro-tq",                                                                                              
  workflows: [SleepingWorkflow]                                                                                        
)                       

worker.run do                                                                                                          
  handle = env.client.start_workflow(                                                                                  
    SleepingWorkflow,                                                                                                  
    id: "repro-#{Process.pid}",                                                                                        
    task_queue: "repro-tq"                                                                                             
  )                                                                                                                    
                                                                                                                       
  # Keep the clock pinned so the timer cannot fire. The bug: instead of                                                
  # blocking (or raising), #result returns nil once the long poll expires.                                             
  env.auto_time_skipping_disabled do                                                                                   
    started = Time.now                                                                                                 
    value = handle.result                                                                                              
    puts "result returned #{value.inspect} after #{(Time.now - started).round(1)}s"                                    
    puts "workflow status: #{handle.describe.status}"                                                                  
  end                                                                                                                  
end                                                                                                                    
                                                                                                                       
env.shutdown                                                                                                           

Observed output (temporalio 1.5.0, Ruby 3.4.8, macOS arm64):


result returned nil after 20.0s                                                                                        
workflow status: 1                                                                                                     

result returned nil after exactly the long-poll window, with the workflow
still WORKFLOW_EXECUTION_STATUS_RUNNING (1) and its timer pending.

Environment/Versions

  • OS and processor: macOS 15 (arm64); also observed on ubuntu-latest x86_64 in CI
  • Temporal Version: temporalio gem 1.5.0 (relevant code verified identical in
    1.4.1 and 1.6.0); bundled time-skipping Java test server via
    Temporalio::Testing::WorkflowEnvironment.start_time_skipping
  • Ruby 3.4.8
  • Are you using Docker or Kubernetes or building Temporal from source? No —
    the SDK-managed test server binary

Additional context

  • A real server that returns a non-empty continuation token on long-poll
    expiry would not trigger this, but the SDK should be robust to either
    behaviour: an empty page during a wait_new_event wait means "no event
    yet", not "history complete".
  • Our current workaround prepends a module onto the time-skipping handle that
    re-polls while describe.status is RUNNING and raises after a deadline —
    effective, but it relies on private constants and cannot distinguish a
    genuine nil workflow result at the result call site.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions