|
1 | 1 | import os |
2 | 2 | import json |
3 | 3 | import shutil |
| 4 | +import signal |
4 | 5 | import subprocess |
5 | 6 | import tempfile |
| 7 | +import time |
6 | 8 | import unittest |
7 | 9 | from getpass import getuser |
8 | 10 | from pathlib import Path |
@@ -1895,6 +1897,109 @@ def test_atomic_lock_directory_blocks_new_run(self): |
1895 | 1897 | restore_path(self.lock_path, lock_backup) |
1896 | 1898 | restore_path(self.lock_dir, lock_dir_backup) |
1897 | 1899 |
|
| 1900 | + def test_termination_cleans_up_descendant_processes(self): |
| 1901 | + with tempfile.TemporaryDirectory() as temp_dir: |
| 1902 | + temp_root = Path(temp_dir) / "repo" |
| 1903 | + shutil.copytree(ROOT, temp_root, ignore=shutil.ignore_patterns('repositories')) |
| 1904 | + |
| 1905 | + child_pid_file = Path(temp_dir) / "child.pid" |
| 1906 | + ready_file = Path(temp_dir) / "ready" |
| 1907 | + |
| 1908 | + self._write_stub( |
| 1909 | + temp_root / "agent-lifecycle" / "sync_agents.py", |
| 1910 | + ( |
| 1911 | + "#!/usr/bin/env python3\n" |
| 1912 | + "import os\n" |
| 1913 | + "import signal\n" |
| 1914 | + "import subprocess\n" |
| 1915 | + "import sys\n" |
| 1916 | + "import time\n" |
| 1917 | + "child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n" |
| 1918 | + "with open(os.environ['CHILD_PID_FILE'], 'w', encoding='utf-8') as fh:\n" |
| 1919 | + " fh.write(str(child.pid))\n" |
| 1920 | + "Path = __import__('pathlib').Path\n" |
| 1921 | + "Path(os.environ['READY_FILE']).write_text('ready\\n', encoding='utf-8')\n" |
| 1922 | + "signal.signal(signal.SIGTERM, lambda *_args: sys.exit(143))\n" |
| 1923 | + "while True:\n" |
| 1924 | + " time.sleep(1)\n" |
| 1925 | + ), |
| 1926 | + ) |
| 1927 | + self._write_stub( |
| 1928 | + temp_root / "agent-tool" / "sync_tools.py", |
| 1929 | + "#!/usr/bin/env python3\nimport sys\nsys.exit(0)\n", |
| 1930 | + ) |
| 1931 | + self._write_stub( |
| 1932 | + temp_root / "agent-skill" / "link_skills.py", |
| 1933 | + "#!/usr/bin/env python3\nimport sys\nsys.exit(0)\n", |
| 1934 | + ) |
| 1935 | + self._write_stub( |
| 1936 | + temp_root / "agent-mcp" / "sync_mcp.py", |
| 1937 | + "#!/usr/bin/env python3\nimport sys\nsys.exit(0)\n", |
| 1938 | + ) |
| 1939 | + self._write_stub( |
| 1940 | + temp_root / "agent-rule" / "scripts" / "sync-agent-rules.sh", |
| 1941 | + "#!/usr/bin/env bash\nexit 0\n", |
| 1942 | + ) |
| 1943 | + self._write_stub( |
| 1944 | + temp_root / "agent-terminal" / "sync_terminal.py", |
| 1945 | + "#!/usr/bin/env python3\nimport sys\nsys.exit(0)\n", |
| 1946 | + ) |
| 1947 | + |
| 1948 | + env = self._managed_env() |
| 1949 | + env["CHILD_PID_FILE"] = str(child_pid_file) |
| 1950 | + env["READY_FILE"] = str(ready_file) |
| 1951 | + |
| 1952 | + proc = subprocess.Popen( |
| 1953 | + [str(temp_root / "manage.sh"), "--offline", "update"], |
| 1954 | + cwd=temp_root, |
| 1955 | + env=env, |
| 1956 | + stdout=subprocess.PIPE, |
| 1957 | + stderr=subprocess.PIPE, |
| 1958 | + text=True, |
| 1959 | + ) |
| 1960 | + |
| 1961 | + child_pid = None |
| 1962 | + try: |
| 1963 | + deadline = time.time() + 10 |
| 1964 | + while time.time() < deadline: |
| 1965 | + if ready_file.exists() and child_pid_file.exists(): |
| 1966 | + child_pid = int(child_pid_file.read_text(encoding="utf-8").strip()) |
| 1967 | + break |
| 1968 | + if proc.poll() is not None: |
| 1969 | + break |
| 1970 | + time.sleep(0.1) |
| 1971 | + |
| 1972 | + self.assertIsNotNone(child_pid, "生命周期任务未按预期启动子进程") |
| 1973 | + os.kill(child_pid, 0) |
| 1974 | + |
| 1975 | + proc.terminate() |
| 1976 | + proc.wait(timeout=10) |
| 1977 | + proc.communicate(timeout=1) |
| 1978 | + |
| 1979 | + deadline = time.time() + 3 |
| 1980 | + child_alive = True |
| 1981 | + while time.time() < deadline: |
| 1982 | + try: |
| 1983 | + os.kill(child_pid, 0) |
| 1984 | + except OSError: |
| 1985 | + child_alive = False |
| 1986 | + break |
| 1987 | + time.sleep(0.1) |
| 1988 | + |
| 1989 | + self.assertFalse(child_alive, f"manage.sh 退出后子进程仍存活: {child_pid}") |
| 1990 | + finally: |
| 1991 | + if proc.poll() is None: |
| 1992 | + proc.kill() |
| 1993 | + proc.communicate(timeout=5) |
| 1994 | + else: |
| 1995 | + proc.stdout.close() |
| 1996 | + proc.stderr.close() |
| 1997 | + if child_pid is not None: |
| 1998 | + try: |
| 1999 | + os.kill(child_pid, signal.SIGKILL) |
| 2000 | + except OSError: |
| 2001 | + pass |
| 2002 | + |
1898 | 2003 | def test_unlock_removes_atomic_lock_artifacts(self): |
1899 | 2004 | lock_backup = move_aside(self.lock_path) |
1900 | 2005 | lock_dir_backup = move_aside(self.lock_dir) |
@@ -2594,3 +2699,89 @@ def test_mcp_sync_defaults_to_config_only_mode(self): |
2594 | 2699 | combined = (result.stdout + result.stderr).decode("utf-8", errors="replace") |
2595 | 2700 | self.assertEqual(result.returncode, 0, combined) |
2596 | 2701 | self.assertTrue(agent_config_path.exists(), combined) |
| 2702 | + |
| 2703 | + def test_atomic_mcp_sync_termination_cleans_up_descendant_processes(self): |
| 2704 | + with tempfile.TemporaryDirectory() as temp_dir: |
| 2705 | + temp_root = Path(temp_dir) / "repo" |
| 2706 | + shutil.copytree(ROOT, temp_root, ignore=shutil.ignore_patterns('repositories')) |
| 2707 | + |
| 2708 | + child_pid_file = Path(temp_dir) / "mcp-child.pid" |
| 2709 | + ready_file = Path(temp_dir) / "mcp-ready" |
| 2710 | + |
| 2711 | + self._write_stub( |
| 2712 | + temp_root / "agent-mcp" / "sync_mcp.py", |
| 2713 | + ( |
| 2714 | + "#!/usr/bin/env python3\n" |
| 2715 | + "import os\n" |
| 2716 | + "import signal\n" |
| 2717 | + "import subprocess\n" |
| 2718 | + "import sys\n" |
| 2719 | + "import time\n" |
| 2720 | + "child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n" |
| 2721 | + "with open(os.environ['CHILD_PID_FILE'], 'w', encoding='utf-8') as fh:\n" |
| 2722 | + " fh.write(str(child.pid))\n" |
| 2723 | + "Path = __import__('pathlib').Path\n" |
| 2724 | + "Path(os.environ['READY_FILE']).write_text('ready\\n', encoding='utf-8')\n" |
| 2725 | + "signal.signal(signal.SIGTERM, lambda *_args: sys.exit(143))\n" |
| 2726 | + "while True:\n" |
| 2727 | + " time.sleep(1)\n" |
| 2728 | + ), |
| 2729 | + ) |
| 2730 | + |
| 2731 | + env = self._managed_env() |
| 2732 | + env["CHILD_PID_FILE"] = str(child_pid_file) |
| 2733 | + env["READY_FILE"] = str(ready_file) |
| 2734 | + |
| 2735 | + proc = subprocess.Popen( |
| 2736 | + [str(temp_root / "manage.sh"), "--offline", "mcp:sync"], |
| 2737 | + cwd=temp_root, |
| 2738 | + env=env, |
| 2739 | + stdout=subprocess.PIPE, |
| 2740 | + stderr=subprocess.PIPE, |
| 2741 | + text=True, |
| 2742 | + ) |
| 2743 | + |
| 2744 | + child_pid = None |
| 2745 | + try: |
| 2746 | + deadline = time.time() + 10 |
| 2747 | + while time.time() < deadline: |
| 2748 | + if ready_file.exists() and child_pid_file.exists(): |
| 2749 | + child_pid = int(child_pid_file.read_text(encoding="utf-8").strip()) |
| 2750 | + break |
| 2751 | + if proc.poll() is not None: |
| 2752 | + break |
| 2753 | + time.sleep(0.1) |
| 2754 | + |
| 2755 | + self.assertIsNotNone(child_pid, "mcp:sync 未按预期启动子进程") |
| 2756 | + os.kill(child_pid, 0) |
| 2757 | + |
| 2758 | + proc.terminate() |
| 2759 | + proc.wait(timeout=10) |
| 2760 | + proc.stdout.close() |
| 2761 | + proc.stderr.close() |
| 2762 | + |
| 2763 | + deadline = time.time() + 3 |
| 2764 | + child_alive = True |
| 2765 | + while time.time() < deadline: |
| 2766 | + try: |
| 2767 | + os.kill(child_pid, 0) |
| 2768 | + except OSError: |
| 2769 | + child_alive = False |
| 2770 | + break |
| 2771 | + time.sleep(0.1) |
| 2772 | + |
| 2773 | + self.assertFalse(child_alive, f"mcp:sync 退出后子进程仍存活: {child_pid}") |
| 2774 | + finally: |
| 2775 | + if proc.poll() is None: |
| 2776 | + proc.kill() |
| 2777 | + proc.wait(timeout=5) |
| 2778 | + proc.stdout.close() |
| 2779 | + proc.stderr.close() |
| 2780 | + else: |
| 2781 | + proc.stdout.close() |
| 2782 | + proc.stderr.close() |
| 2783 | + if child_pid is not None: |
| 2784 | + try: |
| 2785 | + os.kill(child_pid, signal.SIGKILL) |
| 2786 | + except OSError: |
| 2787 | + pass |
0 commit comments