Skip to content

Fix anyio patch: skip done tasks to prevent zombie-scope hot-loop#3

Open
constkolesnyak wants to merge 1 commit into
mainfrom
fix/anyio-skip-done-tasks
Open

Fix anyio patch: skip done tasks to prevent zombie-scope hot-loop#3
constkolesnyak wants to merge 1 commit into
mainfrom
fix/anyio-skip-done-tasks

Conversation

@constkolesnyak

Copy link
Copy Markdown
Owner

Summary

Third iteration of the anyio _deliver_cancellation hot-loop. Previous patches (ec0f8f7, f1d6329) covered the "only task is current" and "task has _must_cancel" cases. This one was caused by done tasks lingering in CancelScope._tasks — anyio 4.13.0 doesn't always prune them before the cancel callback fires.

For a done task: _must_cancel=False (cleared on final step), _task_started()=True, _fut_waiter=None. The previous patch fell into the "waiter not done → cancel()" branch, called task.cancel() (no-op on done tasks), and still flagged should_retry=True → infinite call_soon.

Observed live

  • Three zombie-scopes in one nerve process at 10:30 on 2026-04-24
  • ~55k epoll_pwait/sec combined, 100% CPU on MainThread
  • Load 1.6, cpu-thermal 60°C
  • Diagnosed via py-spy dump (stack parked on lines 91/95/97/98 of _anyio_patch.py) and a gc-scan of CancelScope objects (each stuck scope had exactly one done=True task)

Changes

  • nerve/_anyio_patch.py: add if task.done(): continue at the top of the cancellation loop; updated module docstring with the third regression history
  • tests/test_anyio_patch.py: new regression test test_no_hot_loop_when_only_task_is_done reproducing the zombie-scope shape with a stubbed done task (asserts no cancel() call, should_retry=False, _cancel_handle=None); existing _FakeTask updated with a done() method

Verification

  • Unit tests: 8/8 passing locally
  • Live patch applied via gdb injection before restart → spin immediately dropped from 109k syscalls/2s to 40 syscalls/2s, load 1.6 → 0.21, temp 60°C → 52.3°C

🤖 Generated with Claude Code

Third iteration of the anyio _deliver_cancellation spin. This time
a CancelScope retained already-finished tasks in self._tasks (anyio
doesn't always prune them before the cancel callback fires). For a
done task _must_cancel=False (cleared on final step), _task_started
is True, _fut_waiter is None — so the previous patch fell into the
"waiter not done → cancel()" branch. task.cancel() is a no-op on
done tasks, but should_retry was flagged anyway, so call_soon kept
re-queuing forever.

Observed live: three zombie-scopes in one process producing ~55k
epoll_pwait/sec combined, 100% CPU on MainThread, load 1.6, 60°C.
Confirmed via py-spy (stack parked on lines 91/95/97/98 of the
patch) and a gc-scan dump of CancelScope objects (three active
_cancel_handle scopes, each with a single done=True task).

Fix: add `if task.done(): continue` at the top of the loop.

Also add regression test that reproduces the zombie-scope shape
with a stubbed done task and asserts no cancel() call, no retry,
and no pending _cancel_handle.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant