Skip to content

Fix backend deadlock when mpv output exceeds the stdout pipe buffer - #446

Open
jfrmorales wants to merge 1 commit into
Fredolx:mainfrom
jfrmorales:fix/mpv-stdout-pipe-deadlock
Open

jfrmorales wants to merge 1 commit into
Fredolx:mainfrom
jfrmorales:fix/mpv-stdout-pipe-deadlock

Conversation

@jfrmorales

Copy link
Copy Markdown

Summary

Fixes a hard backend deadlock that freezes the whole app when a stream fails or drops. The fix drains mpv's stdout concurrently instead of only after the process exits, while keeping the exact error message that is currently shown to the user.

Symptom

When playing a channel or losing signal, the UI froze completely and one CPU core pegged at 100%. The window could not be closed and channels could not be switched — the process had to be killed manually (killall -9 open_tv).

Environment where it reproduces

  • Fedora Silverblue / Bazzite, NVIDIA GPU, Wayland
  • Flatpak build (dev.fredol.open-tv)
  • Streams played through mpv with the yt-dlp hook

The pipe-buffer condition below is OS-level and not NVIDIA-specific, but this setup made it trigger reliably (verbose hook/log output on flaky IPTV links).

Root cause

In src-tauri/src/mpv.rs, mpv is spawned with stdout(Stdio::piped()), but the pipe was only read after cmd.wait() returned:

status = cmd.wait() => {
    ...
    let stdout = cmd.stdout.take();      // read only here, after exit
    ...
}

A Linux pipe has a limited buffer (~64KB). If mpv (or its yt-dlp subprocess) writes more than that to stdout, mpv blocks indefinitely on write() waiting for the buffer to drain. Nothing reads the buffer until mpv exits — but mpv can't exit while it's blocked writing. So cmd.wait() never returns, the async task and the state locks behind it are held forever, and the UI deadlocks.

Fix

Spawn a small task that drains stdout line-by-line while mpv runs, then use the collected output as the error message on a non-success exit:

let stdout = cmd.stdout.take().context("no stdout")?;
let output = tokio::spawn(async move {
    let mut lines = BufReader::new(stdout).lines();
    let mut error = String::new();
    while let Ok(Some(line)) = lines.next_line().await {
        if !error.is_empty() { error.push('\n'); }
        error.push_str(&line);
    }
    error
});

Because the pipe is read continuously, the buffer can never fill, so mpv can never block on write and cmd.wait() always returns.

The error-reporting behavior is preserved: on a non-zero exit the collected output is surfaced to the user exactly as before (with the same "Mpv encountered an unknown error" fallback when there is no output). The cancellation path is unchanged; the drain task ends on its own via EOF when mpv is killed.

Testing

  • cargo check passes.
  • Verified manually: a working channel plays normally, and a dead/403 channel now shows the mpv error in a toast and the UI stays fully responsive (no freeze, no stuck core).

mpv is spawned with stdout piped, but the pipe was only read after
cmd.wait() returned. On Linux a pipe has a limited buffer (~64KB), so
when mpv (or its yt-dlp hook) writes more than that, mpv blocks forever
on write(), never exits, cmd.wait() never returns, and the whole backend
deadlocks with the UI frozen.

Drain stdout in a concurrent task while mpv runs, then use the collected
output as the error message on a non-success exit. This keeps the exact
error-reporting behavior (the message is still surfaced to the user) and
removes the deadlock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Jun 28, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

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