Use subprocess.Popen for background jobs to survive TUI exit
asyncio's SubprocessTransport sends SIGKILL to child processes during event loop cleanup, killing the gniza bash wrapper when the TUI exits. Switch to subprocess.Popen which has no such cleanup behavior, allowing backup jobs to continue running after the TUI is closed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -24,24 +25,22 @@ 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, log_file: str | None = None) -> asyncio.subprocess.Process:
|
||||
def start_cli_background(*args: str, log_file: str) -> subprocess.Popen:
|
||||
"""Start a CLI process that survives TUI exit.
|
||||
|
||||
Uses subprocess.Popen directly (not asyncio) so there is no
|
||||
SubprocessTransport that would SIGKILL the child on event-loop cleanup.
|
||||
"""
|
||||
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,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
fh = open(log_file, "w")
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=fh,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
fh.close()
|
||||
return proc
|
||||
|
||||
|
||||
async def stream_cli(callback, *args: str) -> int:
|
||||
|
||||
14
tui/jobs.py
14
tui/jobs.py
@@ -2,6 +2,7 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
@@ -9,7 +10,7 @@ from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from tui.backend import start_cli_process
|
||||
from tui.backend import start_cli_background
|
||||
|
||||
MAX_OUTPUT_LINES = 10_000
|
||||
FINISHED_JOB_TTL_HOURS = 24
|
||||
@@ -42,7 +43,7 @@ class Job:
|
||||
finished_at: datetime | None = None
|
||||
return_code: int | None = None
|
||||
output: list[str] = field(default_factory=list)
|
||||
_proc: asyncio.subprocess.Process | None = field(default=None, repr=False)
|
||||
_proc: subprocess.Popen | None = field(default=None, repr=False)
|
||||
_pid: int | None = field(default=None, repr=False)
|
||||
_pgid: int | None = field(default=None, repr=False)
|
||||
_reconnected: bool = field(default=False, repr=False)
|
||||
@@ -80,7 +81,7 @@ class JobManager:
|
||||
async def run_job(self, app, job: Job, *cli_args: str) -> int:
|
||||
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))
|
||||
proc = start_cli_background(*cli_args, log_file=str(log_path))
|
||||
job._proc = proc
|
||||
job._pid = proc.pid
|
||||
try:
|
||||
@@ -89,10 +90,9 @@ class JobManager:
|
||||
job._pgid = None
|
||||
self._save_registry()
|
||||
try:
|
||||
# Wait for process and tail log file concurrently
|
||||
wait_task = asyncio.create_task(proc.wait())
|
||||
# Poll process and tail log file
|
||||
with open(log_path, "r") as f:
|
||||
while not wait_task.done():
|
||||
while proc.poll() is None:
|
||||
line = f.readline()
|
||||
if line:
|
||||
text = line.rstrip("\n")
|
||||
@@ -130,7 +130,7 @@ class JobManager:
|
||||
return job.return_code if job.return_code is not None else 1
|
||||
|
||||
@staticmethod
|
||||
def _kill_process_group(proc: asyncio.subprocess.Process) -> None:
|
||||
def _kill_process_group(proc: subprocess.Popen) -> None:
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
|
||||
Reference in New Issue
Block a user