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:
parent
a2350dd0f6
commit
c42513e0db
1 changed files with 61 additions and 16 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue