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:
@@ -24,8 +24,18 @@ async def run_cli(*args: str) -> tuple[int, str, str]:
|
||||
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)
|
||||
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(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
|
||||
38
tui/jobs.py
38
tui/jobs.py
@@ -45,6 +45,7 @@ class Job:
|
||||
_pid: int | None = field(default=None, repr=False)
|
||||
_pgid: int | None = field(default=None, repr=False)
|
||||
_reconnected: bool = field(default=False, repr=False)
|
||||
_log_file: str | None = field(default=None, repr=False)
|
||||
|
||||
|
||||
class JobManager:
|
||||
@@ -75,7 +76,9 @@ class JobManager:
|
||||
asyncio.create_task(self.run_job(app, job, *cli_args))
|
||||
|
||||
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._pid = proc.pid
|
||||
try:
|
||||
@@ -84,14 +87,22 @@ class JobManager:
|
||||
job._pgid = None
|
||||
self._save_registry()
|
||||
try:
|
||||
while True:
|
||||
line = await proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
text = line.decode().rstrip("\n")
|
||||
if len(job.output) < MAX_OUTPUT_LINES:
|
||||
job.output.append(text)
|
||||
await proc.wait()
|
||||
# Wait for process and tail log file concurrently
|
||||
wait_task = asyncio.create_task(proc.wait())
|
||||
with open(log_path, "r") as f:
|
||||
while not wait_task.done():
|
||||
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:
|
||||
job.output.append(text)
|
||||
rc = proc.returncode if proc.returncode is not None else 1
|
||||
job.return_code = rc
|
||||
job.status = "success" if rc == 0 else "failed"
|
||||
@@ -182,6 +193,7 @@ class JobManager:
|
||||
"pid": pid,
|
||||
"pgid": job._pgid,
|
||||
"started_at": job.started_at.isoformat(),
|
||||
"log_file": job._log_file,
|
||||
})
|
||||
try:
|
||||
REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -221,6 +233,14 @@ class JobManager:
|
||||
job._pid = pid
|
||||
job._pgid = entry.get("pgid")
|
||||
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
|
||||
# Clean up registry: only keep still-running entries
|
||||
self._save_registry()
|
||||
|
||||
Reference in New Issue
Block a user