Write job output to file instead of pipe to survive TUI exit

The subprocess stdout was a pipe to the TUI. When the TUI exited, the
pipe broke (SIGPIPE) and killed the backup process. Now the subprocess
writes to a log file in WORK_DIR, and the TUI tails it for live
display. When the TUI exits, the subprocess keeps running because it
writes to a file, not a pipe. On restart, the log file is loaded to
show output for reconnected or finished background jobs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-06 20:50:09 +02:00
parent a08cf9911c
commit 8fec087987
2 changed files with 40 additions and 10 deletions

View File

@@ -24,8 +24,18 @@ async def run_cli(*args: str) -> tuple[int, str, str]:
return proc.returncode or 0, stdout.decode(), stderr.decode() return proc.returncode or 0, stdout.decode(), stderr.decode()
async def start_cli_process(*args: str) -> asyncio.subprocess.Process: async def start_cli_process(*args: str, log_file: str | None = None) -> asyncio.subprocess.Process:
cmd = [_gniza_bin(), "--cli"] + list(args) cmd = [_gniza_bin(), "--cli"] + list(args)
if log_file:
fh = open(log_file, "w")
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=fh,
stderr=asyncio.subprocess.STDOUT,
start_new_session=True,
)
fh.close()
return proc
return await asyncio.create_subprocess_exec( return await asyncio.create_subprocess_exec(
*cmd, *cmd,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,

View File

@@ -45,6 +45,7 @@ class Job:
_pid: int | None = field(default=None, repr=False) _pid: int | None = field(default=None, repr=False)
_pgid: int | None = field(default=None, repr=False) _pgid: int | None = field(default=None, repr=False)
_reconnected: bool = field(default=False, repr=False) _reconnected: bool = field(default=False, repr=False)
_log_file: str | None = field(default=None, repr=False)
class JobManager: class JobManager:
@@ -75,7 +76,9 @@ class JobManager:
asyncio.create_task(self.run_job(app, job, *cli_args)) asyncio.create_task(self.run_job(app, job, *cli_args))
async def run_job(self, app, job: Job, *cli_args: str) -> int: async def run_job(self, app, job: Job, *cli_args: str) -> int:
proc = await start_cli_process(*cli_args) log_path = _work_dir() / f"gniza-job-{job.id}.log"
job._log_file = str(log_path)
proc = await start_cli_process(*cli_args, log_file=str(log_path))
job._proc = proc job._proc = proc
job._pid = proc.pid job._pid = proc.pid
try: try:
@@ -84,14 +87,22 @@ class JobManager:
job._pgid = None job._pgid = None
self._save_registry() self._save_registry()
try: try:
while True: # Wait for process and tail log file concurrently
line = await proc.stdout.readline() wait_task = asyncio.create_task(proc.wait())
if not line: with open(log_path, "r") as f:
break while not wait_task.done():
text = line.decode().rstrip("\n") line = f.readline()
if line:
text = line.rstrip("\n")
if len(job.output) < MAX_OUTPUT_LINES:
job.output.append(text)
else:
await asyncio.sleep(0.2)
# Read remaining lines after process exit
for line in f:
text = line.rstrip("\n")
if len(job.output) < MAX_OUTPUT_LINES: if len(job.output) < MAX_OUTPUT_LINES:
job.output.append(text) job.output.append(text)
await proc.wait()
rc = proc.returncode if proc.returncode is not None else 1 rc = proc.returncode if proc.returncode is not None else 1
job.return_code = rc job.return_code = rc
job.status = "success" if rc == 0 else "failed" job.status = "success" if rc == 0 else "failed"
@@ -182,6 +193,7 @@ class JobManager:
"pid": pid, "pid": pid,
"pgid": job._pgid, "pgid": job._pgid,
"started_at": job.started_at.isoformat(), "started_at": job.started_at.isoformat(),
"log_file": job._log_file,
}) })
try: try:
REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True) REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
@@ -221,6 +233,14 @@ class JobManager:
job._pid = pid job._pid = pid
job._pgid = entry.get("pgid") job._pgid = entry.get("pgid")
job._reconnected = alive job._reconnected = alive
job._log_file = entry.get("log_file")
# Load output from log file
if job._log_file and Path(job._log_file).is_file():
try:
lines = Path(job._log_file).read_text().splitlines()
job.output = lines[:MAX_OUTPUT_LINES]
except OSError:
pass
self._jobs[job.id] = job self._jobs[job.id] = job
# Clean up registry: only keep still-running entries # Clean up registry: only keep still-running entries
self._save_registry() self._save_registry()