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()
|
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,
|
||||||
|
|||||||
38
tui/jobs.py
38
tui/jobs.py
@@ -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 len(job.output) < MAX_OUTPUT_LINES:
|
if line:
|
||||||
job.output.append(text)
|
text = line.rstrip("\n")
|
||||||
await proc.wait()
|
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
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user