Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ No metadata file connects `create` to `burn`. `cmd_burn` derives disc count from

### Subprocess wrapper

`shell/runner.py:run()` streams subprocess output line-by-line with a `[label]` prefix. Use `capture=True` only when you need to parse stdout (e.g. `tools.par2.verify` matching on "All files are correct" / "Repair is required"). `check=True` raises `CalledProcessError`; pass `check=False` when a non-zero exit is informational rather than fatal.
`shell/runner.py:run()` has three output modes. **Default** streams subprocess output line-by-line with a `[label]` prefix — fine for tools that emit `\n`-terminated lines. **`capture=True`** buffers stdout/stderr into the returned `CompletedProcess` so callers can parse it. **`passthrough=True`** lets the child inherit our stdout/stderr directly — needed for tools that paint progress via `\r` (par2 verify/repair: "Scanning: X%" updates rewrite a single line, which the default streamer would buffer until the next `\n`). par2's exit codes are precise (0=OK, 1=REPAIRABLE, 2=BROKEN) so `tools.par2.verify` reads the result from the return code and doesn't need to capture stdout. `check=True` raises `CalledProcessError`; pass `check=False` when a non-zero exit is informational rather than fatal.

SIGINT handling: children share the parent's process group, so a tty Ctrl+C reaches them too. The wrapper waits up to 5 s for the child to exit, then escalates `terminate()` → wait 5 s → `kill()`. `_check_sigint` converts `returncode == -SIGINT` into `KeyboardInterrupt` on the way out, so the top-level handler in `cli.py` emits a single uniform cancel banner (exit 130) instead of a noisy `CalledProcessError`. `tools/growisofs.py` and `tools/dar.py` opt out of this default in different ways: growisofs runs in `start_new_session=True` and installs its own two-press SIGINT handler so a single accidental Ctrl+C does not coaster a BD-R; dar shares our group so it gets the user's SIGINT directly and we just join + escalate.

Expand Down
22 changes: 21 additions & 1 deletion src/bd_archive/shell/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,16 @@ def _check_sigint(returncode: int) -> None:


def run(
cmd: list[str], *, label: str = "", check: bool = True, capture: bool = False
cmd: list[str],
*,
label: str = "",
check: bool = True,
capture: bool = False,
passthrough: bool = False,
) -> subprocess.CompletedProcess:
if capture and passthrough:
raise ValueError("capture and passthrough are mutually exclusive")

if capture:
# check=False here so we can intercept the SIGINT case before
# subprocess.run synthesises a CalledProcessError on its own.
Expand All @@ -25,6 +33,18 @@ def run(
raise subprocess.CalledProcessError(r.returncode, cmd, r.stdout, r.stderr)
return r

if passthrough:
# Inherit our stdout/stderr so the child writes straight to the
# user's terminal — required for tools whose progress uses \r
# to repaint a single line (par2 "Scanning: X%"). The default
# streaming path below reads until \n, which buffers those
# updates and shows nothing live. Trade-off: no [label] prefix.
r = subprocess.run(cmd, check=False)
_check_sigint(r.returncode)
if check and r.returncode != 0:
raise subprocess.CalledProcessError(r.returncode, cmd)
return r

prefix = f" [{label}] " if label else " "
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
assert proc.stdout is not None
Expand Down
17 changes: 12 additions & 5 deletions src/bd_archive/tools/par2.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,22 @@ def create(target_file: Path, redundancy: int):


def verify(par2_index: Path) -> VerifyResult:
r = run(["par2", "verify", str(par2_index)], check=False, capture=True)
out = r.stdout + r.stderr
if "All files are correct" in out:
# par2cmdline exit codes: 0 = all files OK, 1 = repair possible,
# 2 = repair not possible. Maps directly onto VerifyResult, so we
# use passthrough to let par2 paint its "Scanning: X%" progress
# straight to the terminal — verify takes ~20 min on a 25 GB BD-R
# and is otherwise a black screen.
r = run(["par2", "verify", str(par2_index)], check=False, passthrough=True)
if r.returncode == 0:
return VerifyResult.OK
if "Repair is required" in out:
if r.returncode == 1:
return VerifyResult.REPAIRABLE
return VerifyResult.BROKEN


def repair(par2_index: Path) -> bool:
r = run(["par2", "repair", str(par2_index)], label="par2", check=False)
# Same passthrough rationale as verify: repair's Loading /
# Constructing / Verifying steps all use \r-updated progress that
# the line-buffered streamer would swallow.
r = run(["par2", "repair", str(par2_index)], check=False, passthrough=True)
return r.returncode == 0
Loading