process_manager.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. """Cross-platform PID-file based process management for zhuce6."""
  2. from __future__ import annotations
  3. import ctypes
  4. import os
  5. from pathlib import Path
  6. import signal
  7. import subprocess
  8. import time
  9. from .paths import STATE_DIR
  10. PID_DIR = STATE_DIR
  11. PROJECT_ROOT = Path(__file__).resolve().parents[1]
  12. def pid_file(name: str) -> Path:
  13. return Path(PID_DIR) / f"zhuce6-{name}.pid"
  14. def write_pid(name: str, pid: int | None = None) -> Path:
  15. path = pid_file(name)
  16. path.parent.mkdir(parents=True, exist_ok=True)
  17. path.write_text(str(int(pid if pid is not None else os.getpid())), encoding="utf-8")
  18. return path
  19. def read_pid(name: str) -> int | None:
  20. path = pid_file(name)
  21. if not path.is_file():
  22. return None
  23. try:
  24. return int(path.read_text(encoding="utf-8").strip() or "0")
  25. except (OSError, ValueError):
  26. return None
  27. def is_running(pid: int) -> bool:
  28. if pid <= 0:
  29. return False
  30. if os.name == "nt":
  31. process = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid)
  32. if process == 0:
  33. return False
  34. ctypes.windll.kernel32.CloseHandle(process)
  35. return True
  36. try:
  37. os.kill(pid, 0)
  38. except ProcessLookupError:
  39. return False
  40. except PermissionError:
  41. return True
  42. proc_stat = Path("/proc") / str(pid) / "stat"
  43. if proc_stat.is_file():
  44. try:
  45. fields = proc_stat.read_text(encoding="utf-8").split()
  46. except OSError:
  47. return True
  48. if len(fields) >= 3 and fields[2] == "Z":
  49. return False
  50. return True
  51. def remove_pid(name: str) -> None:
  52. try:
  53. pid_file(name).unlink()
  54. except FileNotFoundError:
  55. return
  56. def _send_terminate(pid: int) -> None:
  57. if os.name == "nt":
  58. subprocess.run(["taskkill", "/PID", str(pid), "/T"], capture_output=True, text=True, check=False)
  59. return
  60. os.kill(pid, signal.SIGTERM)
  61. def _send_kill(pid: int) -> None:
  62. if os.name == "nt":
  63. subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, text=True, check=False)
  64. return
  65. os.kill(pid, signal.SIGKILL)
  66. def _stop_pid(pid: int | None, timeout: float = 5.0, *, remove_name: str | None = None) -> bool:
  67. if pid is None:
  68. if remove_name:
  69. remove_pid(remove_name)
  70. return False
  71. if pid == os.getpid():
  72. if remove_name:
  73. remove_pid(remove_name)
  74. return True
  75. if not is_running(pid):
  76. if remove_name:
  77. remove_pid(remove_name)
  78. return False
  79. try:
  80. _send_terminate(pid)
  81. except OSError:
  82. pass
  83. deadline = time.time() + max(0.1, timeout)
  84. while time.time() < deadline:
  85. if not is_running(pid):
  86. if remove_name:
  87. remove_pid(remove_name)
  88. return True
  89. time.sleep(0.1)
  90. try:
  91. _send_kill(pid)
  92. except OSError:
  93. pass
  94. force_deadline = time.time() + 2.0
  95. while time.time() < force_deadline:
  96. if not is_running(pid):
  97. if remove_name:
  98. remove_pid(remove_name)
  99. return True
  100. time.sleep(0.1)
  101. if not is_running(pid):
  102. if remove_name:
  103. remove_pid(remove_name)
  104. return True
  105. return False
  106. def _list_repo_process_pids() -> list[int]:
  107. if os.name == "nt":
  108. return []
  109. proc_root = Path("/proc")
  110. if not proc_root.is_dir():
  111. return []
  112. project_root = PROJECT_ROOT.resolve()
  113. current_pid = os.getpid()
  114. matched: list[int] = []
  115. for entry in proc_root.iterdir():
  116. if not entry.name.isdigit():
  117. continue
  118. pid = int(entry.name)
  119. if pid == current_pid:
  120. continue
  121. try:
  122. cwd = (entry / "cwd").resolve()
  123. except OSError:
  124. continue
  125. if cwd != project_root:
  126. continue
  127. try:
  128. cmdline = (entry / "cmdline").read_text(encoding="utf-8", errors="ignore").replace("\x00", " ")
  129. except OSError:
  130. continue
  131. if "main.py" not in cmdline or "zhuce6" not in cmdline:
  132. continue
  133. matched.append(pid)
  134. return sorted(set(matched))
  135. def stop_process(name: str, timeout: float = 5.0) -> bool:
  136. return _stop_pid(read_pid(name), timeout=timeout, remove_name=name)
  137. def stop_all(timeout: float = 5.0) -> dict[str, bool]:
  138. results: dict[str, bool] = {}
  139. for path in sorted(Path(PID_DIR).glob("zhuce6-*.pid")):
  140. name = path.stem.removeprefix("zhuce6-")
  141. results[name] = stop_process(name, timeout=timeout)
  142. orphan_pids = _list_repo_process_pids()
  143. for pid in orphan_pids:
  144. _stop_pid(pid, timeout=timeout)
  145. results["orphan_pids"] = orphan_pids
  146. return results
  147. def status_all() -> list[dict[str, object]]:
  148. statuses: list[dict[str, object]] = []
  149. for path in sorted(Path(PID_DIR).glob("zhuce6-*.pid")):
  150. name = path.stem.removeprefix("zhuce6-")
  151. pid = read_pid(name)
  152. statuses.append(
  153. {
  154. "name": name,
  155. "pid": pid,
  156. "running": bool(pid and is_running(pid)),
  157. "pid_file": str(path),
  158. }
  159. )
  160. return statuses