from __future__ import annotations
|
|
import ctypes
|
import json
|
import os
|
import pathlib
|
import sys
|
import subprocess
|
import tempfile
|
import time
|
import unittest
|
from ctypes import wintypes
|
|
PROJECT_DEV = pathlib.Path(__file__).resolve().parents[2]
|
if str(PROJECT_DEV) not in sys.path:
|
sys.path.insert(0, str(PROJECT_DEV))
|
|
from bili_authenticated_extension.job import WindowsJob, close_handles, open_nul_handles # noqa: E402
|
|
HELPER = pathlib.Path(__file__).with_name("sleep_tree_helper.py")
|
|
|
def wait_records(path: pathlib.Path, count: int, timeout: float = 8.0) -> list[dict]:
|
deadline = time.monotonic() + timeout
|
while time.monotonic() < deadline:
|
if path.exists():
|
try:
|
records = [json.loads(line) for line in path.read_text(encoding="ascii").splitlines() if line]
|
except (OSError, json.JSONDecodeError):
|
records = []
|
if len(records) >= count:
|
return records
|
time.sleep(0.05)
|
raise AssertionError(f"expected {count} PID records")
|
|
|
def pid_signaled(pid: int, timeout: float = 5.0) -> bool:
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
synchronize = 0x00100000
|
handle = kernel32.OpenProcess(synchronize, False, pid)
|
if not handle:
|
return True
|
try:
|
return kernel32.WaitForSingleObject(handle, int(timeout * 1000)) == 0
|
finally:
|
kernel32.CloseHandle(handle)
|
|
|
@unittest.skipUnless(os.name == "nt", "Windows Job Object test")
|
class JobObjectTests(unittest.TestCase):
|
def test_suspended_assign_resume_and_all_descendants_die_for_seven_terminal_causes(self) -> None:
|
causes = ("cancel", "eof", "disconnect", "keyboard_interrupt", "system_exit", "timeout", "host_kill")
|
for cause in causes:
|
with self.subTest(cause=cause), tempfile.TemporaryDirectory() as temporary:
|
pid_file = pathlib.Path(temporary) / "pids.jsonl"
|
nul_in, nul_out, nul_error = open_nul_handles()
|
job = WindowsJob()
|
process = None
|
try:
|
process = job.launch_suspended(
|
[sys.executable, str(HELPER), "--role", "worker", "--pid-file", str(pid_file)],
|
stdin_handle=nul_in,
|
stdout_handle=nul_out,
|
stderr_handle=nul_error,
|
)
|
records = wait_records(pid_file, 3)
|
self.assertEqual({"worker", "child", "grandchild"}, {item["role"] for item in records})
|
if cause == "host_kill":
|
job.close()
|
else:
|
job.terminate()
|
self.assertTrue(process.wait(5))
|
for item in records:
|
self.assertTrue(pid_signaled(item["pid"]), item)
|
finally:
|
if process is not None:
|
process.close()
|
job.close()
|
close_handles(nul_in, nul_out, nul_error)
|
|
def test_abrupt_job_owner_death_kills_real_worker_child_and_grandchild(self) -> None:
|
with tempfile.TemporaryDirectory() as temporary:
|
pid_file = pathlib.Path(temporary) / "owner-pids.jsonl"
|
owner = subprocess.Popen(
|
[sys.executable, str(HELPER), "--role", "owner", "--pid-file", str(pid_file)],
|
stdin=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
)
|
try:
|
records = wait_records(pid_file, 4)
|
self.assertEqual(
|
{"owner", "worker", "child", "grandchild"},
|
{item["role"] for item in records},
|
)
|
descendants = [item for item in records if item["role"] != "owner"]
|
owner.kill()
|
owner.wait(timeout=5)
|
for item in descendants:
|
self.assertTrue(pid_signaled(item["pid"]), item)
|
finally:
|
if owner.poll() is None:
|
owner.kill()
|
owner.wait(timeout=5)
|
|
|
if __name__ == "__main__":
|
unittest.main()
|