Skip to content

fix(io): a held connection ends on a pause, not on a parked producer (#377) - #518

Merged
superuser404notfound merged 4 commits into
superuser404notfound:mainfrom
Rasmusmart57:fix/ae377-held-connection-ends-on-pause
Sep 9, 2026
Merged

fix(io): a held connection ends on a pause, not on a parked producer (#377)#518
superuser404notfound merged 4 commits into
superuser404notfound:mainfrom
Rasmusmart57:fix/ae377-held-connection-ends-on-pause

Conversation

@Rasmusmart57

Copy link
Copy Markdown
Contributor

The 5 s full-window end fires during playback rather than at a pause, which spends most of what heldSourceConnection is 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:

                       held    pushed
connections             218      1055
429s                      0       157

The flag works. But 218 is not the one connection the design describes, and the reason is in the log:

[AVIOReader] pump held connection idle for 5s with 15MB ahead; ending it, will re-request at the frontier once the consumer drains

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:

This bounds the one dormant stretch the slack cannot: a consumer that has STOPPED. A paused viewer would otherwise hold a dormant flow for the length of the pause

But it infers "stopped" from a full window, and HLSSegmentProducer races ahead, fills the window, and parks with a full segment cache while the muxer works (parked=10s in 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, which HLSVideoEngine already owns and already hands to the producer; this plumbs the same closure to the reader alongside onNetworkPhaseChanged, 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:

connections   6   (all in the open phase: header, index, tail)
pause ends    0
429s          0

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.

…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>
Rasmus Martinsson and others added 3 commits September 9, 2026 19:09
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
superuser404notfound merged commit ff50904 into superuser404notfound:main Sep 9, 2026
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
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