Skip to content

fix(bluebubbles): track fire-and-forget mark_read task to prevent GC - #12006

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

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

Conversation

@alexzhu0

Copy link
Copy Markdown
Contributor

What & why

The inbound webhook handler in the BlueBubbles adapter already stored its main `handle_message` task in `self._background_tasks` (with a done-callback that discards on completion), but the adjacent `mark_read` read-receipt task was scheduled with a bare `asyncio.create_task(...)` and the return value discarded.

Python's event loop only holds a weak reference to tasks returned by `create_task`; the Python docs explicitly warn that untracked tasks can be garbage-collected before completing. In the webhook-receipt path this means a GC pass between task creation and the first `await` inside `mark_read` silently drops the read receipt — users see their messages as "unread" on the iMessage side even though the agent processed them.

Fifth PR in the `asyncio.create_task` audit — see #11997 (dingtalk), #11998 (weixin), #12000 (qqbot), #12001 (rl_training). Smaller than the others because the adapter already had the tracking infrastructure — only the `mark_read` site was missed.

Change

Route the task through the existing `_background_tasks` set with the same done-callback pattern used for `handle_message` five lines up:

```python
mark_read_task = asyncio.create_task(self.mark_read(session_chat_id))
self._background_tasks.add(mark_read_task)
mark_read_task.add_done_callback(self._background_tasks.discard)
```

How to test

No existing `mark_read`-specific tests; the change follows the identical tracking pattern established for `handle_message` two blocks up in the same method.

Import sanity:

```bash
python -c "from gateway.platforms.bluebubbles import BlueBubblesAdapter; print('ok')"
```

Platforms tested

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

Related

Companion to the earlier `asyncio.create_task` audit PRs in this family.

The inbound webhook handler already stored its main handle_message
task in self._background_tasks (with a done-callback that discards
on completion), but the adjacent mark_read read-receipt task was
scheduled with a bare asyncio.create_task(...) and the return value
discarded.

Python's event loop only holds a weak reference to tasks returned by
create_task; the docs explicitly warn that untracked tasks can be
garbage-collected before completing. In the webhook-receipt path
this means a GC pass between task creation and the first await
inside mark_read silently drops the read receipt — users see their
messages as "unread" on the iMessage side even though the agent
processed them.

Route the task through the existing _background_tasks set with the
same done-callback pattern used for handle_message five lines up.
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 P3 Low — cosmetic, nice to have 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 P3 Low — cosmetic, nice to have type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants