Retain the chunk-received trace task in read_nowait - #13300
Conversation
read_nowait is sync but the on-chunk hook is async, so it is scheduled with create_task and the task was discarded — the TODO on that line already noted it. The event loop only holds a weak reference, so the trace event can be collected before it fires, and chunk tracing silently loses events under load. Keeps the task in a per-stream set and discards it in a done callback. Only the "save" half of the TODO. Awaiting it would need a sync/async split of read_nowait, and it would also want the self._timer bound that _fire_chunk_received applies on the async paths — both are design calls, so the TODO is narrowed rather than removed.
Two follow-ups on the fire-and-forget fix: A StreamReader is constructed per response, but _on_chunk_received is only set when client tracing is enabled, so allocating the holding set eagerly in __init__ charged every response for a rarely-used feature. Measured at 46ns per construction on this machine, about +40% on the object construction itself. It is now allocated on first use. Added two tests. One asserts the task is strongly referenced while pending and dropped once it completes, so keeping the reference cannot turn into a leak; the discard arrives one tick late because add_done_callback is delivered via call_soon. The other asserts the set stays unallocated when no hook is set, which pins the lazy behaviour. Both fail against the current upstream streams.py and pass with this change.
|
Pushed 2ef5141 with two follow-ups. The holding set is now allocated lazily. A It is now built on first use, so the common path allocates nothing. Added two tests, both of which fail against the current upstream
On the red Benchmark check — that job is not a performance regression. The run completed |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #13300 +/- ##
==========================================
- Coverage 98.98% 98.98% -0.01%
==========================================
Files 132 132
Lines 49023 49076 +53
Branches 2551 2553 +2
==========================================
+ Hits 48526 48578 +52
Misses 373 373
- Partials 124 125 +1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. |
What
read_nowaitschedules the chunk-received trace hook and drops the task, which theTODOon that line already flags:The loop only keeps a weak reference, so the task can be garbage collected before the hook runs. Chunk tracing then loses events with no error and no log line — the failure is a missing datapoint, which is the hardest kind to notice, and it gets more likely exactly when the loop is busiest.
What this PR does — and does not — do
This is only the "save" half of that TODO.
The async paths already handle this properly through
_fire_chunk_received, which also bounds a hung handler with the per-stream timer:read_nowaitcannot use it because it is synchronous. Awaiting the hook there would need a sync/async split ofread_nowait, and the fire-and-forget task also bypasses theself._timerbound the async paths get. Both are design calls for the maintainers, not something to decide in a drive-by PR — so the TODO is narrowed rather than removed, and now names the remaining piece:_chunk_received_tasksis added to__slots__and initialised next to_on_chunk_received.discardrather thanremoveso a double callback cannot raise; the set stays bounded by the number of in-flight hooks.The
# type: ignore[unused-awaitable]is gone, since the result is bound now.Verification
No test: the failure is a garbage-collection race, so a test would have to force a GC at a chosen moment and assert a task did not vanish — flaky by construction. Happy to add one if you have a shape in mind.
Found with an AST scan for
create_task/ensure_futureresults discarded as bare expression statements (excludingTaskGroup.create_task, which does hold strong references). This was the only hit in the package.