Read ffmpeg progress from a file, not a pipe, to avoid deadlock

Driving -progress over pipe:1 made ffmpeg block on the progress write
whenever the GUI stopped draining the pipe (e.g. the parent terminal or
the app's event loop wedging), deadlocking every running encode.

Write -progress to a temp file instead: a file write never blocks, so the
encoder runs to completion regardless of GUI state. The executor calls
optional start_/stop_progress_polling hooks; ffmpeg polls the file on a
500ms GLib timer and removes it when done. Verified an encode completes
with no reader at all (previously a hard deadlock).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-04 04:39:20 -07:00
parent 7f574897c7
commit 480ec59ba4
2 changed files with 78 additions and 21 deletions

View file

@ -109,6 +109,12 @@ class executor(GObject.GObject):
self.launch_process(self.command_var)
if self.use_pulse_mode != self.pulse_mode:
self.set_pulse_mode(self.use_pulse_mode)
# let subclasses start any timer-based progress polling (e.g. ffmpeg
# reads its -progress file on a GLib timer). No-op by default.
try:
self.start_progress_polling()
except AttributeError:
pass
def remove_ansi(self, line):
@ -308,6 +314,11 @@ class executor(GObject.GObject):
retval = -1
self.set_pulse_mode(False)
# stop any subclass progress polling timer
try:
self.stop_progress_polling()
except AttributeError:
pass
# call, if it exists, the post-function
try:

View file

@ -19,6 +19,7 @@
import subprocess
import os
import re
from gi.repository import GLib
import devedeng.configuration_data
import devedeng.executor
import devedeng.mux_dvd_menu
@ -182,11 +183,19 @@ class ffmpeg(devedeng.executor.executor):
self.command_var.append("-n")
self.command_var.append("10")
self.command_var.append("ffmpeg")
# emit structured, steadily-paced progress on stdout (pipe:1) instead of
# the carriage-return stats line, which barely updates over a pipe; this
# is what keeps the progress bar moving (see process_stdout)
# Write structured, steadily-paced progress to a FILE (never a pipe):
# a file write can't block the encoder, so a wedged or crashed GUI can
# never deadlock ffmpeg on backpressure. The app polls this file on a
# timer to update the progress bar (see start_progress_polling).
self.progress_file = output_file + ".progress"
try:
# start clean so we never read a previous run's position
if os.path.exists(self.progress_file):
os.remove(self.progress_file)
except OSError:
pass
self.command_var.append("-progress")
self.command_var.append("pipe:1")
self.command_var.append(self.progress_file)
self.command_var.append("-stats_period")
self.command_var.append("0.5")
self.command_var.append("-nostats")
@ -725,37 +734,74 @@ class ffmpeg(devedeng.executor.executor):
return (final_path)
def process_stdout(self, data):
# ffmpeg -progress emits newline-delimited key=value pairs on stdout,
# e.g. out_time=00:01:23.456 / out_time_ms=83456000 / progress=continue.
# This updates steadily over a pipe, unlike the carriage-return stats.
if self.progress_bar is None:
# progress is read from the -progress file (see start_progress_polling),
# not stdout, so nothing to do here
return
def start_progress_polling(self):
""" Poll ffmpeg's -progress file on a GLib timer to drive the progress
bar. Reading a file never blocks the encoder, so a wedged or crashed
GUI can't deadlock ffmpeg on pipe backpressure. """
self._progress_timer = None
if getattr(self, "progress_file", None) is None:
return
for line in data:
self._progress_timer = GLib.timeout_add(500, self._poll_progress_file)
def stop_progress_polling(self):
timer = getattr(self, "_progress_timer", None)
if timer is not None:
GLib.source_remove(timer)
self._progress_timer = None
# tidy up the temp progress file
pf = getattr(self, "progress_file", None)
if pf is not None:
try:
os.remove(pf)
except OSError:
pass
def _poll_progress_file(self):
# returning True keeps the GLib timer alive
if self.progress_bar is None:
return True
pf = getattr(self, "progress_file", None)
if (pf is None) or (not os.path.exists(pf)):
return True
try:
with open(pf, "r") as f:
content = f.read()
except OSError:
return True
# ffmpeg appends progress blocks; the last out_time/progress is current
seconds = None
ended = False
for line in content.splitlines():
line = line.strip()
if line.startswith("out_time_ms=") or line.startswith("out_time_us="):
try:
micros = float(line.split("=", 1)[1])
seconds = float(line.split("=", 1)[1]) / 1000000.0
except (ValueError, IndexError):
continue
seconds = micros / 1000000.0
self._set_progress_fraction(seconds)
pass
elif line.startswith("out_time="):
value = line.split("=", 1)[1]
parts = value.split(":")
seconds = 0.0
parts = line.split("=", 1)[1].split(":")
acc = 0.0
ok = True
for e in parts:
try:
seconds = seconds * 60.0 + float(e)
acc = acc * 60.0 + float(e)
except ValueError:
ok = False
break
if ok:
self._set_progress_fraction(seconds)
seconds = acc
elif line == "progress=end":
self.progress_bar[1].set_fraction(1.0)
self.progress_bar[1].set_text("100.0%")
return
ended = True
if seconds is not None:
self._set_progress_fraction(seconds)
if ended:
self.progress_bar[1].set_fraction(1.0)
self.progress_bar[1].set_text("100.0%")
return True
def _set_progress_fraction(self, seconds):
if (self.progress_bar is None) or (not self.final_length):