Skip to content

fix(weixin): track fire-and-forget tasks to prevent GC mid-flight - #11998

Closed
alexzhu0 wants to merge 1 commit into
NousResearch:mainfrom
alexzhu0:fix/platform-adapters-track-async-tasks
Closed

fix(weixin): track fire-and-forget tasks to prevent GC mid-flight#11998
alexzhu0 wants to merge 1 commit into
NousResearch:mainfrom
alexzhu0:fix/platform-adapters-track-async-tasks

Conversation

@alexzhu0

Copy link
Copy Markdown
Contributor

What & why

`_poll_loop` spawned `_process_message_safe` with a bare `asyncio.create_task(...)` for every inbound message, and `_process_message` did the same for `_maybe_fetch_typing_ticket`. Python's event loop only keeps a weak reference to tasks returned by `create_task`; the Python docs explicitly warn that untracked tasks can be garbage-collected mid-flight.

The weixin long-poll drains buffered messages in tight bursts, so a GC pass at the wrong moment silently drops the user's message after the sync buffer has already been advanced — the lost message cannot be re-polled.

Same root cause as the DingTalk fix in PR #11997 (separate PR because the adapters don't share infrastructure for this).

Change

Mirror the tracking pattern the DingTalk adapter uses:

  • `self._bg_tasks: Set[asyncio.Task] = set()` in `init`
  • `_spawn_bg(coro)` helper — `create_task` + add to set + `add_done_callback(discard)`
  • Route both fire-and-forget sites through `_spawn_bg`:
    • `_poll_loop` → `_process_message_safe` (inbound dispatch)
    • `_process_message` → `_maybe_fetch_typing_ticket`
  • `disconnect()` cancels + awaits `_bg_tasks` before closing HTTP sessions, so no orphaned tasks keep writing after teardown

How to test

```bash
pytest tests/gateway/test_weixin.py -q
```

44 passed (2 new). Regression tests in `TestWeixinBackgroundTaskTracking`:

  • `test_spawn_bg_tracks_task_and_discards_on_completion` — single task is added to `_bg_tasks` while awaiting, removed by the done-callback after completion.
  • `test_spawn_bg_captures_multiple_concurrent_tasks` — 5 concurrent tasks all tracked simultaneously, all drained after completion.

Platforms tested

  • macOS (Darwin 25.3.0), Python 3.11.13. Change is platform-agnostic.

Related

Found during a proactive audit of untracked `asyncio.create_task` sites across platform adapters. Companion to PR #11997 (dingtalk). Further untracked sites exist in `qqbot`, `bluebubbles`, `rl_training_tool` — keeping each adapter its own PR to keep reviews scoped.

_poll_loop spawned _process_message_safe with a bare
asyncio.create_task(...) for every inbound message, and
_process_message spawned _maybe_fetch_typing_ticket the same way.
Python's event loop only keeps a weak reference to tasks returned by
create_task — the Python docs explicitly warn that untracked tasks can
be garbage-collected mid-flight. The weixin long-poll drains buffered
messages in tight bursts, so a GC pass at the wrong moment silently
drops a user's message after the sync buffer has already advanced.

Mirror the tracking pattern the DingTalk adapter uses:
- Add self._bg_tasks: Set[asyncio.Task] in __init__
- Add _spawn_bg(coro) helper that create_task's, adds to the set, and
  installs a done-callback to discard the task on completion
- Route both fire-and-forget sites (inbound dispatch +
  typing-ticket fetch) through _spawn_bg
- Cancel + await _bg_tasks in disconnect() before closing HTTP
  sessions, so no orphaned tasks keep writing after teardown

