Hi there! Would love some thoughts on the bug below. Thank you!
What are you really trying to do?
We are running our temporal workers / activities with the Fiber activity executor using the Async gem. Our Temporal process basically looks like:
Async do
worker = Temporalio::Worker.new(
client:,
task_queue: TASK_QUEUE,
activities: ACTIVITIES,
workflows: WORKFLOWS
)
log("worker", "running on #{TASK_QUEUE} (pid #{Process.pid})")
worker.run(shutdown_signals: %w[SIGINT SIGTERM])
end
Describe the bug
Activities and workflows will process fine, until an activity exceeds its START_TO_CLOSE timeout and receives a CanceledError. Once this happens, the reactor loop will be entirely frozen. No more activities will run. Workflow activities continue to get picked up but silently fail, until we've leaked all the workflow slots. We see this in our production app today on the latest temporal SDK 1.6.0
Minimal Reproduction
Note: this was drafted by AI, but confirmed to reproduce the issue manually
worker.rb
# frozen_string_literal: true
# Minimal reproduction: a fiber-executor activity hitting its START_TO_CLOSE
# timeout permanently bricks the Temporal Ruby worker.
#
# Gemfile:
# gem "temporalio", "1.6.0"
# gem "async", "2.43.0"
#
# Setup:
# docker run -d --name temporal-dev -p 7233:7233 \
# temporalio/temporal:latest server start-dev --ip 0.0.0.0
#
# Then, in two terminals:
# bundle exec ruby worker.rb
# bundle exec ruby driver.rb
#
# Expected: ping workflows keep completing forever.
# Actual: ping workflows stop completing the moment the hang activity is
# cancelled, and never recover.
$stdout.sync = true
require "bundler/setup"
require "async"
require "temporalio/activity"
require "temporalio/client"
require "temporalio/retry_policy"
require "temporalio/worker"
require "temporalio/workflow"
TASK_QUEUE = "min-repro"
def log(msg) = puts("[#{Time.now.strftime('%H:%M:%S')}] worker: #{msg}")
# Never returns on its own. Core's local start_to_close timer fires at 5s and
# delivers cancellation via Fiber#raise -- that is the bug trigger.
class HangActivity < Temporalio::Activity::Definition
activity_executor :fiber
def execute
log("hang activity started (will be cancelled at 5s)")
sleep(3600)
end
end
# Liveness probe: proves whether the worker still dispatches ANY new work.
class PingActivity < Temporalio::Activity::Definition
activity_executor :fiber
def execute(n)
log("ping activity #{n}")
"pong-#{n}"
end
end
class HangWorkflow < Temporalio::Workflow::Definition
def execute
Temporalio::Workflow.execute_activity(
HangActivity,
start_to_close_timeout: 5,
retry_policy: Temporalio::RetryPolicy.new(max_attempts: 1)
)
end
end
class PingWorkflow < Temporalio::Workflow::Definition
def execute(n)
Temporalio::Workflow.execute_activity(
PingActivity, n,
start_to_close_timeout: 10,
retry_policy: Temporalio::RetryPolicy.new(max_attempts: 1)
)
end
end
client = Temporalio::Client.connect("127.0.0.1:7233", "default")
# The `Async` wrapper is essential to the bug. It places the SDK's single
# dispatch loop -- Temporalio::Worker#run's `loop { runner.next_event }`, which
# handles activity tasks AND workflow activations -- onto a fiber managed by a
# transfer-based scheduler. Cancel delivery calls Fiber#raise inline on that
# loop; Fiber#raise uses resume semantics, async uses transfer semantics, so the
# call never returns and the loop is stranded forever.
#
# Control: delete the two `activity_executor :fiber` lines above (leaving this
# Async block exactly as-is) so the activities run on the default thread-pool
# executor. The same timeout then does no harm -- pings keep completing
# indefinitely. Cancellation is still delivered; only the delivery mechanism
# differs.
Async do
worker = Temporalio::Worker.new(
client:,
task_queue: TASK_QUEUE,
activities: [HangActivity, PingActivity],
workflows: [HangWorkflow, PingWorkflow]
)
log("running on #{TASK_QUEUE}")
worker.run(shutdown_signals: %w[SIGINT SIGTERM])
end
driver.rb
# frozen_string_literal: true
# Drives the reproduction. Starts one workflow whose activity will hit its
# START_TO_CLOSE timeout, then repeatedly runs a trivial workflow to show that
# the worker stops processing new work entirely.
$stdout.sync = true
require "bundler/setup"
require "temporalio/client"
TASK_QUEUE = "min-repro"
def log(msg) = puts("[#{Time.now.strftime('%H:%M:%S')}] driver: #{msg}")
client = Temporalio::Client.connect("127.0.0.1:7233", "default")
run = Time.now.to_i
log("starting HangWorkflow (its activity hits START_TO_CLOSE after 5s)")
client.start_workflow("HangWorkflow", id: "hang-#{run}", task_queue: TASK_QUEUE)
log("polling with PingWorkflow every 2s...")
n = 0
loop do
n += 1
handle = client.start_workflow(
"PingWorkflow", n, id: "ping-#{run}-#{n}", task_queue: TASK_QUEUE
)
# Hard timeout on a thread: once the worker is bricked the workflow simply
# never progresses, so we need our own deadline for an unambiguous verdict.
t = Thread.new { handle.result }
if t.join(8)
log("ping #{n}: OK")
else
t.kill
log("ping #{n}: STUCK -- worker has stopped dispatching work")
end
sleep(2)
end
Gemfile
# frozen_string_literal: true
source "https://rubygems.org"
# Versions pinned to those in use when the bug was reproduced.
# temporalio 1.6.0 was the latest release at the time.
gem "async", "2.43.0"
gem "temporalio", "1.6.0"
Environment/Versions
- OS and processor: Linux
- Temporal Version: 1.6.0 SDK
- Confirmed with both Temporal docker & cloud edition
Additional context
Hi there! Would love some thoughts on the bug below. Thank you!
What are you really trying to do?
We are running our temporal workers / activities with the Fiber activity executor using the Async gem. Our Temporal process basically looks like:
Describe the bug
Activities and workflows will process fine, until an activity exceeds its START_TO_CLOSE timeout and receives a
CanceledError. Once this happens, the reactor loop will be entirely frozen. No more activities will run. Workflow activities continue to get picked up but silently fail, until we've leaked all the workflow slots. We see this in our production app today on the latest temporal SDK 1.6.0Minimal Reproduction
Note: this was drafted by AI, but confirmed to reproduce the issue manually
worker.rb
driver.rb
Gemfile
Environment/Versions
Additional context