You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Both synchronize with the synchronous client's fire-and-forget channel-recreate thread by waiting on TaskHubGrpcClient._recreate_done_event. That event is set from inside the recreate thread, so it becomes signalled while the thread is still alive. A subsequent trigger therefore sees is_alive() as True, silently drops the recreate and skips the clear(), leaving the event set from the previous run. The next wait() then returns immediately without a recreate having happened, and the assertions that follow fail.
This is a test-synchronization defect only. The production single-flight behaviour is correct and intentional — dropping a trigger while a recreate is genuinely in flight is the documented design.
2. What is the impact?
Intermittent, hard-to-attribute CI and local test failures that are easy to dismiss as infrastructure noise.
Both tests pass in isolation and fail only under load, so they waste triage time and erode trust in the suite.
Because the failure surfaces as a different assertion each time — a missing threading.Timer, an unclosed channel, a non-empty _retired_channels, or an unexpected get_grpc_channel call count — it does not look like one bug, which makes it easy to misdiagnose repeatedly.
The window widens under CPU contention, so this gets worse on busy CI runners and on parallel test runs.
It creates a real risk of masking a genuine regression: a future change that legitimately breaks channel recreation would produce a failure indistinguishable from this flake.
3. Details
The recreate thread signals completion in its finally block, as its final statement:
threading.Event.set() runs inside the thread, so at the moment the event is signalled the thread has not yet finished teardown and is_alive() still returns True.
The scheduler gates on exactly that liveness check, and — critically — the clear() sits after the early return:
Thread T1 reaches its finally and calls set(). T1 is still alive.
The test's _recreate_done_event.wait(timeout=5.0) returns True.
The next RPC calls _schedule_recreate(). existing.is_alive() is still True, so the trigger is dropped at the early return — and clear() is never reached.
The test's next _recreate_done_event.wait(timeout=5.0) returns Trueimmediately, off the stale set from step 1.
Assertions about the second recreate fail, because no second recreate ever ran.
The two affected tests chain multiple recreates through this pattern — two in the first, three in the second:
The first test's own comment states precisely the hazard it is trying to avoid — "so the single-flight guard in _schedule_recreate does not drop the second trigger" — but Event.wait() does not actually provide that guarantee.
Note
The asynchronous client is not affected. It gates on asyncio.Task.done() rather than thread liveness, and under a single-threaded event loop the task is already complete by the time a waiter resumes. Its docstring calls this out explicitly:
Fix the tests, not the production code. Waiting on the event establishes that the recreate finished its work, but the single-flight guard keys on thread liveness, so the tests must wait on the same condition the guard reads. Joining the thread after the event wait closes the gap:
This makes is_alive() deterministically False before the next trigger, so the following _schedule_recreate() reaches clear() and the subsequent wait() reflects the new recreate rather than a stale signal.
A small test helper — something like _await_recreate(client) — applied at each of the five wait sites would keep this consistent and prevent the pattern being reintroduced.
Alternatives considered and rejected:
Moving set() out of the thread. A thread cannot signal its own death from within itself, so this would require a supervisor thread or a Thread.join() in the scheduler — real complexity and a blocking call on the RPC path, to fix a test-only problem.
Clearing the event before the early return in _schedule_recreate. This would make a dropped trigger indistinguishable from a pending one for any waiter, converting a test flake into a potential production hang.
Retry/sleep in the tests. Masks the race rather than removing it, and reintroduces timing sensitivity.
1. What is the issue?
Two tests in
tests/durabletask/test_client.pyare intermittently flaky:test_sync_client_close_closes_all_retired_sdk_channels_immediatelytest_sync_client_recreate_cooldown_prevents_immediate_repeated_recreationBoth synchronize with the synchronous client's fire-and-forget channel-recreate thread by waiting on
TaskHubGrpcClient._recreate_done_event. That event is set from inside the recreate thread, so it becomes signalled while the thread is still alive. A subsequent trigger therefore seesis_alive()asTrue, silently drops the recreate and skips theclear(), leaving the event set from the previous run. The nextwait()then returns immediately without a recreate having happened, and the assertions that follow fail.This is a test-synchronization defect only. The production single-flight behaviour is correct and intentional — dropping a trigger while a recreate is genuinely in flight is the documented design.
2. What is the impact?
Intermittent, hard-to-attribute CI and local test failures that are easy to dismiss as infrastructure noise.
threading.Timer, an unclosed channel, a non-empty_retired_channels, or an unexpectedget_grpc_channelcall count — it does not look like one bug, which makes it easy to misdiagnose repeatedly.3. Details
The recreate thread signals completion in its
finallyblock, as its final statement:durabletask-python/durabletask/client.py
Lines 536 to 542 in 7223d24
threading.Event.set()runs inside the thread, so at the moment the event is signalled the thread has not yet finished teardown andis_alive()still returnsTrue.The scheduler gates on exactly that liveness check, and — critically — the
clear()sits after the early return:durabletask-python/durabletask/client.py
Lines 518 to 532 in 7223d24
The failing interleaving is:
T1reaches itsfinallyand callsset().T1is still alive._recreate_done_event.wait(timeout=5.0)returnsTrue._schedule_recreate().existing.is_alive()is stillTrue, so the trigger is dropped at the early return — andclear()is never reached._recreate_done_event.wait(timeout=5.0)returnsTrueimmediately, off the stale set from step 1.The two affected tests chain multiple recreates through this pattern — two in the first, three in the second:
durabletask-python/tests/durabletask/test_client.py
Lines 667 to 675 in 7223d24
durabletask-python/tests/durabletask/test_client.py
Lines 911 to 930 in 7223d24
The first test's own comment states precisely the hazard it is trying to avoid — "so the single-flight guard in
_schedule_recreatedoes not drop the second trigger" — butEvent.wait()does not actually provide that guarantee.Note
The asynchronous client is not affected. It gates on
asyncio.Task.done()rather than thread liveness, and under a single-threaded event loop the task is already complete by the time a waiter resumes. Its docstring calls this out explicitly:durabletask-python/durabletask/client.py
Lines 1058 to 1074 in 7223d24
4. Proposed solution
Fix the tests, not the production code. Waiting on the event establishes that the recreate finished its work, but the single-flight guard keys on thread liveness, so the tests must wait on the same condition the guard reads. Joining the thread after the event wait closes the gap:
This makes
is_alive()deterministicallyFalsebefore the next trigger, so the following_schedule_recreate()reachesclear()and the subsequentwait()reflects the new recreate rather than a stale signal.A small test helper — something like
_await_recreate(client)— applied at each of the five wait sites would keep this consistent and prevent the pattern being reintroduced.Alternatives considered and rejected:
set()out of the thread. A thread cannot signal its own death from within itself, so this would require a supervisor thread or aThread.join()in the scheduler — real complexity and a blocking call on the RPC path, to fix a test-only problem._schedule_recreate. This would make a dropped trigger indistinguishable from a pending one for any waiter, converting a test flake into a potential production hang.