Preserve native geometry coverage and publish lifecycle fixture PID files atomically so the newly enabled native CI cannot read an empty PID.
211 lines
8.5 KiB
Python
211 lines
8.5 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
import select
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
SCRIPTS_DIR = Path(__file__).resolve().parents[2]
|
|
if str(SCRIPTS_DIR) not in sys.path:
|
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
|
|
from conformance.harness import ConformanceContext, ConformanceError
|
|
|
|
|
|
REAL_POPEN = subprocess.Popen
|
|
FIXTURE = """
|
|
from pathlib import Path
|
|
import os, subprocess, sys, time
|
|
|
|
root = Path(sys.argv[1])
|
|
role = sys.argv[2]
|
|
if role != 'grandchild':
|
|
next_role = 'child' if role == 'parent' else 'grandchild'
|
|
subprocess.Popen([sys.executable, '-I', '-S', __file__, str(root), next_role])
|
|
# Publish the PID only after the contents are complete. The parent polls for
|
|
# file existence, so creating the final path first exposes an empty file.
|
|
pid_path = root / (role + '.pid.tmp')
|
|
pid_path.write_text(str(os.getpid()))
|
|
pid_path.replace(root / (role + '.pid'))
|
|
while True:
|
|
if role == 'parent' and (root / 'exit').exists():
|
|
code = root / 'exit-code'
|
|
raise SystemExit(int(code.read_text()) if code.exists() else 0)
|
|
time.sleep(0.01)
|
|
"""
|
|
|
|
|
|
@unittest.skipUnless(os.name == "posix", "POSIX launch process groups")
|
|
class PosixLifecycleTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.temporary = tempfile.TemporaryDirectory(prefix="nebula-posix-lifecycle-")
|
|
self.root = Path(self.temporary.name)
|
|
self.fixture = self.root / "fixture.py"
|
|
self.fixture.write_text(FIXTURE, encoding="utf-8")
|
|
self.app = SimpleNamespace(executable=self.root / "fixture-app")
|
|
self.context = ConformanceContext(
|
|
self.app, "linux-test", self.root / "config", self.root / "work",
|
|
self.root / "artifacts", startup_timeout=2,
|
|
)
|
|
self.context.prepare()
|
|
self.context.port_file.write_text("1 fixture-token", encoding="utf-8")
|
|
self.parents: list[subprocess.Popen] = []
|
|
self.launches: list[Path] = []
|
|
self.descendants: list[int] = []
|
|
self.unrelated: subprocess.Popen | None = None
|
|
self.addCleanup(self.cleanup_owned_processes)
|
|
|
|
def launch_fixture(command, **kwargs):
|
|
launch = self.root / f"launch-{len(self.launches) + 1}"
|
|
launch.mkdir()
|
|
self.launches.append(launch)
|
|
# All fixture descendants inherit this pipe. EOF proves they have
|
|
# actually exited even when their former parent is already reaped.
|
|
kwargs["stdout"] = subprocess.PIPE
|
|
process = REAL_POPEN([
|
|
sys.executable, "-I", "-S", os.fspath(self.fixture),
|
|
os.fspath(launch), "parent",
|
|
], **kwargs)
|
|
self.parents.append(process)
|
|
return process
|
|
|
|
self.client = SimpleNamespace(request=lambda *args, **kwargs: {
|
|
"result": {
|
|
"process_id": self.parents[-1].pid,
|
|
"windows": [{"tabs": [{"kind": "shell", "panes": [{}]}]}],
|
|
},
|
|
})
|
|
self.launch_patch = patch("conformance.harness.subprocess.Popen", side_effect=launch_fixture)
|
|
self.client_patch = patch(
|
|
"conformance.harness.RuntimeClient.from_port_file", return_value=self.client,
|
|
)
|
|
self.launch_patch.start()
|
|
self.client_patch.start()
|
|
self.addCleanup(self.launch_patch.stop)
|
|
self.addCleanup(self.client_patch.stop)
|
|
|
|
def capture_descendants(self) -> None:
|
|
for role in ("child", "grandchild"):
|
|
path = self.launches[-1] / f"{role}.pid"
|
|
deadline = time.monotonic() + 5
|
|
while not path.is_file() and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
self.assertTrue(path.is_file(), f"fixture never created {role}")
|
|
self.descendants.append(int(path.read_text()))
|
|
|
|
def assert_tree_exited(self, process: subprocess.Popen) -> None:
|
|
self.assertTrue(select.select([process.stdout], [], [], 2)[0], "launch descendants survived")
|
|
self.assertEqual(os.read(process.stdout.fileno(), 65536), b"")
|
|
|
|
def cleanup_owned_processes(self) -> None:
|
|
self.context.stop(force=True)
|
|
# Keep the negative fixture bounded when running against the old harness.
|
|
for launch in self.launches:
|
|
for role in ("child", "grandchild"):
|
|
path = launch / f"{role}.pid"
|
|
if path.is_file() and path.read_text():
|
|
self.descendants.append(int(path.read_text()))
|
|
for pid in set(self.descendants):
|
|
try:
|
|
os.kill(pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
for process in [*self.parents, self.unrelated]:
|
|
if process is not None:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
if process.stdout is not None:
|
|
process.stdout.close()
|
|
self.temporary.cleanup()
|
|
|
|
def test_force_stop_terminates_the_launch_but_not_an_unrelated_process(self) -> None:
|
|
self.unrelated = REAL_POPEN(
|
|
[sys.executable, "-I", "-S", "-c", "import time; time.sleep(60)"],
|
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
)
|
|
self.context.start()
|
|
self.capture_descendants()
|
|
self.context.stop(force=True)
|
|
self.assert_tree_exited(self.parents[-1])
|
|
self.assertIsNone(self.unrelated.poll())
|
|
|
|
def test_stop_terminates_children_after_the_wrapper_has_exited(self) -> None:
|
|
self.context.start()
|
|
self.capture_descendants()
|
|
(self.launches[-1] / "exit").touch()
|
|
self.context.process.wait(timeout=5)
|
|
self.context.stop(force=True)
|
|
self.assert_tree_exited(self.parents[-1])
|
|
|
|
def test_restart_terminates_the_previous_launch(self) -> None:
|
|
self.context.start()
|
|
self.capture_descendants()
|
|
self.context.restart()
|
|
self.capture_descendants()
|
|
self.assert_tree_exited(self.parents[0])
|
|
self.assertFalse(select.select([self.parents[1].stdout], [], [], 0)[0])
|
|
|
|
def test_normal_close_terminates_leftover_children_and_preserves_exit_code(self) -> None:
|
|
for code in (0, 7):
|
|
with self.subTest(code=code):
|
|
self.context.start()
|
|
self.capture_descendants()
|
|
(self.launches[-1] / "exit-code").write_text(str(code))
|
|
with patch.object(
|
|
self.context, "api",
|
|
side_effect=lambda *args, **kwargs: (self.launches[-1] / "exit").touch(),
|
|
):
|
|
if code:
|
|
with self.assertRaisesRegex(ConformanceError, "Nebula exited with code 7"):
|
|
self.context.close_and_wait()
|
|
else:
|
|
self.context.close_and_wait()
|
|
self.assert_tree_exited(self.parents[-1])
|
|
|
|
def test_start_rejects_an_endpoint_from_another_launch(self) -> None:
|
|
self.context.startup_timeout = 0.15
|
|
self.client.request = lambda *args, **kwargs: {
|
|
"result": {
|
|
"process_id": os.getpid(),
|
|
"windows": [{"tabs": [{"kind": "shell", "panes": [{}]}]}],
|
|
},
|
|
}
|
|
with self.assertRaisesRegex(ConformanceError, "runtime process does not belong to this launch"):
|
|
self.context.start()
|
|
self.assert_tree_exited(self.parents[-1])
|
|
|
|
def test_start_accepts_the_application_beneath_a_wrapper(self) -> None:
|
|
self.client.request = lambda *args, **kwargs: {
|
|
"result": {
|
|
"process_id": int((self.launches[-1] / "child.pid").read_text()),
|
|
"windows": [{"tabs": [{"kind": "shell", "panes": [{}]}]}],
|
|
},
|
|
}
|
|
self.context.start()
|
|
self.capture_descendants()
|
|
self.context.stop(force=True)
|
|
self.assert_tree_exited(self.parents[-1])
|
|
|
|
def test_cleanup_failure_retains_the_owned_group_for_retry(self) -> None:
|
|
self.context.start()
|
|
self.capture_descendants()
|
|
process = self.context.process
|
|
with patch("conformance.harness.os.killpg", side_effect=OSError("cleanup unavailable")):
|
|
with self.assertRaisesRegex(OSError, "cleanup unavailable"):
|
|
self.context.stop(force=True)
|
|
self.assertIs(self.context.process, process)
|
|
self.context.stop(force=True)
|
|
self.assert_tree_exited(process)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|