Two regression tests cover the tracking lifecycle: a single task is
added and discarded on completion, and multiple concurrent tasks are
all captured simultaneously.
haileymarshall added a commit to haileymarshall/hermes-agent that referenced this pull request Apr 18, 2026
`handle_401` spawned a dedup'd recovery coroutine via
`asyncio.create_task(_do_handle())` and discarded the returned task
reference. Python's event loop only keeps weak references to tasks, so
the coroutine could be garbage-collected before it called
`pending.set_result(...)`. Every concurrent caller awaiting that future
then hangs forever, and the `finally: entry.pending_401.pop(...)`
cleanup never runs — so subsequent 401s for the same key latch onto the
dead future too. Same pattern the adapter-side fixes address (NousResearch#11997,
NousResearch#11998, NousResearch#12000, NousResearch#12001, NousResearch#12006).

Hold the task in a process-wide set on the manager and discard it via
`add_done_callback` once it completes. Regression test covers both the
structural invariant (task tracked, then removed on completion) and a
concurrent dedup path with a forced `gc.collect()` between the handler's
await points.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery labels Apr 24, 2026
@alexzhu0

Copy link
Copy Markdown
Contributor Author

Closing as part of post-mortem cleanup of an early-batch proactive audit that did not get review traction. The patch still applies if anyone wants to repurpose it. My contribution methodology has moved to alexzhu0/echo-agent — not pursuing this individual fix further.

@alexzhu0 alexzhu0 closed this May 20, 2026
teknium1 pushed a commit that referenced this pull request Jun 30, 2026
`handle_401` spawned a dedup'd recovery coroutine via
`asyncio.create_task(_do_handle())` and discarded the returned task
reference. Python's event loop only keeps weak references to tasks, so
the coroutine could be garbage-collected before it called
`pending.set_result(...)`. Every concurrent caller awaiting that future
then hangs forever, and the `finally: entry.pending_401.pop(...)`
cleanup never runs — so subsequent 401s for the same key latch onto the
dead future too. Same pattern the adapter-side fixes address (#11997,
#11998, #12000, #12001, #12006).

Hold the task in a process-wide set on the manager and discard it via
`add_done_callback` once it completes. Regression test covers both the
structural invariant (task tracked, then removed on completion) and a
concurrent dedup path with a forced `gc.collect()` between the handler's
await points.
dtera pushed a commit to dtera/hermes-agent that referenced this pull request Jul 1, 2026
`handle_401` spawned a dedup'd recovery coroutine via
`asyncio.create_task(_do_handle())` and discarded the returned task
reference. Python's event loop only keeps weak references to tasks, so
the coroutine could be garbage-collected before it called
`pending.set_result(...)`. Every concurrent caller awaiting that future
then hangs forever, and the `finally: entry.pending_401.pop(...)`
cleanup never runs — so subsequent 401s for the same key latch onto the
dead future too. Same pattern the adapter-side fixes address (NousResearch#11997,
NousResearch#11998, NousResearch#12000, NousResearch#12001, NousResearch#12006).

Hold the task in a process-wide set on the manager and discard it via
`add_done_callback` once it completes. Regression test covers both the
structural invariant (task tracked, then removed on completion) and a
concurrent dedup path with a forced `gc.collect()` between the handler's
await points.
waefrebeorn pushed a commit to waefrebeorn/slermes that referenced this pull request Jul 2, 2026
`handle_401` spawned a dedup'd recovery coroutine via
`asyncio.create_task(_do_handle())` and discarded the returned task
reference. Python's event loop only keeps weak references to tasks, so
the coroutine could be garbage-collected before it called
`pending.set_result(...)`. Every concurrent caller awaiting that future
then hangs forever, and the `finally: entry.pending_401.pop(...)`
cleanup never runs — so subsequent 401s for the same key latch onto the
dead future too. Same pattern the adapter-side fixes address (NousResearch#11997,
NousResearch#11998, NousResearch#12000, NousResearch#12001, NousResearch#12006).

Hold the task in a process-wide set on the manager and discard it via
`add_done_callback` once it completes. Regression test covers both the
structural invariant (task tracked, then removed on completion) and a
concurrent dedup path with a forced `gc.collect()` between the handler's
await points.
Jasper6439 pushed a commit to Jasper6439/hermes-agent that referenced this pull request Jul 5, 2026
`handle_401` spawned a dedup'd recovery coroutine via
`asyncio.create_task(_do_handle())` and discarded the returned task
reference. Python's event loop only keeps weak references to tasks, so
the coroutine could be garbage-collected before it called
`pending.set_result(...)`. Every concurrent caller awaiting that future
then hangs forever, and the `finally: entry.pending_401.pop(...)`
cleanup never runs — so subsequent 401s for the same key latch onto the
dead future too. Same pattern the adapter-side fixes address (NousResearch#11997,
NousResearch#11998, NousResearch#12000, NousResearch#12001, NousResearch#12006).

Hold the task in a process-wide set on the manager and discard it via
`add_done_callback` once it completes. Regression test covers both the
structural invariant (task tracked, then removed on completion) and a
concurrent dedup path with a forced `gc.collect()` between the handler's
await points.
habarmc1223-sudo pushed a commit to habarmc1223-sudo/hermes-agent-fluxmem that referenced this pull request Jul 8, 2026
`handle_401` spawned a dedup'd recovery coroutine via
`asyncio.create_task(_do_handle())` and discarded the returned task
reference. Python's event loop only keeps weak references to tasks, so
the coroutine could be garbage-collected before it called
`pending.set_result(...)`. Every concurrent caller awaiting that future
then hangs forever, and the `finally: entry.pending_401.pop(...)`
cleanup never runs — so subsequent 401s for the same key latch onto the
dead future too. Same pattern the adapter-side fixes address (NousResearch#11997,
NousResearch#11998, NousResearch#12000, NousResearch#12001, NousResearch#12006).

Hold the task in a process-wide set on the manager and discard it via
`add_done_callback` once it completes. Regression test covers both the
structural invariant (task tracked, then removed on completion) and a
concurrent dedup path with a forced `gc.collect()` between the handler's
await points.
santhreal pushed a commit to santhreal/hermes-agent that referenced this pull request Jul 13, 2026
`handle_401` spawned a dedup'd recovery coroutine via
`asyncio.create_task(_do_handle())` and discarded the returned task
reference. Python's event loop only keeps weak references to tasks, so
the coroutine could be garbage-collected before it called
`pending.set_result(...)`. Every concurrent caller awaiting that future
then hangs forever, and the `finally: entry.pending_401.pop(...)`
cleanup never runs — so subsequent 401s for the same key latch onto the
dead future too. Same pattern the adapter-side fixes address (NousResearch#11997,
NousResearch#11998, NousResearch#12000, NousResearch#12001, NousResearch#12006).

Hold the task in a process-wide set on the manager and discard it via
`add_done_callback` once it completes. Regression test covers both the
structural invariant (task tracked, then removed on completion) and a
concurrent dedup path with a forced `gc.collect()` between the handler's
await points.
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
`handle_401` spawned a dedup'd recovery coroutine via
`asyncio.create_task(_do_handle())` and discarded the returned task
reference. Python's event loop only keeps weak references to tasks, so
the coroutine could be garbage-collected before it called
`pending.set_result(...)`. Every concurrent caller awaiting that future
then hangs forever, and the `finally: entry.pending_401.pop(...)`
cleanup never runs — so subsequent 401s for the same key latch onto the
dead future too. Same pattern the adapter-side fixes address (NousResearch#11997,
NousResearch#11998, NousResearch#12000, NousResearch#12001, NousResearch#12006).

Hold the task in a process-wide set on the manager and discard it via
`add_done_callback` once it completes. Regression test covers both the
structural invariant (task tracked, then removed on completion) and a
concurrent dedup path with a forced `gc.collect()` between the handler's
await points.
leewenjie pushed a commit to leewenjie/hermes-agent that referenced this pull request Aug 7, 2026
`handle_401` spawned a dedup'd recovery coroutine via
`asyncio.create_task(_do_handle())` and discarded the returned task
reference. Python's event loop only keeps weak references to tasks, so
the coroutine could be garbage-collected before it called
`pending.set_result(...)`. Every concurrent caller awaiting that future
then hangs forever, and the `finally: entry.pending_401.pop(...)`
cleanup never runs — so subsequent 401s for the same key latch onto the
dead future too. Same pattern the adapter-side fixes address (NousResearch#11997,
NousResearch#11998, NousResearch#12000, NousResearch#12001, NousResearch#12006).

Hold the task in a process-wide set on the manager and discard it via
`add_done_callback` once it completes. Regression test covers both the
structural invariant (task tracked, then removed on completion) and a
concurrent dedup path with a forced `gc.collect()` between the handler's
await points.
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
`handle_401` spawned a dedup'd recovery coroutine via
`asyncio.create_task(_do_handle())` and discarded the returned task
reference. Python's event loop only keeps weak references to tasks, so
the coroutine could be garbage-collected before it called
`pending.set_result(...)`. Every concurrent caller awaiting that future
then hangs forever, and the `finally: entry.pending_401.pop(...)`
cleanup never runs — so subsequent 401s for the same key latch onto the
dead future too. Same pattern the adapter-side fixes address (NousResearch#11997,
NousResearch#11998, NousResearch#12000, NousResearch#12001, NousResearch#12006).

Hold the task in a process-wide set on the manager and discard it via
`add_done_callback` once it completes. Regression test covers both the
structural invariant (task tracked, then removed on completion) and a
concurrent dedup path with a forced `gc.collect()` between the handler's
await points.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants