Commit graph

5 commits

Author SHA1 Message Date
Breadway
a058482b39 Raise Cast Streaming mirroring to 1080p, retune bitrate range to match
User is moving to a faster (150 Mbps) network and wants 1080p. Raised
build_video_pipeline_for_streaming and VideoParams::default() together
(they must agree -- see caststream.rs's doc comment on why a resolution
mismatch there is a protocol violation, not just soft video).

Left build_video_pipeline (the HLS/DLNA path) at 720p -- that one's
1280x720 choice is about an older Default Media Receiver's decoder
profile/level, unrelated to what's changing here.

Bitrate ceiling raised from 4-8x scaling but deliberately not straight
back up to the old 8 Mbps: real testing tonight showed the AIMD probe
pins to whatever MAX_BITRATE_KBPS is for the entire session once
estimated_bandwidth_bps() reports (unreliably -- flat ~20 Mbps most of
a session that was visibly stuttering) that there's headroom, and 8
Mbps sustained was more than the previous network+receiver could hold.
6 Mbps is a solid target for 1080p30 on its own merits. MIN_BITRATE_KBPS
bumped 1000->1500 to match (1080p needs more of a floor than 720p did
before it's a wall of blocking artifacts).
2026-08-15 22:34:21 +08:00
Breadway
1ee607b5e9 cast_mirror: bound the negotiation-timeout stop path too
start()'s "never received an ANSWER" branch called session.stop()
directly (blocking, unbounded, not even off the async runtime's worker
thread) and capture.close().await with no timeout -- the exact same
freeze-the-whole-daemon hazard Self::stop() was just bounded against,
just at a different call site. An unresponsive receiver hits this path
by definition (that's what a negotiation timeout means), so it's not a
hypothetical: reproduced live just now, wedging the daemon for over a
minute with no way to recover short of kill -9.

Factored the bound into stop_session_bounded/close_capture_bounded so
both call sites share one implementation instead of drifting.
2026-08-15 22:34:21 +08:00
Breadway
5e587ce342 Fix mirroring host-selection race, control-channel fragility, teardown
deadlock, and OFFER/encode resolution mismatch

Found live-testing on real hardware after the first round of fixes:

1. mDNS resolves one Chromecast on every local address it has (a private
   IPv4 and a link-local IPv6 in the common case), as separate
   CastDeviceFound events for the same id. The device map was a plain
   HashMap::insert, so whichever address resolved last won -- and a bare
   fe80:: address has no interface scope attached, so connecting to it
   fails outright. This is what "failed to connect and launch the
   Mirroring receiver" actually was; the message just didn't say why,
   since daemon.rs was converting the anyhow::Error with to_string()
   (Display, outermost .context() only) instead of "{e:#}" (full chain).
   Fixed both: prefer an already-usable host over a link-local one
   instead of always taking the newest resolution, and preserve the full
   error chain to the GUI/bread event log.

2. The CASTV2 receive loop (run_io_loop) treated any error from
   device.receive() as connection-fatal and ended the whole loop --
   including a plain JSON deserialization failure on a single
   MEDIA_STATUS message (rust_cast's struct requires an `images` field
   this receiver didn't send). That killed the receiver-status/control
   channel for the rest of every session, on the very first status
   update, while the RTP stream itself kept flowing obliviously.
   rust_cast::Error already distinguishes Io/Tls/Dns (actually fatal)
   from Serialization/Parsing/etc (a bad message, not a dead socket) --
   only end the loop on the former now.

3. CastSession::stop() blocks on an unbounded reply_rx.recv() waiting for
   the io thread's device.receiver.stop_app() -- a network round trip
   rust_cast gives no way to put a read timeout on. If the receiver ever
   stops responding, that never returns, and since the daemon actor
   processes one command at a time, a single wedged stop_cast freezes
   every future IPC request too, recoverable only by killing the process
   -- which is exactly what was observed live. Bounded both that wait and
   join_pump's thread joins to 5s; past that, log a warning and tear down
   anyway rather than hang forever. The abandoned thread(s) may leak, but
   a leak beats an unrecoverable daemon.

4. VideoParams::default() advertised 1920x1080 in the OFFER while
   build_video_pipeline_for_streaming actually encodes 1280x720 --
   negotiated and actual resolution disagreeing is a real protocol
   violation, not just soft video, and a plausible cause of a receiver
   decoder corrupting or freezing outright rather than merely looking
   worse. Made the OFFER match what's actually sent.
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