@@ -1419,6 +1419,154 @@ def some_activity() -> str:
14191419 assert result .result == "context var: some value!"
14201420
14211421
1422+ @activity .defn
1423+ def post_return_cancel_activity () -> str :
1424+ return "done"
1425+
1426+
1427+ @activity .defn
1428+ def post_return_cancel_probe_activity () -> str :
1429+ return "probe"
1430+
1431+
1432+ class PostReturnCancelInterceptor (Interceptor ):
1433+ def __init__ (
1434+ self , after_return_started : asyncio .Event , allow_completion : asyncio .Event
1435+ ) -> None :
1436+ super ().__init__ ()
1437+ self .after_return_started = after_return_started
1438+ self .allow_completion = allow_completion
1439+
1440+ def intercept_activity (
1441+ self , next : ActivityInboundInterceptor
1442+ ) -> ActivityInboundInterceptor :
1443+ return PostReturnCancelActivityInbound (
1444+ next , self .after_return_started , self .allow_completion
1445+ )
1446+
1447+
1448+ class PostReturnCancelActivityInbound (ActivityInboundInterceptor ):
1449+ def __init__ (
1450+ self ,
1451+ next : ActivityInboundInterceptor ,
1452+ after_return_started : asyncio .Event ,
1453+ allow_completion : asyncio .Event ,
1454+ ) -> None :
1455+ super ().__init__ (next )
1456+ self .after_return_started = after_return_started
1457+ self .allow_completion = allow_completion
1458+
1459+ async def execute_activity (self , input : ExecuteActivityInput ) -> Any :
1460+ result = await super ().execute_activity (input )
1461+ if activity .info ().activity_type == "post_return_cancel_activity" :
1462+ self .after_return_started .set ()
1463+ await self .allow_completion .wait ()
1464+ return result
1465+
1466+
1467+ async def test_sync_activity_cancel_after_return_does_not_kill_thread_pool_worker (
1468+ client : Client ,
1469+ worker : ExternalWorker ,
1470+ env : WorkflowEnvironment ,
1471+ shared_state_manager : SharedStateManager ,
1472+ ):
1473+ if env .supports_time_skipping :
1474+ pytest .skip ("Test requires real worker-side timeout cancellation delivery" )
1475+
1476+ def alive_thread_count (executor : ThreadPoolExecutor ) -> int :
1477+ return sum (1 for thread in list (executor ._threads ) if thread .is_alive ())
1478+
1479+ after_return_started = asyncio .Event ()
1480+ allow_completion = asyncio .Event ()
1481+ act_task_queue = str (uuid .uuid4 ())
1482+
1483+ with ThreadPoolExecutor (max_workers = 1 ) as executor :
1484+ with pytest .warns (
1485+ UserWarning ,
1486+ match = "Worker max_concurrent_activities is 2 but activity_executor's max_workers is only" ,
1487+ ):
1488+ act_worker = Worker (
1489+ client ,
1490+ task_queue = act_task_queue ,
1491+ activities = [
1492+ post_return_cancel_activity ,
1493+ post_return_cancel_probe_activity ,
1494+ ],
1495+ activity_executor = executor ,
1496+ interceptors = [
1497+ PostReturnCancelInterceptor (after_return_started , allow_completion )
1498+ ],
1499+ max_concurrent_activities = 2 ,
1500+ shared_state_manager = shared_state_manager ,
1501+ )
1502+
1503+ async with act_worker :
1504+ try :
1505+ timed_out_workflow = asyncio .create_task (
1506+ client .execute_workflow (
1507+ "kitchen_sink" ,
1508+ KSWorkflowParams (
1509+ actions = [
1510+ KSAction (
1511+ execute_activity = KSExecuteActivityAction (
1512+ name = "post_return_cancel_activity" ,
1513+ task_queue = act_task_queue ,
1514+ start_to_close_timeout_ms = 200 ,
1515+ retry_max_attempts = 1 ,
1516+ )
1517+ )
1518+ ]
1519+ ),
1520+ id = str (uuid .uuid4 ()),
1521+ task_queue = worker .task_queue ,
1522+ )
1523+ )
1524+ await asyncio .wait_for (after_return_started .wait (), timeout = 5 )
1525+
1526+ with pytest .raises (WorkflowFailureError ) as err :
1527+ await timed_out_workflow
1528+ timeout = assert_activity_error (err .value )
1529+ assert isinstance (timeout , TimeoutError )
1530+ assert timeout .type == TimeoutType .START_TO_CLOSE
1531+
1532+ activity_worker = act_worker ._activity_worker
1533+ assert activity_worker
1534+ for _ in range (50 ):
1535+ if any (
1536+ running .cancelled_event and running .cancelled_event .is_set ()
1537+ for running in activity_worker ._running_activities .values ()
1538+ ):
1539+ break
1540+ await asyncio .sleep (0.1 )
1541+ else :
1542+ pytest .fail ("Timed out waiting for activity cancellation" )
1543+
1544+ probe_result = await asyncio .wait_for (
1545+ client .execute_workflow (
1546+ "kitchen_sink" ,
1547+ KSWorkflowParams (
1548+ actions = [
1549+ KSAction (
1550+ execute_activity = KSExecuteActivityAction (
1551+ name = "post_return_cancel_probe_activity" ,
1552+ task_queue = act_task_queue ,
1553+ start_to_close_timeout_ms = 30000 ,
1554+ retry_max_attempts = 1 ,
1555+ )
1556+ )
1557+ ]
1558+ ),
1559+ id = str (uuid .uuid4 ()),
1560+ task_queue = worker .task_queue ,
1561+ ),
1562+ timeout = 5 ,
1563+ )
1564+ assert probe_result == "probe"
1565+ assert alive_thread_count (executor ) == 1
1566+ finally :
1567+ allow_completion .set ()
1568+
1569+
14221570@activity .defn
14231571async def local_without_schedule_to_close_activity () -> str :
14241572 return "some-activity"
0 commit comments