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.
What are you really trying to do?
Running an e2e test suite against
Temporalio::Testing::WorkflowEnvironment.start_time_skipping: start aworkflow that waits on an in-workflow timer, await
handle.result(lettingtime 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
resultreturningnilwhile the workflow was stillrunning.
Describe the bug
Client::WorkflowHandle#resultcan returnnilfor a workflow that is stillrunning, 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):
resultwaits for the close event by calling.nexton the history-eventsenumerator inside
Kernel#loop(
lib/temporalio/client/workflow_handle.rb):The enumerator behind it
(
Internal::Client::Implementation#fetch_workflow_history_events) terminateswhenever a
GetWorkflowExecutionHistoryresponse carries an emptynext_page_token:The time-skipping test server answers an expired
wait_new_eventlong poll(~20s) with no events and an empty
next_page_tokenwhen the workflowhas not closed. The enumerator therefore ends without yielding,
.nextraises
StopIteration— andKernel#loopsilently rescuesStopIterationand returns the enumerator block's value (nil, since theinner
breakcarries no value). Soresultreturnsnilwith the workflowstill running. The
raise Error, "Unknown close event type..."guard isunreachable 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
niland readsstate 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.:
(or have the paging enumerator re-issue a
wait_new_eventrequest when theresponse is empty with an empty token). Either way,
resultshould neverreturn a value that did not come from a close event.
Minimal Reproduction
Self-contained, public API only.
auto_time_skipping_disabledis used tolegitimately 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.
Observed output (temporalio 1.5.0, Ruby 3.4.8, macOS arm64):
resultreturnednilafter exactly the long-poll window, with the workflowstill
WORKFLOW_EXECUTION_STATUS_RUNNING(1) and its timer pending.Environment/Versions
1.4.1 and 1.6.0); bundled time-skipping Java test server via
Temporalio::Testing::WorkflowEnvironment.start_time_skippingthe SDK-managed test server binary
Additional context
expiry would not trigger this, but the SDK should be robust to either
behaviour: an empty page during a
wait_new_eventwait means "no eventyet", not "history complete".
re-polls while
describe.statusis RUNNING and raises after a deadline —effective, but it relies on private constants and cannot distinguish a
genuine nil workflow result at the
resultcall site.