gui: parse CLI progress frames so compress no longer looks stuck

The CLI paints progress as carriage-return frames ("\r  path [###] 42%", no
newline until 100%). Worker.run read the child line-by-line, so it emitted
NOTHING for the whole job and the GUI looked frozen on any file bigger than one
block — reported as 'I can't compress, the app stucks'. The worker now reads
with read1() and splits on both \n and \r, parsing the percentage out of the
progress frames and driving the QProgressBar (indeterminate until the first %).
Also run the child with stdin=/dev/null (a prompt on inherited stdin would block
forever) and merge stderr into stdout so a filling second pipe can't deadlock.
This commit is contained in:
Cristian Cezar Moisés 2026-07-11 22:53:44 -03:00
commit c42513e0db

View file

@ -79,7 +79,7 @@ def _is_runnable(path):
# Liveness check — catches missing shared libraries, broken rpath, # Liveness check — catches missing shared libraries, broken rpath,
# ABI mismatch, etc. 3-second cap so we never hang the GUI startup. # ABI mismatch, etc. 3-second cap so we never hang the GUI startup.
try: try:
r = subprocess.run([p, "version"], capture_output=True, timeout=3) r = subprocess.run([p, "version"], capture_output=True, stdin=subprocess.DEVNULL, timeout=3)
if r.returncode != 0: if r.returncode != 0:
err = r.stderr.decode("utf-8", errors="replace").strip() err = r.stderr.decode("utf-8", errors="replace").strip()
return False, f"exit {r.returncode}: {err.splitlines()[0] if err else 'no stderr'}" return False, f"exit {r.returncode}: {err.splitlines()[0] if err else 'no stderr'}"
@ -178,7 +178,7 @@ def _get_version():
number = "?" number = "?"
full = "" full = ""
try: try:
r = subprocess.run([VAPTVUPT, "version"], capture_output=True, text=True, timeout=5) r = subprocess.run([VAPTVUPT, "version"], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5)
if r.returncode == 0: if r.returncode == 0:
full = r.stdout.strip() full = r.stdout.strip()
lines = full.split("\n") lines = full.split("\n")
@ -219,7 +219,7 @@ def _get_caps():
pqonly = True pqonly = True
if not pqonly: if not pqonly:
try: try:
h = subprocess.run([VAPTVUPT, "help"], capture_output=True, text=True, timeout=5) h = subprocess.run([VAPTVUPT, "help"], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5)
txt = (h.stdout or "") + (h.stderr or "") txt = (h.stdout or "") + (h.stderr or "")
if "--pq-only" in txt: if "--pq-only" in txt:
pqonly = True pqonly = True
@ -254,7 +254,7 @@ _PQ_FLAG = {
def _detect_archive_pq(archive): def _detect_archive_pq(archive):
"""Inspect an archive's `info` and return the matching PQ token, or None.""" """Inspect an archive's `info` and return the matching PQ token, or None."""
try: try:
r = subprocess.run([VAPTVUPT, "info", archive], capture_output=True, text=True, timeout=15) r = subprocess.run([VAPTVUPT, "info", archive], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=15)
txt = (r.stdout or "") + (r.stderr or "") txt = (r.stdout or "") + (r.stderr or "")
except Exception: except Exception:
return None return None
@ -330,7 +330,8 @@ QFrame#sep { background: #1a2a30; max-height: 1px; }
def run_zupt(args, timeout=30): def run_zupt(args, timeout=30):
try: try:
r = subprocess.run([ZUPT]+list(args), capture_output=True, text=True, timeout=timeout) r = subprocess.run([ZUPT]+list(args), capture_output=True, text=True,
stdin=subprocess.DEVNULL, timeout=timeout)
return r.returncode, r.stdout, r.stderr return r.returncode, r.stdout, r.stderr
except FileNotFoundError: return -1, "", f"vaptvupt not found: {VAPTVUPT}\n\nDiscovery log:\n" + "\n".join(_DISCOVERY_LOG[-10:]) except FileNotFoundError: return -1, "", f"vaptvupt not found: {VAPTVUPT}\n\nDiscovery log:\n" + "\n".join(_DISCOVERY_LOG[-10:])
except subprocess.TimeoutExpired: return -1, "", "Timed out" except subprocess.TimeoutExpired: return -1, "", "Timed out"
@ -338,23 +339,59 @@ def run_zupt(args, timeout=30):
class Worker(QObject): class Worker(QObject):
done = Signal(int, str, str) done = Signal(int, str, str)
log = Signal(str) log = Signal(str)
pct = Signal(int)
# The CLI paints live progress as "\r <path> [##### ] 42%" frames —
# carriage returns only, no newline until 100%. A line-based reader yields
# NOTHING for the entire job, so the GUI looked frozen on any file larger
# than one block ("app is stuck"). Parse the \r frames into a percentage.
_PCT_RE = re.compile(r"(\d{1,3})%\s*$")
def __init__(self, args): def __init__(self, args):
super().__init__(); self.args = args; self.proc = None; self._cancelled = False super().__init__(); self.args = args; self.proc = None; self._cancelled = False
def run(self): def run(self):
self.log.emit(f"$ {Path(VAPTVUPT).name} {' '.join(self.args)}") self.log.emit(f"$ {Path(VAPTVUPT).name} {' '.join(self.args)}")
try: try:
# errors="replace": the CLI can echo non-UTF-8 bytes (foreign # stdin=DEVNULL: the CLI prompts on a terminal for some inputs
# filenames on mounted drives); strict decoding would raise mid-read # (e.g. bare -p); a child that reads stdin inherited from the GUI's
# and the job would never report done. # terminal would block forever. /dev/null makes prompts fail fast.
self.proc = proc = subprocess.Popen([ZUPT]+self.args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, errors="replace") # stderr is merged into stdout so ONE stream carries everything
# (the CLI's human output is on stderr; stdout is empty) — no
# second pipe that could fill while we drain the first.
self.proc = proc = subprocess.Popen(
[ZUPT]+self.args, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
if self._cancelled: # cancel() ran before Popen finished (see below) if self._cancelled: # cancel() ran before Popen finished (see below)
proc.kill() proc.kill()
err_lines = [] lines = []
for line in proc.stderr: buf = ""
line = line.rstrip('\n') last_pct = -1
if line: err_lines.append(line); self.log.emit(line) while True:
stdout, _ = proc.communicate(timeout=7200) # read1: return whatever bytes are available (>=1) instead of
self.done.emit(proc.returncode, stdout or "", "\n".join(err_lines)) # blocking for a full buffer — required for live \r progress.
chunk = proc.stdout.read1(65536)
if not chunk:
break
buf += chunk.decode("utf-8", errors="replace")
# Split on BOTH \n (real lines) and \r (progress frames);
# keep the trailing partial segment in the buffer.
segs = re.split(r"(\r\n|\n|\r)", buf)
buf = segs[-1]
for i in range(0, len(segs) - 1, 2):
seg, sep = segs[i], segs[i + 1]
if sep == "\r" or (seg and self._PCT_RE.search(seg) and "[" in seg):
m = self._PCT_RE.search(seg)
if m:
p = min(100, int(m.group(1)))
if p != last_pct:
last_pct = p; self.pct.emit(p)
continue # progress frames stay out of the log
if seg.strip():
lines.append(seg); self.log.emit(seg)
proc.wait(timeout=7200)
if buf.strip() and not self._PCT_RE.search(buf):
lines.append(buf); self.log.emit(buf)
self.done.emit(proc.returncode, "", "\n".join(lines))
except FileNotFoundError: self.done.emit(-1, "", f"vaptvupt not found: {VAPTVUPT}") except FileNotFoundError: self.done.emit(-1, "", f"vaptvupt not found: {VAPTVUPT}")
except subprocess.TimeoutExpired: proc.kill(); self.done.emit(-1, "", "Timed out") except subprocess.TimeoutExpired: proc.kill(); self.done.emit(-1, "", "Timed out")
except Exception as exc: except Exception as exc:
@ -425,10 +462,18 @@ def run_async(parent, cmd, btn, log, progress=None, info=None):
if info: # e.g. an auto-detect note; appended AFTER the clear so it survives if info: # e.g. an auto-detect note; appended AFTER the clear so it survives
log.append(info) log.append(info)
btn.setEnabled(False) btn.setEnabled(False)
if progress: progress.show() if progress:
progress.setRange(0, 0) # indeterminate until the CLI reports a %
progress.show()
t = QThread(); w = Worker(cmd); w.moveToThread(t) t = QThread(); w = Worker(cmd); w.moveToThread(t)
# log.append targets a main-thread QObject -> Qt queues it to the GUI thread. # log.append targets a main-thread QObject -> Qt queues it to the GUI thread.
w.log.connect(log.append) w.log.connect(log.append)
if progress:
def on_pct(p):
if progress.maximum() != 100:
progress.setRange(0, 100)
progress.setValue(p)
w.pct.connect(on_pct)
# Keep a LIST of live (thread, worker) refs on the parent. Tabs with more # Keep a LIST of live (thread, worker) refs on the parent. Tabs with more
# than one action button (Disk: backup + restore) previously shared a # than one action button (Disk: backup + restore) previously shared a
# single _thread/_worker slot, so starting a second op dropped the only # single _thread/_worker slot, so starting a second op dropped the only