Skip to content

Fix two crashes in the aiokafka threaded producer - #759

Open
wbarnha wants to merge 10 commits into
masterfrom
claude/faust-aiokafka-shutdown-publish-fixes
Open

Fix two crashes in the aiokafka threaded producer#759
wbarnha wants to merge 10 commits into
masterfrom
claude/faust-aiokafka-shutdown-publish-fixes

Conversation

@wbarnha

@wbarnha wbarnha commented Aug 6, 2026

Copy link
Copy Markdown
Member

Both bugs were surfaced by the type checker in #758 and left marked XXX there, because fixing them changes runtime behaviour and that PR was annotation-only. This PR fixes them, each with a regression test.

#758 has since been squash-merged, so this branch has been rebased onto master and now contains only the two fixes and their tests.

1. _shutdown_thread raised TypeError on every producer-thread shutdown

ThreadedProducer._shutdown_thread was a plain def overriding mode.threads.ServiceThread._shutdown_thread, which is async def and is awaited by _serve(). The fix makes it async and awaits super()._shutdown_thread(), preserving mode's teardown and the existing once-only guard.

2. publish_message(wait=True) could never succeed

The waiting branch called Topic._on_published without its required send-future argument. Real topics therefore raised TypeError.

The fix follows Faust's channel abstraction:

  • ChannelT declares _finalize_message.
  • The threaded producer reports send completion, then delegates result and callback handling to channel._finalize_message.
  • This preserves channel-owned completion semantics and supports async callbacks.
  • The non-waiting branch remains unchanged.

Why the existing test missed it

The existing test used a bare Mock() channel, which accepts the malformed call. The regression test uses a real topic and verifies the future result, callback, and sensor completion.

Tests

Added regression coverage for the real-topic wait path and all shutdown paths.

Both were found by the type checker in #758 and left marked `XXX` there
because fixing them changes runtime behaviour.

`ThreadedProducer._shutdown_thread` was a plain `def` overriding
`mode.threads.ServiceThread._shutdown_thread`, which is `async def` and is
awaited by `_serve()` in a `finally:`.  Every shutdown of the producer thread
therefore evaluated `await None` and raised TypeError.  The thread only
recovered because `_start_thread` catches that exception and calls
`set_shutdown()` before re-raising -- so mode's teardown
(`on_thread_stop`, stopping children, futures and exit stacks) never ran, and
the thread died with a traceback instead of stopping cleanly.

The override also scheduled `on_thread_stop()` with
`asyncio.run_coroutine_threadsafe` onto `self.thread_loop` -- the loop that
was about to stop, and the loop already running `_serve()`.  Because the
TypeError tore down `run_until_complete` immediately, that coroutine never got
a chance to run, so the producer was never flushed or stopped on this path.

Make it `async def` and await `super()._shutdown_thread()`, which runs
`on_thread_stop()` and the rest of mode's teardown in order.  The once-only
guard is kept; when shutdown has already been initiated the shutdown event is
still set, matching what the old TypeError path ended up doing via
`_start_thread`.

`ThreadedProducer.publish_message(wait=True)` called
`fut.message.channel._on_published(message=..., state=..., producer=...)`.
`Topic._on_published` takes the send future as a required *positional* `fut`
and reads the result off it, so the call raised
`TypeError: Topic._on_published() missing 1 required positional argument`.
The waiting branch has no such future -- `send_and_wait` has already resolved
-- so complete the message directly instead: report the sensor, set the
result, and invoke the callback, which is what `Topic.publish_message(wait=True)`
does via `_finalize_message`.  The non-waiting branch keeps using
`_on_published` as a done-callback, where `add_done_callback` supplies `fut`.

