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 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,
proc = subprocess.Popen(
cmd,
stdout=fh,
stderr=asyncio.subprocess.STDOUT,
stderr=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,
start_new_session=True,
)
async def stream_cli(callback, *args: str) -> int:

View File

@@ -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)