Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions tests/test_slash_worker_watchdog.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import psutil
import pytest

from tui_gateway import slash_worker

Expand All @@ -19,3 +20,18 @@ def test_is_orphaned_false_when_parent_alive_and_matches():
assert (
slash_worker._is_orphaned(me.pid, me.create_time(), getppid=lambda: me.pid) is False
)


def test_is_orphaned_true_when_parent_is_zombie(monkeypatch):
me = psutil.Process()

class ZombieParent:
def status(self):
return psutil.STATUS_ZOMBIE

def create_time(self):
return me.create_time()

monkeypatch.setattr(slash_worker.psutil, "pid_exists", lambda pid: True)
monkeypatch.setattr(slash_worker.psutil, "Process", lambda pid: ZombieParent())
assert slash_worker._is_orphaned(me.pid, me.create_time(), getppid=lambda: me.pid) is True
199 changes: 199 additions & 0 deletions tests/tui_gateway/test_slash_worker_lifecycle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
"""Tests for slash_worker orphan reaping and lifecycle helpers."""

from __future__ import annotations

import psutil
import pytest

from tui_gateway import slash_worker_lifecycle as lifecycle


def test_is_slash_worker_cmdline():
assert lifecycle.is_slash_worker_cmdline(
["/usr/bin/python", "-m", "tui_gateway.slash_worker", "--session-key", "k"]
)
assert not lifecycle.is_slash_worker_cmdline(["python", "-m", "pytest"])


def test_has_live_gateway_owner_true_for_direct_child(monkeypatch):
my_pid = 4242

class FakeParent:
ppid = my_pid

def cmdline(self):
return []

monkeypatch.setattr(lifecycle.psutil, "Process", lambda pid: FakeParent())
assert lifecycle.has_live_gateway_owner(9001, my_pid=my_pid) is True


def test_has_live_gateway_owner_true_for_gateway_ancestor(monkeypatch):
gateway_pid = 5000
worker_pid = 9001

class GatewayProc:
ppid = 1

def cmdline(self):
return ["python", "-m", "tui_gateway.entry"]

def status(self):
return psutil.STATUS_RUNNING

class WorkerParent:
ppid = gateway_pid

def cmdline(self):
return ["conhost.exe"]

def status(self):
return psutil.STATUS_RUNNING

def fake_process(pid):
if pid == worker_pid:
return WorkerParent()
if pid == gateway_pid:
return GatewayProc()
raise psutil.NoSuchProcess(pid)

monkeypatch.setattr(lifecycle.psutil, "Process", fake_process)
assert lifecycle.has_live_gateway_owner(worker_pid, my_pid=4242) is True


def test_has_live_gateway_owner_false_when_chain_breaks(monkeypatch):
worker_pid = 9001

class WorkerParent:
ppid = 7777

def cmdline(self):
return ["bash"]

def status(self):
return psutil.STATUS_RUNNING

def fake_process(pid):
if pid == worker_pid:
return WorkerParent()
raise psutil.NoSuchProcess(pid)

monkeypatch.setattr(lifecycle.psutil, "Process", fake_process)
assert lifecycle.has_live_gateway_owner(worker_pid, my_pid=4242) is False


def test_has_live_gateway_owner_supports_psutil7_ppid_method(monkeypatch):
my_pid = 4242
worker_pid = 9001
gateway_pid = 5000

class GatewayProc:
def ppid(self):
return 1

def cmdline(self):
return ["python", "-m", "tui_gateway.entry"]

def status(self):
return psutil.STATUS_RUNNING

class WorkerParent:
def ppid(self):
return gateway_pid

def cmdline(self):
return ["conhost.exe"]

def status(self):
return psutil.STATUS_RUNNING

def fake_process(pid):
if pid == worker_pid:
return WorkerParent()
if pid == gateway_pid:
return GatewayProc()
raise psutil.NoSuchProcess(pid)

monkeypatch.setattr(lifecycle.psutil, "Process", fake_process)
assert lifecycle.has_live_gateway_owner(worker_pid, my_pid=my_pid) is True


def test_reap_orphan_slash_workers_terminates_unowned(monkeypatch):
terminated: list[int] = []

class FakeProcInfo:
def __init__(self, pid, cmdline):
self.info = {"pid": pid, "cmdline": cmdline}

rows = [
FakeProcInfo(100, ["python", "-m", "tui_gateway.slash_worker", "--session-key", "k1"]),
FakeProcInfo(101, ["python", "-m", "pytest"]),
]

monkeypatch.setattr(
lifecycle.psutil,
"process_iter",
lambda attrs: iter(rows),
)
monkeypatch.setattr(
lifecycle,
"has_live_gateway_owner",
lambda pid, my_pid: False,
)
monkeypatch.setattr(
lifecycle,
"_terminate_pid",
lambda pid: terminated.append(pid),
)

count = lifecycle.reap_orphan_slash_workers(my_pid=4242)
assert count == 1
assert terminated == [100]


def test_reap_orphan_slash_workers_skips_owned_workers(monkeypatch):
terminated: list[int] = []

class FakeProcInfo:
def __init__(self, pid, cmdline):
self.info = {"pid": pid, "cmdline": cmdline}

rows = [
FakeProcInfo(100, ["python", "-m", "tui_gateway.slash_worker", "--session-key", "k1"]),
]

