Commit graph

5 commits

Author SHA1 Message Date
Breadway
17abeed7ae Fix Cast teardown leaks, keyframe latch, and DLNA session lifecycle
Dropped frames never requested a keyframe, SessionEnded skipped ordered
stop (portal/TV/FFI leak, next start could abort), and a late end could
kill the following cast. Failed starts left PlatformClientPosix alive.
DLNA leaked its HTTP server and ignored portal EOS.

Also: start no longer blocks the daemon actor, IPC accept/request loops
stay up, HLS Range is clamped, LAN IP follows the renderer subnet, and
the picker closes before the portal dialog and handles Escape.
2026-08-16 14:16:23 +08:00
Breadway
bd511fea33 Measure real EnqueueFrame outcomes, then size the send window to fit RTT
Every "fix" for the mirroring freezes so far has been reasoned from code
rather than measured, because the one counter that could have falsified any
of them was blind by construction: `enqueue_frame` returns as soon as a frame
is *posted* to openscreen's TaskRunner, long before `Sender::EnqueueFrame`
decides whether to accept it. The frame pump's `enqueued_fps` therefore read a
healthy 30fps through every freeze.

Add `BreadcastEnqueueStats` (new FFI accessor, no behaviour change): per-second
counts of OK / MAX_DURATION_IN_FLIGHT / REACHED_ID_SPAN_LIMIT /
PAYLOAD_TOO_LARGE, plus the in-flight window gauges and RTT sampled at the
enqueue attempt, all surfaced on the existing "frame pump rate" line as
`accepted_fps` / `rejected_*`.

Measured against the real Chromecast, that settles it: 12.2% of frames were
being rejected with MAX_DURATION_IN_FLIGHT, in 85% of all seconds -- steady,
not just during visible freezes. Since breadcast enqueues already-encoded
frames, each rejection silently breaks the H.264 reference chain rather than
merely dropping a frame.

The measurement also corrects the diagnosis. The send window is
clamp(2*RTT, kMinSenderInFlight, target_playout_delay/3); the assumption was
that a LAN pins it to the 66ms floor. It does not -- RTT to this receiver runs
42-189ms, so 2*RTT is 84-378ms and the window was pinned at the *ceiling*,
133ms at a 400ms playout delay. The ceiling was the binding constraint, so
raising the floor alone would have changed nothing.

So raise both, ceiling first: target playout delay 400ms -> 1200ms (ceiling
133ms -> 400ms) and kMinSenderInFlight 66ms -> 200ms for RTT dips. Measured
over a matched 65s steady-state window, rejections fall 12.2% -> 4.3% and
seconds containing a broken reference chain 85% -> 40%. Costs ~800ms of
added latency, which is unnoticeable for mirroring to a TV.

This is an improvement, not a cure. The residual rejections are bursts
(in-flight seen at 433ms against a 200ms window, RTT spiking to 221ms), and no
static window survives those. The real fix is the backpressure contract
sender.h documents and this facade still doesn't implement: consult
GetInFlightMediaDuration()/GetMaxInFlightMediaDuration() and throttle *before*
encoding, so a skipped frame never leaves a dangling reference behind.
2026-08-15 22:34:21 +08:00
Breadway
22a18eee1b Fix silent frame-chain corruption on EnqueueFrame rejection; revert to 720p
Root cause (found by Opus 5 second-opinion review) of the freezing that
survived every prior fix tonight: openscreen's Sender caps in-flight
unacknowledged media at clamp(2*RTT, 66ms, 133ms) -- on a LAN that's
pinned at the 66ms floor, about two frames at 30fps. When EnqueueFrame
rejects a frame under that budget, facade.cc discarded the result
(`(void)video_sender->EnqueueFrame(frame)`) and moved on. But
vah264enc had already encoded the *next* frame as a P-slice depending
on the one that just got silently dropped -- the encoder has no idea
the drop happened, since it happens downstream of encoding, at this
FFI boundary. The receiver sees an unbroken frame-ID sequence (nothing
here told it otherwise) and decodes a P-slice against a reference
picture it never received: a stuck/corrupted frame until the next
regularly-scheduled key frame (up to ~2s, longer if that key frame is
itself dropped the same way -- explains the 20+s outlier). Zero
"receiver reported picture loss" lines across 195s of a visibly
freezing session confirms the receiver genuinely never noticed
anything was wrong, which a real decode-capability or packet-loss
problem would have triggered.

This also explains why moving to a faster network made it *worse*:
the in-flight budget is RTT-derived, not bandwidth-derived, so more
throughput doesn't raise the 66ms floor at all -- while 1080p tripled
the per-frame packet count, increasing how often frames missed that
window.

