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 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:
|
||||||
|
|||||||
14
tui/jobs.py
14
tui/jobs.py
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user