fix(io): a held connection ends on a pause, not on a parked producer (#377) - #518
Merged
superuser404notfound merged 4 commits intoSep 9, 2026
Conversation
…uperuser404notfound#377) The 5 s full-window end fires during playback, not at a pause. Its own comment names the case it is for -- "a consumer that has STOPPED", "a paused viewer" -- but it infers that from a full window, and the segment producer fills the window and parks with a full segment cache while the muxer works. From the reader that is indistinguishable from a stopped consumer, so the fallback fires every ~17 s. Measured on an Apple TV 4K 3rd gen, tvOS 26.6, against the origin from superuser404notfound#377, one hour of playback with the viewer never pausing: 213 idle ends and 218 connections, where the design describes one. Against an origin that refuses requests, 218 connections are 218 chances to be refused, and the same hour without the flag drew 157 refusals across 1055 connections. So the wait now carries a deadline only while the consumer is actually paused, read from the same `playIntentProvider` the segment producer already uses, plumbed engine -> demuxer -> reader alongside `onNetworkPhaseChanged`. A playing consumer with a full window issues no read and waits: no byte is on the wire, the socket fills, and the sender stops itself, which is what every ffmpeg-based player does whenever its demuxer stops reading. The pause keeps a bound, at 300 s rather than 5. That is the unbounded case superuser404notfound#310's worst episode came from (11 minutes dormant), and it is the only one left once a parked producer is no longer read as a stopped one. Same device, same source, ten minutes: 6 connections, all in the open phase, 0 ends, 0 refusals. The count stops growing after the open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suite asserted the old contract, that a consumer which stops drawing ends the connection. That is the behaviour this change is fixing: a parked segment producer stops drawing and is not paused. - `stalledConsumerEndsTheHeldConnection` becomes `pausedConsumerEnds...` and drives `playIntentProvider` to say so. - A new case asserts the other half: a PLAYING consumer that has stopped drawing keeps its connection and the origin is still asked exactly once. - `refillAfterIdleEnd` drives a pause and then lifts it, since a pause is now what ends a held connection. `heldPausedBudgetSeconds` is an instance property so a test can express a pause without sleeping through the shipped 300 s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eness flag The playing case asserted hasLiveConnectionForTesting eight seconds in, which passed locally and failed on CI: it is a check on when the sample lands rather than on what the reader did. It now draws again after the wait and asserts the origin was still asked exactly once, which is the actual contract and is the same witness the pushed control is measured against. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l to start
Two things the first cut got wrong, both found by driving a real 7-minute
pause on the device rather than by reading it.
The delivery-gap watchdog ended the connection at 20 s, so the 300 s bound
was unreachable and a pause was reported as a stall:
pump gen=7 no delivery for 20.1s at offset 2341879269
(16344KB read-ahead held, had delivered data); ending it
Its verdict was written for a transport that ends at the high water, where a
connection holding a full window and delivering nothing really is faulted. A
held connection on a full window has no read outstanding: the pump asked for
a budget, was told there was no room, and is waiting. Nothing is late. So the
watchdog stands aside in exactly that state and still owns the case this path
can have, bytes asked for and none arriving.
The paused budget then still never started, because nothing broadcasts a
pause: no one reads during one, so a consumer that pauses AFTER the window
fills would leave the pump blocked in `winCond.wait()` for the length of the
pause. Measured: a 420 s pause held the connection to the end of the drill
with no bound spent, which is the superuser404notfound#310 exposure this was supposed to bound.
The playing branch polls the play intent once a second instead.
Device, tvOS 26.6, 90 s of playback then a 420 s pause:
held connection paused for 300s with 15MB ahead; ending it
(no delivery-gap line)
resume cost one connection at the frontier, 0 refusals
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
superuser404notfound
merged commit Sep 9, 2026
ff50904
into
superuser404notfound:main
5 checks passed
ijuniorfu
pushed a commit
to ijuniorfu/AetherEngine
that referenced
this pull request
Sep 10, 2026
…#377) The delivery-gap watchdog measures from the last delivery, and a held connection waiting on a full window has no read outstanding to measure. PR superuser404notfound#518 lets that wait run past `connStallTimeout`, and the old clock kept running through it, so two things followed. The watchdog re-armed at its 20 ms floor for the length of the park: the remaining-gap re-arm collapses to `max(0.02, ...)` once the gap outgrows the timeout, which is a 50 Hz timer taking the window lock for as long as the producer stays parked. And the read the consumer's return issues was born already late. The next tick, at most 20 ms away, saw a gap older than the timeout and ended a connection that had been healthy throughout, then re-requested at the frontier. That is the request this flag exists to remove, booked as a stall in the log. Loopback wins that race with its own delivery, which is why the request count could not state it; the measured gap can, and did (4.5 s accumulated across a 5 s park). The clock now belongs to an outstanding read. The watchdog restarts it while it stands aside, and the pull budget restarts it when it grants a budget, which is the moment a read begins. Three more from reading the merged branch. A nil play intent is read as stopped rather than as playing: every path that plays wires the provider, so a reader without one is a consumer the engine cannot vouch for, and "playing" would let it hold a dormant flow for its whole life, which is the superuser404notfound#310 exposure the flag is supposed to bound. The provider sits behind a leaf lock like the phase sink next to it, because the demuxer forwards it whenever the engine assigns it, which may be after `open()`, while the pump thread reads it. And the two doc blocks the branch left attached to the wrong declarations now sit on what they describe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDKAJrLjLXVsCvJVNdJEPw
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The 5 s full-window end fires during playback rather than at a pause, which spends most of what
heldSourceConnectionis for. Measured on the origin from #377 rather than reasoned about.What the field run showed
Apple TV 4K 3rd gen, tvOS 26.6, 6.75.0, one hour with the flag on and one hour without, same title, same evening, same edge, back to back:
The flag works. But 218 is not the one connection the design describes, and the reason is in the log:
213 of those in the hour, and the viewer never paused once.
Why it fires
The constant's own comment names the case it is for:
But it infers "stopped" from a full window, and
HLSSegmentProducerraces ahead, fills the window, and parks with a full segment cache while the muxer works (parked=10sin the same capture). From the reader those are the same thing, so the pause fallback fires roughly every 17 seconds during ordinary playback.That matters more here than the request count suggests. 218 connections against an origin that refuses requests are 218 chances to be refused, and 3.6 per minute is about 435 over a two hour film.
The change
The wait carries a deadline only while the consumer is actually paused. The signal is
playIntentProvider, whichHLSVideoEnginealready owns and already hands to the producer; this plumbs the same closure to the reader alongsideonNetworkPhaseChanged, engine → demuxer → reader. Nil means playing, so a reader nobody wires keeps today's behaviour.A playing consumer with a full window issues no read and waits. No byte is on the wire, the socket buffer fills, and the sender stops itself, which is what an ffmpeg-based player does whenever its demuxer stops reading.
A paused one keeps a bound, at 300 s rather than 5. That is the unbounded stretch #310's worst episode came from, and it is the only one left once a parked producer is no longer read as a stopped one.
After
Same device, same source, ten minutes:
The count stops growing once the file is open, which is the shape ffmpeg has against this same URL (three requests, one connection).
What is not measured
A multi-minute pause with the connection held. The 300 s bound is reasoned from #310's dose rather than measured, and if you would rather it were smaller, or driven by something other than wall clock, that is a number I have no evidence for. Arm B of the transport probe held a stream task on a closed window for 60 s on this device with 1 Hz canaries clean against both the origin and a neutral host, and arm C could not reproduce #220's mechanism there at all, but neither of those reaches a ten minute pause.
Happy to run whatever else against this origin.