Skip to content

Commit cd17e57

Browse files
odedkedemdreeze“Odedclaude
authored
Release the GIL during the activity heartbeat core call (#1643)
record_activity_heartbeat was a synchronous pyo3 fn that held the GIL while core's record_heartbeat blocked on the outstanding-activity lock. Task starts invoke a custom slot supplier's mark_slot_used (which acquires the GIL) while holding that same lock, so any worker with a custom slot supplier and heartbeating activities could deadlock permanently (ABBA on GIL vs. lock). Decode the heartbeat proto with the GIL held (it borrows the Python bytes), then detach from the GIL for the core call so the cycle cannot form. Fixes #1642 Co-authored-by: “Oded <“oded”@dreeze.com”> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7fe7e6f commit cd17e57

2 files changed

Lines changed: 97 additions & 4 deletions

File tree

temporalio/bridge/src/worker.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -658,10 +658,14 @@ impl WorkerRef {
658658
enter_sync!(self.runtime);
659659
let heartbeat = ActivityHeartbeat::decode(proto.as_bytes())
660660
.map_err(|err| PyValueError::new_err(format!("Invalid proto: {err}")))?;
661-
self.worker
662-
.as_ref()
663-
.unwrap()
664-
.record_activity_heartbeat(heartbeat);
661+
let worker = self.worker.as_ref().unwrap().clone();
662+
// Detach from the GIL during the core call. Core may block on internal
663+
// locks whose holders can call back into Python (e.g. a custom slot
664+
// supplier's mark_slot_used runs while core's outstanding-activity
665+
// lock is held); holding the GIL here would deadlock the worker.
666+
proto
667+
.py()
668+
.detach(move || worker.record_activity_heartbeat(heartbeat));
665669
Ok(())
666670
}
667671

tests/worker/test_worker.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,95 @@ def release_slot(self, ctx: SlotReleaseContext) -> None:
589589
await asyncio.sleep(1)
590590

591591

592+
@activity.defn
593+
async def heartbeating_activity(beats: int) -> None:
594+
for i in range(beats):
595+
activity.heartbeat(i)
596+
await asyncio.sleep(0)
597+
598+
599+
@workflow.defn
600+
class HeartbeatingFanOutWorkflow:
601+
@workflow.run
602+
async def run(self, total: int, beats: int, window: int) -> None:
603+
in_flight = asyncio.Semaphore(window)
604+
605+
async def run_one() -> None:
606+
async with in_flight:
607+
await workflow.execute_activity(
608+
heartbeating_activity,
609+
beats,
610+
start_to_close_timeout=timedelta(minutes=1),
611+
heartbeat_timeout=timedelta(seconds=30),
612+
)
613+
614+
await asyncio.gather(*(run_one() for _ in range(total)))
615+
616+
617+
async def test_custom_slot_supplier_with_heartbeating_activities(client: Client):
618+
"""Regression test for the GIL <-> core-mutex deadlock (#1642).
619+
620+
Any custom slot supplier plus heartbeating activities used to wedge the
621+
worker permanently: the heartbeat FFI held the GIL while blocking on
622+
core's outstanding-activity lock, while an activity task start invoked
623+
mark_slot_used (which acquires the GIL) under that same lock. The
624+
supplier below is deliberately trivial - the deadlock was in the calling
625+
convention, not in what the callbacks do.
626+
627+
NOTE: on regression this test HANGS rather than fails - the deadlock
628+
freezes the event loop, so no in-process timeout (including the
629+
wait_for below) can fire, and only a CI-level job timeout kills it.
630+
"""
631+
632+
class CappedSlotSupplier(CustomSlotSupplier):
633+
def __init__(self, max_slots: int) -> None:
634+
self.max_slots = max_slots
635+
self.used = 0
636+
637+
async def reserve_slot(self, ctx: SlotReserveContext) -> SlotPermit:
638+
while True:
639+
permit = self.try_reserve_slot(ctx)
640+
if permit is not None:
641+
return permit
642+
await asyncio.sleep(0.005)
643+
644+
def try_reserve_slot(self, ctx: SlotReserveContext) -> SlotPermit | None:
645+
# Called with the GIL held, so no extra lock is needed
646+
if self.used >= self.max_slots:
647+
return None
648+
self.used += 1
649+
return SlotPermit()
650+
651+
def mark_slot_used(self, ctx: SlotMarkUsedContext) -> None:
652+
return None
653+
654+
def release_slot(self, ctx: SlotReleaseContext) -> None:
655+
self.used = max(0, self.used - 1)
656+
657+
fixed = FixedSizeSlotSupplier(100)
658+
tuner = WorkerTuner.create_composite(
659+
workflow_supplier=fixed,
660+
# A small cap keeps activity starts (and thus mark_slot_used calls)
661+
# churning against the heartbeat FFI
662+
activity_supplier=CappedSlotSupplier(8),
663+
local_activity_supplier=fixed,
664+
nexus_supplier=fixed,
665+
)
666+
async with new_worker(
667+
client,
668+
HeartbeatingFanOutWorkflow,
669+
activities=[heartbeating_activity],
670+
tuner=tuner,
671+
) as w:
672+
wf1 = await client.start_workflow(
673+
HeartbeatingFanOutWorkflow.run,
674+
args=[600, 50, 150],
675+
id=f"heartbeating-slot-supplier-{uuid.uuid4()}",
676+
task_queue=w.task_queue,
677+
)
678+
await asyncio.wait_for(wf1.result(), timeout=120)
679+
680+
592681
@workflow.defn(
593682
name="DeploymentVersioningWorkflow",
594683
versioning_behavior=VersioningBehavior.AUTO_UPGRADE,

0 commit comments

Comments
 (0)