monkeypatch.setattr(
lifecycle.psutil,
"process_iter",
lambda attrs: iter(rows),
)
monkeypatch.setattr(
lifecycle,
"has_live_gateway_owner",
lambda pid, my_pid: True,
)
monkeypatch.setattr(
lifecycle,
"_terminate_pid",
lambda pid: terminated.append(pid),
)

count = lifecycle.reap_orphan_slash_workers(my_pid=4242)
assert count == 0
assert terminated == []


def test_maybe_reap_orphan_slash_workers_on_startup_runs_once(monkeypatch):
lifecycle._reaper_ran = False
calls: list[int] = []

monkeypatch.setattr(
lifecycle,
"reap_orphan_slash_workers",
lambda **kwargs: calls.append(1) or 2,
)

lifecycle.maybe_reap_orphan_slash_workers_on_startup()
lifecycle.maybe_reap_orphan_slash_workers_on_startup()

assert calls == [1]
79 changes: 79 additions & 0 deletions tests/tui_gateway/test_slash_worker_lifecycle_e2e.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Windows-only end-to-end checks for slash_worker lifecycle helpers."""

from __future__ import annotations

import subprocess
import sys
import time

import psutil
import pytest

pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows job-object e2e")


def test_kill_on_close_job_terminates_child_when_spawner_exits():
script = """
import subprocess, sys
from tui_gateway.slash_worker_lifecycle import attach_slash_worker_kill_job

BREAKAWAY = 0x01000000
proc = subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(120)"],
creationflags=BREAKAWAY,
)
job = attach_slash_worker_kill_job(proc)
if job is None:
raise SystemExit("job attach failed")
# Keep job handle alive until this process exits.
_KEEP_JOB = job
print(proc.pid, flush=True)
"""
spawner = subprocess.Popen(
[sys.executable, "-c", script],
stdout=subprocess.PIPE,
text=True,
)
out, _ = spawner.communicate(timeout=15)
worker_pid = int(out.strip())
assert spawner.returncode == 0
time.sleep(2)
assert not psutil.pid_exists(worker_pid), "child should die when job handle closes"


def test_reaper_kills_orphan_slash_worker_after_launcher_exits():
launcher = """
import subprocess, sys, time
BREAKAWAY = 0x01000000
proc = subprocess.Popen(
[sys.executable, "-m", "tui_gateway.slash_worker", "--session-key", "e2e-reap"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
creationflags=BREAKAWAY,
)
print(proc.pid, flush=True)
time.sleep(0.5)
"""
launcher_proc = subprocess.Popen(
[sys.executable, "-c", launcher],
stdout=subprocess.PIPE,
text=True,
)
out, _ = launcher_proc.communicate(timeout=20)
worker_pid = int(out.strip())
# Give watchdog a moment; if still alive, reaper must kill it.
time.sleep(1)
if not psutil.pid_exists(worker_pid):
pytest.skip("watchdog already reaped worker before reaper ran")

from tui_gateway.slash_worker_lifecycle import reap_orphan_slash_workers

reaped = reap_orphan_slash_workers(my_pid=__import__("os").getpid())
time.sleep(1)
try:
assert reaped >= 1
assert not psutil.pid_exists(worker_pid)
finally:
if psutil.pid_exists(worker_pid):
psutil.Process(worker_pid).kill()
14 changes: 14 additions & 0 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,12 @@ def __init__(self, session_key: str, model: str):
if model:
argv += ["--model", model]

popen_kwargs: dict = {}
if sys.platform == "win32":
# Escape the parent job (Electron/Desktop) so AssignProcessToJobObject
# can bind the worker to our kill-on-close job (#48643).
popen_kwargs["creationflags"] = 0x01000000 # CREATE_BREAKAWAY_FROM_JOB

self._closed = False
self.proc = subprocess.Popen(
argv,
Expand All @@ -256,7 +262,11 @@ def __init__(self, session_key: str, model: str):
bufsize=1,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main already passes creationflags=windows_hide_flags() in this same Popen call (tui_gateway/server.py:323). Salvage this by composing the breakaway bit with that helper; adding a second creationflags kwarg conflicts, while replacing it loses the existing no-console-window behavior.

cwd=os.getcwd(),
env=os.environ.copy(),
**popen_kwargs,
)
from tui_gateway.slash_worker_lifecycle import attach_slash_worker_kill_job

self._win_kill_job = attach_slash_worker_kill_job(self.proc)
threading.Thread(target=self._drain_stdout, daemon=True).start()
threading.Thread(target=self._drain_stderr, daemon=True).start()

Expand Down Expand Up @@ -640,6 +650,10 @@ def _loop():
atexit.register(_shutdown_sessions)
_start_idle_reaper()

from tui_gateway.slash_worker_lifecycle import maybe_reap_orphan_slash_workers_on_startup

maybe_reap_orphan_slash_workers_on_startup()


# ── Plumbing ──────────────────────────────────────────────────────────

Expand Down
5 changes: 4 additions & 1 deletion tui_gateway/slash_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ def _is_orphaned(original_ppid, parent_create_time, getppid=os.getppid) -> bool:
try:
if not psutil.pid_exists(original_ppid):
return True
return psutil.Process(original_ppid).create_time() != parent_create_time
parent = psutil.Process(original_ppid)
if parent.status() in (psutil.STATUS_ZOMBIE, psutil.STATUS_DEAD):
return True
return parent.create_time() != parent_create_time
except psutil.Error:
return True

Expand Down
Loading