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:
shuki
2026-03-06 21:28:32 +02:00
parent ae1563396c
commit ad65a376fd
2 changed files with 21 additions and 22 deletions

View File

@@ -1,5 +1,6 @@
import asyncio import asyncio
import os import os
import subprocess
from pathlib import Path 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() 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) cmd = [_gniza_bin(), "--cli"] + list(args)
if log_file:
fh = open(log_file, "w") fh = open(log_file, "w")
proc = await asyncio.create_subprocess_exec( proc = subprocess.Popen(
*cmd, cmd,
stdout=fh, stdout=fh,
stderr=asyncio.subprocess.STDOUT, stderr=subprocess.STDOUT,
start_new_session=True, start_new_session=True,
) )
fh.close() fh.close()
return proc return proc
return await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
start_new_session=True,
)
async def stream_cli(callback, *args: str) -> int: async def stream_cli(callback, *args: str) -> int:

View File

@@ -2,6 +2,7 @@ import asyncio
import json import json
import os import os
import signal import signal
import subprocess
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -9,7 +10,7 @@ from pathlib import Path
from textual.message import Message from textual.message import Message
from tui.backend import start_cli_process from tui.backend import start_cli_background
MAX_OUTPUT_LINES = 10_000 MAX_OUTPUT_LINES = 10_000
FINISHED_JOB_TTL_HOURS = 24 FINISHED_JOB_TTL_HOURS = 24
@@ -42,7 +43,7 @@ class Job:
finished_at: datetime | None = None finished_at: datetime | None = None
return_code: int | None = None return_code: int | None = None
output: list[str] = field(default_factory=list) 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) _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)
@@ -80,7 +81,7 @@ class JobManager:
async def run_job(self, app, job: Job, *cli_args: str) -> int: async def run_job(self, app, job: Job, *cli_args: str) -> int:
log_path = _work_dir() / f"gniza-job-{job.id}.log" log_path = _work_dir() / f"gniza-job-{job.id}.log"
job._log_file = str(log_path) 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._proc = proc
job._pid = proc.pid job._pid = proc.pid
try: try:
@@ -89,10 +90,9 @@ class JobManager:
job._pgid = None job._pgid = None
self._save_registry() self._save_registry()
try: try:
# Wait for process and tail log file concurrently # Poll process and tail log file
wait_task = asyncio.create_task(proc.wait())
with open(log_path, "r") as f: with open(log_path, "r") as f:
while not wait_task.done(): while proc.poll() is None:
line = f.readline() line = f.readline()
if line: if line:
text = line.rstrip("\n") text = line.rstrip("\n")
@@ -130,7 +130,7 @@ class JobManager:
return job.return_code if job.return_code is not None else 1 return job.return_code if job.return_code is not None else 1
@staticmethod @staticmethod
def _kill_process_group(proc: asyncio.subprocess.Process) -> None: def _kill_process_group(proc: subprocess.Popen) -> None:
try: try:
pgid = os.getpgid(proc.pid) pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGKILL) os.killpg(pgid, signal.SIGKILL)