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
{{ message }}
Repository navigation
Add context manager support to TaskHubGrpcClient to prevent gRPC channel leaks #134
TaskHubGrpcClient has a close() method but does not implement __enter__/__exit__, so it cannot be used as a context manager. This means callers must remember to call close() manually (typically in a try/finally block), but no existing tests or examples do this — every callsite creates the client and never closes it, leaking the underlying gRPC channel.
AsyncTaskHubGrpcClient already has __aenter__/__aexit__, so there is an asymmetry between the sync and async clients.
Scope
This affects ~40+ callsites across the repository:
This issue is still applicable as of main (post-#135). I verified the following on the latest main:
TaskHubGrpcClient (sync) in durabletask/client.py still defines close() but does not implement __enter__ / __exit__.
AsyncTaskHubGrpcClient continues to expose __aenter__ / __aexit__ that delegate to close(), so the sync/async asymmetry described in the original issue remains.
TaskHubGrpcWorker already supports context-manager usage, so the sync client is the only public class in this asymmetric state.
PR Add gRPC client and worker connection resiliency #135 (gRPC client and worker connection resiliency) did not add context-manager support; it focused on hardening the recreate/close path. That work makes context-manager support more valuable, because close() now also tears down resiliency state.
The same shape already exists on AsyncTaskHubGrpcClient (__aexit__ -> await self.close()), which Add gRPC client and worker connection resiliency #135 also extended; we are bringing the sync client in line with that established pattern.
DurableTaskSchedulerClient (azuremanaged) subclasses TaskHubGrpcClient and does not override close(), so it inherits the new context-manager behavior automatically and continues to flow through the resiliency-aware close().
Proposed implementation
Add the two dunder methods immediately after close() in TaskHubGrpcClient:
Migrate existing callsites to with ...: so they exercise the new path and stop leaking channels in long-running test runs. Scope on current main (the original ~40 figure is now slightly larger because of test growth since the issue was filed):
The tests/durabletask/test_client.py constructor-focused tests that intentionally do not run RPCs can stay as-is (they assert on construction, not on lifecycle), but the e2e suites above should switch to with.
The azuremanaged examples (e.g. examples/activity_sequence.py, examples/fanout_fanin.py, examples/distributed-tracing/app.py, examples/large_payload/app.py, the entity examples, etc.) instantiate DurableTaskSchedulerClient and would benefit from the same migration, since they inherit the new context-manager support for free. They are not strictly required for the fix but are worth folding into the same PR to align with the worker-style with pattern those files already use.
Example migration (unchanged from the original issue, since it still applies):
Add focused unit tests in tests/durabletask/test_client.py (next to the existing async-context tests and the _closing/recreate tests added by #135):
__enter__ returns the same instance and __exit__ invokes close() exactly once (assert via a spy on TaskHubGrpcClient.close).
After exiting a with block on an SDK-owned channel, client._closing is True and client._channel.close() has been called (mirrors the existing async assertion).
For a caller-owned channel, __exit__ does not close the channel (verifies _owns_channel is still respected end-to-end through the context-manager path).
Exceptions raised inside the with block propagate, and close() is still invoked on the way out.
Run as part of the existing pytest suite plus python -m flake8 on the touched files, per repo conventions.
Changelog
Add to CHANGELOG.md under ## Unreleased:
ADDED
- Added context-manager support (`__enter__` / `__exit__`) to
`TaskHubGrpcClient` so it can be used with `with` statements, mirroring the
existing `AsyncTaskHubGrpcClient` async-context-manager support and the
`TaskHubGrpcWorker` pattern. `DurableTaskSchedulerClient` inherits this
behavior automatically.
No durabletask-azuremanaged/CHANGELOG.md entry is required since the azuremanaged client does not override close() and the behavior change is fully described by the core SDK entry; we can optionally add a cross-reference note there if reviewers prefer.
Callsite migration list refreshed for current main.
Test plan includes a specific regression test that asserts the context-manager path still cleans up resiliency state (_closing, recreate thread, retired channels).
Activity
berndverst commentedon May 26, 2026
Note
This is a proposal from Copilot generated on behalf of Bernd Verst (@berndverst).
Validity check
This issue is still applicable as of
main(post-#135). I verified the following on the latestmain:TaskHubGrpcClient(sync) indurabletask/client.pystill definesclose()but does not implement__enter__/__exit__.AsyncTaskHubGrpcClientcontinues to expose__aenter__/__aexit__that delegate toclose(), so the sync/async asymmetry described in the original issue remains.TaskHubGrpcWorkeralready supports context-manager usage, so the sync client is the only public class in this asymmetric state.close()now also tears down resiliency state.Compatibility with PR #135
The proposal below is purely additive and does not clash with the resiliency overhaul. Specifically:
TaskHubGrpcClient.close()already does all the heavy lifting introduced by Add gRPC client and worker connection resiliency #135:self._closing = Trueso any in-flight_maybe_recreate_channelshort-circuits,self._recreate_thread) with a bounded timeout,_retired_channelsclose timers underself._recreate_lock,self._owns_channel, so caller-owned channels are left untouched.__exit__simply callsself.close(). No new state, no new locks, no parallel teardown path — the resiliency invariants from Add gRPC client and worker connection resiliency #135 are preserved.AsyncTaskHubGrpcClient(__aexit__ -> await self.close()), which Add gRPC client and worker connection resiliency #135 also extended; we are bringing the sync client in line with that established pattern.DurableTaskSchedulerClient(azuremanaged) subclassesTaskHubGrpcClientand does not overrideclose(), so it inherits the new context-manager behavior automatically and continues to flow through the resiliency-awareclose().Proposed implementation
Add the two dunder methods immediately after
close()inTaskHubGrpcClient:Notes:
__enter__is annotated to keep type checkers (and the project's Pylance baseline) happy.__exit__deliberately returnsNone(notFalse) so exceptions inside thewithblock propagate unchanged, matchingAsyncTaskHubGrpcClient.__aexit__.__init__,close(),_maybe_recreate_channel,_schedule_recreate,_close_retired_channel, or any resiliency state introduced by Add gRPC client and worker connection resiliency #135.Callsite updates
Migrate existing callsites to
with ...:so they exercise the new path and stop leaking channels in long-running test runs. Scope on currentmain(the original ~40 figure is now slightly larger because of test growth since the issue was filed):TaskHubGrpcClient(...)usagestests/durabletask/test_orchestration_e2e.pytests/durabletask/test_batch_actions.pytests/durabletask/test_work_item_filters_e2e.pytests/durabletask/entities/test_function_based_entities_e2e.pytests/durabletask/test_large_payload_e2e.pyexamples/in_memory_backend_example/test/test_workflows.pytests/durabletask/entities/test_entity_failure_handling.pytests/durabletask/entities/test_class_based_entities_e2e.pytests/durabletask/test_orchestration_versioning_e2e.pyexamples/human_interaction.pyThe
tests/durabletask/test_client.pyconstructor-focused tests that intentionally do not run RPCs can stay as-is (they assert on construction, not on lifecycle), but the e2e suites above should switch towith.The azuremanaged examples (e.g.
examples/activity_sequence.py,examples/fanout_fanin.py,examples/distributed-tracing/app.py,examples/large_payload/app.py, the entity examples, etc.) instantiateDurableTaskSchedulerClientand would benefit from the same migration, since they inherit the new context-manager support for free. They are not strictly required for the fix but are worth folding into the same PR to align with the worker-stylewithpattern those files already use.Example migration (unchanged from the original issue, since it still applies):
Test plan
Add focused unit tests in
tests/durabletask/test_client.py(next to the existing async-context tests and the_closing/recreate tests added by #135):__enter__returns the same instance and__exit__invokesclose()exactly once (assert via a spy onTaskHubGrpcClient.close).withblock on an SDK-owned channel,client._closing is Trueandclient._channel.close()has been called (mirrors the existing async assertion).__exit__does not close the channel (verifies_owns_channelis still respected end-to-end through the context-manager path).withblock propagate, andclose()is still invoked on the way out._schedule_recreate()), then exit thewithblock, and assert that any retired-channel timers are cancelled and that the recreate thread is joined — confirming we do not regress the Add gRPC client and worker connection resiliency #135 teardown invariants.Run as part of the existing pytest suite plus
python -m flake8on the touched files, per repo conventions.Changelog
Add to
CHANGELOG.mdunder## Unreleased:No
durabletask-azuremanaged/CHANGELOG.mdentry is required since the azuremanaged client does not overrideclose()and the behavior change is fully described by the core SDK entry; we can optionally add a cross-reference note there if reviewers prefer.Summary
close()— preserving every resiliency invariant added in Add gRPC client and worker connection resiliency #135.main._closing, recreate thread, retired channels).Add context manager support to TaskHubGrpcClient (microsoft#145)