Tests: fix flaky sync client recreate tests by joining the recreate thread - #214
Merged
Merged
Conversation
The synchronous client's fire-and-forget recreate thread signals `_recreate_done_event` from inside `_run_recreate`'s `finally` block, so the event becomes set while the thread is still alive. `_schedule_recreate` single-flights on `existing.is_alive()` and its early return sits *before* `_recreate_done_event.clear()`. A trigger issued in that window is therefore dropped and the event is left set from the previous recreate, so the next `wait()` returns immediately off the stale signal and assertions about the following recreate fail. Two tests chained recreates through this pattern and were intermittently flaky under CPU contention: - test_sync_client_close_closes_all_retired_sdk_channels_immediately - test_sync_client_recreate_cooldown_prevents_immediate_repeated_recreation Add an `await_sync_recreate` helper that waits on the event and then joins the recreate thread, so `is_alive()` is deterministically False before the next trigger is issued, and use it at all five wait sites. This is a test-synchronization defect only; the production single-flight behaviour is correct and intentional, so no production code changes. The async client is unaffected -- it gates on `asyncio.Task.done()`. Fixes #212 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677577fc-869c-463f-9243-0f4802a9f040
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes flakiness in the sync client resiliency tests by synchronizing on the condition the production single-flight logic actually uses (recreate thread liveness), not just the internal “done” event.
Changes:
- Introduces a shared
await_sync_recreate()helper that waits for_recreate_done_eventand thenjoin()s the recreate thread to ensure it has fully exited. - Updates five wait sites across the two affected sync-client tests to use the helper instead of waiting on
_recreate_done_eventalone. - Updates
install_resilient_test_stubs()documentation to steer contributors toward the correct synchronization pattern.
andystaples
approved these changes
Jul 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #212
Root cause
TaskHubGrpcClient._run_recreatesignals completion from itsfinallyblock —durabletask/client.py L536-L542:
Event.set()runs inside the recreate thread, so the event is signalled whilethat thread is still alive.
_schedule_recreatesingle-flights on exactly that liveness check, and theearly return sits before the
clear()—L518-L532:
The interleaving
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()isnever reached, leaving the event set from step 1.
wait()returns immediately off that stale signal.Because the failure surfaces as a different assertion each time (a missing
threading.Timer, an unclosed channel, a non-empty_retired_channels, anunexpected
get_grpc_channelcount) it does not look like one bug, which is whyit has been misdiagnosed as infra noise. The window widens under CPU contention,
so it gets worse on busy runners and parallel runs.
Why this fixes the tests, not production
The production single-flight behaviour is correct and intentional — dropping
a trigger while a recreate is genuinely in flight is the documented design. This
is purely a test-synchronization defect: waiting on the event establishes that
the recreate finished its work, but the guard keys on thread liveness, so
the tests must wait on the same condition the guard actually reads.
Alternatives considered and rejected (see #212):
set()out of the thread — a thread cannot signal its own deathfrom within itself, so this needs a supervisor thread or a blocking
Thread.join()on the RPC path. Real complexity and a blocking call on a hotpath, to fix a test-only problem.
indistinguishable from a pending one for any waiter, converting a test flake
into a potential production hang.
sensitivity.
The change
One file,
tests/durabletask/test_client.py(+31 / −8). A module-level helperwaits on the event and joins the thread, so
is_alive()is deterministicallyFalsebefore the next trigger:Applied at all five wait sites across the two affected tests:
test_sync_client_close_closes_all_retired_sdk_channels_immediately(2 sites)test_sync_client_recreate_cooldown_prevents_immediate_repeated_recreation(3 sites)The
install_resilient_test_stubsdocstring, which previously pointedcontributors at the insufficient
_recreate_done_event-only pattern, iscorrected to point at the helper so the bug is not reintroduced.
Note
The async client is not affected. It gates on
asyncio.Task.done()ratherthan thread liveness, and under a single-threaded event loop the task is
already complete by the time a waiter resumes. Its docstring at
durabletask/client.py L1058-L1074
calls this out explicitly. Its wait site is left unchanged.
No production code is touched, and no changelog entry is added — per the repo's
contributor instructions, test-only changes with no user-visible behaviour
change are explicitly excluded from changelogs.
RED / GREEN evidence
The race was forced deterministically rather than waited for. A throwaway
harness (never committed, no repo file mutated) replaced
TaskHubGrpcClient._run_recreateat runtime on the class object with anotherwise-identical body that lingers after
set(), reproducing exactly thestep-1 state — event signalled, thread still alive:
RED — unmodified tests at
58f99b2, forced interleaving: both tests fail.with, respectively:
Two different assertion messages from one underlying bug — matching the
misdiagnosis pattern described in the issue.
GREEN — same forced interleaving, with this fix: both pass, and stay passing
as the forced thread lifetime is scaled up 7.5×, confirming the join makes it
delay-independent rather than merely faster:
set()thread lifetimeGREEN loop: 50 consecutive runs of the two tests under the forced
interleaving (0.05 s) — 0 failures.
Full core suite:
pytest tests/durabletask -q --ignore-glob="*_e2e.py"—684 passed, 7 skipped, byte-for-byte identical to the pre-change baseline on
main(*_e2e.pyrequires a sidecar and is excluded).Validation
pytest tests/durabletask -q --ignore-glob="*_e2e.py"flake8 tests/durabletask/test_client.pytests/is outside the configuredincludescope.tests/durabletask/test_client.pyonly