From ad65a376fd3f313f86f06953957ca0c4b1f268e4 Mon Sep 17 00:00:00 2001 From: shuki Date: Fri, 6 Mar 2026 21:28:32 +0200 Subject: [PATCH] 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 --- tui/backend.py | 29 ++++++++++++++--------------- tui/jobs.py | 14 +++++++------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/tui/backend.py b/tui/backend.py index ffdbb04..ae81604 100644 --- a/tui/backend.py +++ b/tui/backend.py @@ -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: diff --git a/tui/jobs.py b/tui/jobs.py index c1dd63f..010cf52 100644 --- a/tui/jobs.py +++ b/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)