`test_publish_message_with_wait` did not catch this because its channel is a
bare `Mock`, which accepts any call; the new test uses a real topic and fails
with the TypeError above against the previous code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012N2yysiNbzzVcvYhtrpsM7
@wbarnha
wbarnha force-pushed the claude/faust-aiokafka-shutdown-publish-fixes branch from 805596d to 02a772c Compare August 6, 2026 20:08
@wbarnha
wbarnha changed the base branch from claude/faust-mypy-compat-xyb41h to master August 6, 2026 20:08
@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.20%. Comparing base (da5e8c1) to head (50805b7).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #759      +/-   ##
==========================================
- Coverage   96.20%   96.20%   -0.01%     
==========================================
  Files         110      110              
  Lines       11790    11791       +1     
  Branches     1281     1281              
==========================================
  Hits        11343    11343              
- Misses        350      351       +1     
  Partials       97       97              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

wbarnha commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

codecov/project is red at -0.01% (96.05% → 96.04%). Everything else on 697a379 is green, including the required ✅ Ensure the required checks passing gate, all 15 CPython legs, lint (mypy -p faust), CodeQL and both Kafka integration legs.

I'm not pushing a change for it, because there is no uncovered line to cover.

Codecov's own patch check agrees: "All modified and coverable lines are covered by tests." The project delta comes from its summary reporting Lines +3, Hits +2, Misses +1 — but that extra miss is not reproducible. Diffing full-package coverage locally between master (0866b77) and this head, across all 109 measured files:

faust/transport/drivers/aiokafka.py:  master = 699 stmts / 49 miss
                                       #759  = 702 stmts / 49 miss

That is the only file whose numbers move anywhere in the package — 3 new statements, all 3 covered, misses flat. The missing lines are also the same set on both sides, just shifted by two line numbers.

So the -0.01% is a single line out of ~11,084 appearing as uncovered in the merged 15-leg report while every individual leg covers it. codecov/project runs on target: auto with threshold: 0%, so a one-line difference is enough to fail it.

Worth noting this is structural rather than specific to this PR: the repo has no codecov.yml at all, so codecov runs on defaults. A small config would stop both this and the transient red that every PR here shows for the first few minutes of a run (codecov scores partial uploads as legs finish, e.g. 5 of 16 → "32.50% (-63.56%)", then corrects itself):

codecov:
  notify:
    after_n_builds: 15   # 5 pythons x 2 cython (aiokafka) + 5 confluent.
                         # Not 16 - the PyPy leg is continue-on-error and
                         # uploads conditionally, so waiting on it can hang.
coverage:
  status:
    project:
      default:
        threshold: 0.5%  # tolerate cross-leg variance

Happy to open that as a separate PR if wanted — it doesn't belong in this one.


Generated by Claude Code

wbarnha and others added 8 commits August 12, 2026 06:30
The wait=True branch now awaits ``channel._finalize_message(fut, ret)``,
but this test still passed a bare ``Mock()`` as the channel.  A plain Mock
returns a Mock, which is not awaitable, so every aiokafka test leg failed
with ``TypeError: object Mock can't be used in 'await' expression``.

Give the stand-in channel an ``AsyncMock`` ``_finalize_message``, matching
the coroutine every real channel implements.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5Hzidb2taZ7ci6AeBUuJT
The 3.14t free-threaded leg failed teardown with:

    DirtyTest: ('Left over tasks', ...
      "<Task pending coro=<sleep()> cb=[_chain_future._call_set_state()]>")

That task is mode's, not faust's: ServiceThread._wakeup_timer_in_thread
ends every keepalive tick with

    run_coroutine_threadsafe(asyncio.sleep(0), self.parent_loop)

and fires one last tick as the thread stops.  The task completes on the
next iteration of the parent loop -- but the autouse tasks_not_lingering
fixture snapshots tasks as soon as the test coroutine returns, so if the
loop never runs again it is still pending and gets reported.

It surfaced here because _shutdown_thread now performs mode's real
teardown instead of raising TypeError out of _serve(), which gives the
keepalive room to tick before the thread goes away.

Yield once after stop() so the transient task settles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5Hzidb2taZ7ci6AeBUuJT
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.

2 participants