Fixed the actual corruption: both drop paths in facade.cc (the
EnqueueFrame rejection, and the pre-existing non-monotonic-capture-time
guard) now set a `frame_chain_broken` flag, consumed once by
breadcast_caststream_sender_needs_key_frame() so frame_pump_loop forces
a key frame on the very next frame instead of chaining more P-slices
onto a reference that no longer exists on the receiver. A separate flag
from the existing `needs_key_frame` atomic because SchedulePoll's 100ms
timer unconditionally overwrites that one with the Sender's own
(unrelated) NeedsKeyFrame() reading, which would have silently clobbered
this signal.

Also reverted the Cast Streaming pipeline from tonight's 1080p
experiment back to 1280x720 (build_video_pipeline_for_streaming +
VideoParams::default(), which must agree -- a mismatch there is a
separate protocol-level bug fixed earlier tonight). Not the root cause,
but a real contributing factor per the packet-count reasoning above, and
untangling it from the frame_chain_broken fix by changing both at once
would make the next test ambiguous. Kept the 6000/1500 kbps bitrate
range from earlier tonight, now actually paired with 720p for the first
time.

The deeper real fix -- raising openscreen's 66ms in-flight floor itself,
which trades latency for headroom -- is out of scope for tonight; this
targets the corruption mechanism (via the sanctioned, if awkward,
needs_key_frame signal) without touching vendored openscreen constants.
2026-08-15 22:34:21 +08:00
Breadway
5b49955e33 Fix Cast Streaming teardown crash, bitrate collapse, and HLS latency
Three independent problems found by reading the mirroring paths end to end.

1. Segfault on Cast session teardown. CastStreamSender::SchedulePoll
   self-reschedules every 100ms with a raw `this` and was never cancelled,
   so the one task that can be scheduled to run *after* an already-queued
   teardown task would dereference the just-reset `environment` unique_ptr
   (Environment::task_runner() dereferences a member immediately) --- a hard
   null deref on openscreen's TaskRunner thread. TaskRunnerImpl's shutdown
   has an explicit flushing phase and PlatformClientPosix::ShutDown()'s quit
   task queues behind whatever is already pending, so this is a race the
   teardown path can lose. Latch a `shutting_down` atomic before posting
   teardown and check it in the poll and in every other posted task.

2. Encoder bitrate collapsing to the floor within seconds. The control loop
   set `target = 0.85 * estimate` once a second unconditionally. openscreen's
   BandwidthEstimator deliberately under-estimates capacity whenever the
   transmit rate is below it and documents the required TCP-like response;
   multiplying the target by <=0.85 every second instead walks 4000 kbps past
   1500 in ~6s and pins it at the floor on a healthy LAN. Replaced with
   proper AIMD (hold on a zero/unknown estimate, back off below it, probe up
   10%/s otherwise), clamped to 1000..8000 kbps, with unit tests.

3. DLNA/HLS latency. Segment length is max(target-duration, GOP), so
   target-duration=2 with a 2s GOP gave 2s segments, ~6s of renderer buffer,
   plus 3 segments of stale video waited for before handover. 1s segments
   (GOP halved to make that reachable), shorter playlist, and wait for 2
   segments instead of 3.

Also hardened two paths into openscreen's fatal OSP_CHECK on strictly
increasing RTP timestamps: pull_encoded_frame no longer substitutes 0 for a
missing PTS (it skips the buffer), and facade.cc drops non-monotonic capture
times at the FFI boundary. Either could previously abort the daemon outright.

CastMirrorSession now owns the Arc<CastStreamSender> instead of leaving its
lifetime to whichever detached pump thread dropped the last clone, so the
blocking FFI destroy happens at a defined point in stop() with the pump
joins ordered around it.
2026-08-15 22:34:21 +08:00
Breadway
8c745d18e0 Implement Cast Streaming mirroring, DLNA casting, daemon+GUI, and breadd integration
Some checks failed
dev release / build (push) Failing after 12s
Builds out the full v1 scope: a vendored+patched openscreen subset for
low-latency Cast Streaming (Mirroring receiver 0F5096E8) alongside the
existing Cast V2/HLS and new DLNA/AVTransport casting paths, breadcastd's
Idle/Casting state machine with a private IPC socket, the breadcast GTK4
popup as a thin IPC client, and bread.cast.*/bread.command.cast.* breadd
integration (device discovery, start/stop, mirroring lifecycle events).
Also adds bakery/systemd/Forgejo CI packaging.

Validated end-to-end against a real Chromecast/Google TV: negotiated
Cast Streaming session, live pipeline playback, and daemon+GUI click-to-cast/
stop through the actual popup.
2026-08-03 09:07:21 +08:00