fix(defer): make shared EventLoopThread teardown safe for siblings and re-entrant kills - #1794
Open
LHMQ878 wants to merge 1 commit into
Open
fix(defer): make shared EventLoopThread teardown safe for siblings and re-entrant kills#1794LHMQ878 wants to merge 1 commit into
LHMQ878 wants to merge 1 commit into
Conversation
…d re-entrant kills EventLoopThread instances are cached per thread name, but kill(terminate_thread=True) tore the loop down as if it owned it. Two failures followed, both on the paths that chat deletion and scheduler task deletion take. Killing one task cancelled every sibling on the same name. The drain in kill() cancels all pending tasks on the loop, and TaskScheduler gives every scheduled task one fixed name, so deleting one running task cancelled all of them. The cancellation surfaces only on the abandoned task's own future, so nothing reported it. Each task now registers with acquire() and drops its claim in kill(); the loop is stopped only once the count reaches zero. kill(terminate_thread=True) also deadlocked permanently when it ran on the loop's own thread. A task's done-callback runs there and kills its children, so a child sharing the parent's name reached an untimed run_coroutine_threadsafe(...).result() waiting on a coroutine that only that thread could advance. terminate() had the mirror problem in thread.join(). The in-loop path now chains the drain and the stop onto the loop rather than waiting on it, the loop closes itself as run_forever returns, and the off-thread waits are bounded by DRAIN_TIMEOUT so a task that ignores cancellation cannot hang a request handler. Because the in-loop path cannot clear the instance's loop and thread attributes, _start treats a closed loop or a dead thread as absent and rebuilds both together, keeping a torn-down thread name reusable. _start also no longer replaces the loop alone, which would have left it unattended. Fixes agent0ai#1793 Tests: 7 added to tests/test_defer_lifecycle.py. Reverting only helpers/defer.py fails 5 of them and wedges the sixth until the pytest timeout fires; the seventh guards the fix rather than the bug and is documented as such. Full collectible suite: 941 passed, with the failing-test set name-identical to the base (101 pre-existing Windows and missing-dependency failures, no additions or removals).
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.
Description
EventLoopThreadinstances are cached per thread name, butDeferredTask.kill(terminate_thread=True)tore the loop down as if it owned it. Two failures followed, both on the paths that chat deletion and scheduler task deletion take.Fixes #1793
Based on
ready, following the branch recent merged PRs use.Root cause
1. Killing one task cancelled every sibling on the same name. The drain in
kill()cancels all pending tasks on the loop, andTaskSchedulergives every scheduled task one fixed name (helpers/task_scheduler.py:1013), so deleting one running task cancelled all of them. Reached fromapi/scheduler_task_delete.py:37,api/chat_remove.py:12andapi/chat_reset.py:13, all of which passterminate_thread=True.THREAD_BACKGROUNDis shared process-wide the same way.The failure is silent: the cancellation surfaces only on the abandoned task's own future, which nobody holds, so there is no log line — the sibling task simply stops.
2.
kill(terminate_thread=True)deadlocked permanently when it ran on the loop's own thread._on_task_doneis aconcurrent.futuresdone-callback, so it runs on the loop thread, and it kills children. A child sharing the parent's thread name therefore reachedfrom inside the loop, waiting on a coroutine only that thread could advance.
terminate()had the mirror problem in its unboundedthread.join(), and itsloop.close()would raise on a loop that is still running. The stack of the wedged thread in #1793 shows it stopped atdefer.py:176and never moved; every other task on that loop is stuck with it, and the thread being a daemon means no shutdown error surfaces either.The change
Refcount the shared thread. Each
DeferredTaskregisters withacquire()on construction and drops its claim inkill().release(terminate)reports whether the caller may tear the loop down, and only the last user may:Unregistering happens under the same lock that makes the decision, so a task constructed after this point gets a fresh thread rather than one on its way out.
Never wait on the loop from inside it. The in-loop path chains the drain and the stop onto the loop instead:
Chained rather than scheduled independently, so the stop cannot land before the drain finishes.
terminate()on its own thread likewise only requests the stop; the loop is closed from inside_run_event_loop'sfinally, sinceclose()raises on a running loop and the in-loop caller cannot join first.Bound the off-thread waits with
DRAIN_TIMEOUT.kill(terminate_thread=True)runs on request threads, so a task that swallows cancellation must not hang chat deletion. The previous.result()andjoin()had no bound at all.Two details that are consequences of the above rather than separate choices:
_starttreats a closed loop or a dead thread as absent. The in-loop teardown path cannot null outloop/thread— it is running inside a callback the loop still has to return from — so a torn-down instance keeps them attached, and a later task on the same name would be handed a closed loop._startrebuilds the loop and thread together, and_run_event_looptakes its loop as an argument. Replacing only the loop would leave it unattended, because the surviving thread runs the loop it was handed.Deliberately not changed:
_drain_event_loop_tasksstill cancels every task on the loop. That is correct once the loop provably has one user, which the refcount is what establishes.Relationship to #1781
Complementary, different files, no conflict. #1781 moves
parallel_toolsoff the sharedTHREAD_BACKGROUNDonto per-job names, which avoids the shared-thread problem for that one caller by not sharing. It does not touchdefer.py, soTaskScheduler,THREAD_BACKGROUND, the settings and plugin callers and the memorize extensions all still share a name. #1781 also adds akill(terminate_thread=True)on timeout, which makes reaching failure mode 2 more likely rather than less. Either can merge first.Tests
tests/test_defer_lifecycle.pycovered the single-task lifecycle only, and every existing test uses a freshuuid-suffixed thread name — so nothing exercised two tasks on one name, which is why neither failure was caught. Seven tests added:test_killing_one_task_does_not_cancel_siblings_on_the_shared_threadkill(terminate_thread=True)test_killing_from_the_loops_own_thread_does_not_deadlocktest_last_task_to_be_killed_still_tears_the_thread_downtest_teardown_requested_from_inside_the_loop_still_completestest_a_restarted_task_keeps_its_claim_on_the_threadrestart()goes throughkill(), so it must re-register or a sibling's later kill stops the loop under ittest_killing_the_same_task_twice_does_not_release_the_thread_twice__del__also callskill(), andclose_runtime_synckills in afinallyafter a possibly-already-killed tasktest_a_new_task_after_teardown_gets_a_working_threadThree things worth flagging for review:
test_killing_one_task_does_not_cancel_siblings_on_the_shared_threadasserts on the survivor's own result, not on a progress counter. A cancelled task raises there, whereas asserting that the counter advanced could pass on work completed before the kill landed.test_killing_from_the_loops_own_thread_does_not_deadlockprobes by scheduling onto the loop, not by elapsed time. The parent's result arrives before its done-callback runs, so a wedge introduced by that callback would otherwise go unnoticed.test_a_new_task_after_teardown_gets_a_working_threadpasses on the unfixed code too, and the docstring says so. It guards the risk the fix introduces — teardown now leaves a closed loop attached in the in-loop case — rather than the original bug. An earlier draft oftest_teardown_requested_from_inside_the_loop_still_completesalso passed against unfixed source, because it reached the in-loop branch only by winning a race with the done-callback; it now invokeskill()on the loop directly.Control experiment
Reverting only
helpers/defer.pyand keeping the tests, run per-test:..._does_not_cancel_siblings_on_the_shared_thread..._last_task_to_be_killed_still_tears_the_thread_down..._a_restarted_task_keeps_its_claim_on_the_thread..._killing_the_same_task_twice_does_not_release_the_thread_twice..._teardown_requested_from_inside_the_loop_still_completes..._killing_from_the_loops_own_thread_does_not_deadlock..._a_new_task_after_teardown_gets_a_working_threadRun per-test because the deadlock takes the whole file down with it — worth knowing when reviewing: a regression here stalls the run rather than reporting, so
--timeoutis needed. That is noted in the module's DOX.Verification
tests/test_defer_lifecycle.pyThe 101 pre-existing failures and 12 collection errors are this Windows environment: POSIX-only modules (
fcntl,resource), symlink and permission semantics, absent optional dependencies and API keys. Verified by running the same suite on the base commit and diffing failing test names rather than counts — counts alone shift when tests are added to a file.helpers/defer.py.dox.mdupdated per thehelpers/DOX contract: the new public methods, the shared-thread and re-entrancy contracts, and the timeout note for running the tests.