codegraph-engine 2.1.7__py3-none-any.whl → 2.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codegraph/__init__.py +1 -1
- codegraph/cli.py +177 -54
- codegraph/cli_output.py +190 -0
- codegraph/context.py +119 -3
- codegraph/database/interrogation.py +30 -7
- codegraph/freshness.py +59 -21
- codegraph/indexing/indexer.py +11 -4
- codegraph/installer.py +72 -22
- codegraph/interrogation.py +19 -0
- codegraph/mcp/server.py +97 -6
- codegraph/mcp_diagnostics.py +5 -2
- codegraph/monorepo.py +1 -1
- codegraph/process_lifecycle.py +1115 -0
- codegraph/runtime/runner.py +204 -0
- {codegraph_engine-2.1.7.dist-info → codegraph_engine-2.2.0.dist-info}/METADATA +48 -23
- {codegraph_engine-2.1.7.dist-info → codegraph_engine-2.2.0.dist-info}/RECORD +20 -17
- {codegraph_engine-2.1.7.dist-info → codegraph_engine-2.2.0.dist-info}/WHEEL +0 -0
- {codegraph_engine-2.1.7.dist-info → codegraph_engine-2.2.0.dist-info}/entry_points.txt +0 -0
- {codegraph_engine-2.1.7.dist-info → codegraph_engine-2.2.0.dist-info}/licenses/LICENSE +0 -0
- {codegraph_engine-2.1.7.dist-info → codegraph_engine-2.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1115 @@
|
|
|
1
|
+
"""Cross-Platform MCP Process Lifecycle, Ownership Verification, and Safe Shutdown (`src/codegraph/process_lifecycle.py`).
|
|
2
|
+
|
|
3
|
+
Solves:
|
|
4
|
+
- Phase 2: Root-cause instrumentation for `codegraph mcp serve` (PID, parent PID, creation tokens, stdio state, platform).
|
|
5
|
+
- Phase 3: Deterministic MCP stdio shutdown (stdin EOF / transport disconnect, worker stop, SQLite close, child cleanup).
|
|
6
|
+
- Phase 4: Cross-platform parent/child ownership detection (POSIX reparent/zombie detection + Windows Win32 handle/creation-time verification).
|
|
7
|
+
- Phase 5: Safe `codegraph stop` command with strict ownership verification (never kills unrelated Python or reused-PID processes).
|
|
8
|
+
- Phase 6: Upgrade lock detection & safe cleanup during `codegraph install` and `codegraph uninstall`.
|
|
9
|
+
- Phase 7: Atomic process metadata (`~/.codegraph/processes/pid_<pid>.json`) and automatic stale/crash recovery.
|
|
10
|
+
- Phase 9: `codegraph doctor --processes` observability without exposing secrets or environment credentials.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import atexit
|
|
15
|
+
import contextlib
|
|
16
|
+
import ctypes
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import signal
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from enum import StrEnum
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from codegraph.cli_output import get_cli_symbols
|
|
34
|
+
|
|
35
|
+
OWNERSHIP_MARKER = "CODEGRAPH_OWNED_PROCESS_V1"
|
|
36
|
+
PROCESS_SCHEMA_VERSION = "1.0"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class OwnershipState(StrEnum):
|
|
40
|
+
"""Canonical ownership verification states for tracked PIDs."""
|
|
41
|
+
|
|
42
|
+
VERIFIED_CODEGRAPH_OWNED = "VERIFIED_CODEGRAPH_OWNED"
|
|
43
|
+
DEAD_PROCESS = "DEAD_PROCESS"
|
|
44
|
+
PID_REUSED_UNRELATED = "PID_REUSED_UNRELATED"
|
|
45
|
+
INVALID_MARKER = "INVALID_MARKER"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class LiveProcessInfo:
|
|
50
|
+
"""OS-level live process inspection snapshot."""
|
|
51
|
+
|
|
52
|
+
pid: int
|
|
53
|
+
ppid: int
|
|
54
|
+
create_token: str
|
|
55
|
+
executable: str
|
|
56
|
+
command_summary: str
|
|
57
|
+
is_zombie: bool = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ProcessRecord:
|
|
62
|
+
"""Atomic metadata persisted for a running CodeGraph process."""
|
|
63
|
+
|
|
64
|
+
schema_version: str
|
|
65
|
+
ownership_marker: str
|
|
66
|
+
session_id: str
|
|
67
|
+
pid: int
|
|
68
|
+
parent_pid: int
|
|
69
|
+
process_create_token: str
|
|
70
|
+
parent_create_token: str
|
|
71
|
+
executable: str
|
|
72
|
+
argv_signature: tuple[str, ...]
|
|
73
|
+
repository: str
|
|
74
|
+
mode: str
|
|
75
|
+
profile: str
|
|
76
|
+
platform: str
|
|
77
|
+
started_at: str
|
|
78
|
+
|
|
79
|
+
def as_dict(self) -> dict[str, Any]:
|
|
80
|
+
return {
|
|
81
|
+
"schema_version": self.schema_version,
|
|
82
|
+
"ownership_marker": self.ownership_marker,
|
|
83
|
+
"session_id": self.session_id,
|
|
84
|
+
"pid": self.pid,
|
|
85
|
+
"parent_pid": self.parent_pid,
|
|
86
|
+
"process_create_token": self.process_create_token,
|
|
87
|
+
"parent_create_token": self.parent_create_token,
|
|
88
|
+
"executable": self.executable,
|
|
89
|
+
"argv_signature": list(self.argv_signature),
|
|
90
|
+
"repository": self.repository,
|
|
91
|
+
"mode": self.mode,
|
|
92
|
+
"profile": self.profile,
|
|
93
|
+
"platform": self.platform,
|
|
94
|
+
"started_at": self.started_at,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_dict(cls, data: dict[str, Any]) -> ProcessRecord:
|
|
99
|
+
raw_argv = data.get("argv_signature", ["codegraph", "mcp", "serve"])
|
|
100
|
+
argv_tup = (
|
|
101
|
+
tuple(str(x) for x in raw_argv)
|
|
102
|
+
if isinstance(raw_argv, list)
|
|
103
|
+
else ("codegraph", "mcp", "serve")
|
|
104
|
+
)
|
|
105
|
+
return cls(
|
|
106
|
+
schema_version=str(data.get("schema_version", PROCESS_SCHEMA_VERSION)),
|
|
107
|
+
ownership_marker=str(data.get("ownership_marker", "")),
|
|
108
|
+
session_id=str(data.get("session_id", "")),
|
|
109
|
+
pid=int(data.get("pid", -1)),
|
|
110
|
+
parent_pid=int(data.get("parent_pid", -1)),
|
|
111
|
+
process_create_token=str(data.get("process_create_token", "")),
|
|
112
|
+
parent_create_token=str(data.get("parent_create_token", "")),
|
|
113
|
+
executable=str(data.get("executable", "")),
|
|
114
|
+
argv_signature=argv_tup,
|
|
115
|
+
repository=str(data.get("repository", "")),
|
|
116
|
+
mode=str(data.get("mode", "mcp_stdio")),
|
|
117
|
+
profile=str(data.get("profile", "full")),
|
|
118
|
+
platform=str(data.get("platform", sys.platform)),
|
|
119
|
+
started_at=str(data.get("started_at", "")),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class ActiveProcessStatus:
|
|
125
|
+
"""Verified status of an active CodeGraph process."""
|
|
126
|
+
|
|
127
|
+
record: ProcessRecord
|
|
128
|
+
ownership_state: OwnershipState
|
|
129
|
+
parent_alive: bool
|
|
130
|
+
orphaned: bool
|
|
131
|
+
safe_to_stop: bool
|
|
132
|
+
metadata_file: Path
|
|
133
|
+
|
|
134
|
+
def as_dict(self) -> dict[str, Any]:
|
|
135
|
+
return {
|
|
136
|
+
"pid": self.record.pid,
|
|
137
|
+
"parent_pid": self.record.parent_pid,
|
|
138
|
+
"repository": self.record.repository,
|
|
139
|
+
"mode": self.record.mode,
|
|
140
|
+
"profile": self.record.profile,
|
|
141
|
+
"platform": self.record.platform,
|
|
142
|
+
"started_at": self.record.started_at,
|
|
143
|
+
"argv_signature": list(self.record.argv_signature),
|
|
144
|
+
"ownership_state": self.ownership_state.value,
|
|
145
|
+
"parent_alive": self.parent_alive,
|
|
146
|
+
"orphaned": self.orphaned,
|
|
147
|
+
"safe_to_stop": self.safe_to_stop,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class ProcessDiscoveryResult:
|
|
153
|
+
"""Result of scanning the CodeGraph process registry."""
|
|
154
|
+
|
|
155
|
+
active_processes: tuple[ActiveProcessStatus, ...]
|
|
156
|
+
stale_cleaned_pids: tuple[int, ...] = ()
|
|
157
|
+
reused_skipped_pids: tuple[int, ...] = ()
|
|
158
|
+
|
|
159
|
+
def as_dict(self) -> dict[str, Any]:
|
|
160
|
+
return {
|
|
161
|
+
"status": "ok",
|
|
162
|
+
"active_count": len(self.active_processes),
|
|
163
|
+
"orphaned_count": sum(1 for p in self.active_processes if p.orphaned),
|
|
164
|
+
"stale_cleaned_count": len(self.stale_cleaned_pids),
|
|
165
|
+
"reused_skipped_count": len(self.reused_skipped_pids),
|
|
166
|
+
"processes": [p.as_dict() for p in self.active_processes],
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
def format_human(self, *, unicode_override: bool | None = None) -> str:
|
|
170
|
+
sym = get_cli_symbols(unicode_override=unicode_override)
|
|
171
|
+
lines: list[str] = ["CodeGraph Process Diagnostics", ""]
|
|
172
|
+
if not self.active_processes:
|
|
173
|
+
lines.append(f"{sym.ok} No active CodeGraph background processes found.")
|
|
174
|
+
if self.stale_cleaned_pids:
|
|
175
|
+
lines.append(
|
|
176
|
+
f" Cleaned {len(self.stale_cleaned_pids)} stale process metadata file(s)."
|
|
177
|
+
)
|
|
178
|
+
return "\n".join(lines)
|
|
179
|
+
|
|
180
|
+
lines.append(
|
|
181
|
+
f"{'PID':<8} {'Parent':<8} {'Mode':<11} {'Orphaned':<10} {'Safe Stop':<10} Repo"
|
|
182
|
+
)
|
|
183
|
+
for p in self.active_processes:
|
|
184
|
+
orph = "yes" if p.orphaned else "no"
|
|
185
|
+
safe = "yes" if p.safe_to_stop else "no"
|
|
186
|
+
lines.append(
|
|
187
|
+
f"{p.record.pid:<8} {p.record.parent_pid:<8} {p.record.mode:<11} "
|
|
188
|
+
f"{orph:<10} {safe:<10} {p.record.repository}"
|
|
189
|
+
)
|
|
190
|
+
return "\n".join(lines)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class StoppedProcessItem:
|
|
195
|
+
"""Single process stop outcome."""
|
|
196
|
+
|
|
197
|
+
pid: int
|
|
198
|
+
parent_pid: int
|
|
199
|
+
repository: str
|
|
200
|
+
stopped: bool
|
|
201
|
+
forced: bool
|
|
202
|
+
detail: str
|
|
203
|
+
|
|
204
|
+
def as_dict(self) -> dict[str, Any]:
|
|
205
|
+
return {
|
|
206
|
+
"pid": self.pid,
|
|
207
|
+
"parent_pid": self.parent_pid,
|
|
208
|
+
"repository": self.repository,
|
|
209
|
+
"stopped": self.stopped,
|
|
210
|
+
"forced": self.forced,
|
|
211
|
+
"detail": self.detail,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass(frozen=True)
|
|
216
|
+
class StopProcessesReport:
|
|
217
|
+
"""Structured report for `codegraph stop`."""
|
|
218
|
+
|
|
219
|
+
status: str # "ok" | "partial"
|
|
220
|
+
stopped_processes: tuple[StoppedProcessItem, ...]
|
|
221
|
+
stale_cleaned_count: int = 0
|
|
222
|
+
|
|
223
|
+
def as_dict(self) -> dict[str, Any]:
|
|
224
|
+
return {
|
|
225
|
+
"status": self.status,
|
|
226
|
+
"stopped_count": sum(1 for p in self.stopped_processes if p.stopped),
|
|
227
|
+
"stale_cleaned_count": self.stale_cleaned_count,
|
|
228
|
+
"processes": [p.as_dict() for p in self.stopped_processes],
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
def format_human(self, *, unicode_override: bool | None = None) -> str:
|
|
232
|
+
sym = get_cli_symbols(unicode_override=unicode_override)
|
|
233
|
+
if not self.stopped_processes:
|
|
234
|
+
msg = f"{sym.ok} No running CodeGraph processes found."
|
|
235
|
+
if self.stale_cleaned_count > 0:
|
|
236
|
+
msg += f" (Cleaned {self.stale_cleaned_count} stale metadata file(s).)"
|
|
237
|
+
return msg
|
|
238
|
+
|
|
239
|
+
lines: list[str] = [
|
|
240
|
+
"CodeGraph processes",
|
|
241
|
+
f"{'PID':<8}{'Parent':<10}Repo",
|
|
242
|
+
]
|
|
243
|
+
for item in self.stopped_processes:
|
|
244
|
+
lines.append(f"{item.pid:<8}{item.parent_pid:<10}{item.repository}")
|
|
245
|
+
lines.append("")
|
|
246
|
+
for item in self.stopped_processes:
|
|
247
|
+
lines.append(f"Stopping PID {item.pid}...")
|
|
248
|
+
if item.stopped:
|
|
249
|
+
lines.append(f"{sym.ok} {item.detail}")
|
|
250
|
+
else:
|
|
251
|
+
lines.append(f"{sym.error} {item.detail}")
|
|
252
|
+
return "\n".join(lines)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# Process Directory Resolution
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def get_process_registry_dir(custom_dir: Path | None = None) -> Path:
|
|
261
|
+
"""Return the directory where active CodeGraph process metadata files are stored."""
|
|
262
|
+
if custom_dir is not None:
|
|
263
|
+
return custom_dir.resolve()
|
|
264
|
+
env_dir = os.environ.get("CODEGRAPH_PROCESS_DIR", "").strip()
|
|
265
|
+
if env_dir:
|
|
266
|
+
return Path(env_dir).resolve()
|
|
267
|
+
home_dir = (Path.home() / ".codegraph" / "processes").resolve()
|
|
268
|
+
try:
|
|
269
|
+
home_dir.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
if os.access(home_dir, os.W_OK):
|
|
271
|
+
return home_dir
|
|
272
|
+
except OSError:
|
|
273
|
+
pass
|
|
274
|
+
return (Path(tempfile.gettempdir()) / "codegraph-processes").resolve()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _metadata_file_for_pid(pid: int, process_dir: Path | None = None) -> Path:
|
|
278
|
+
reg_dir = get_process_registry_dir(process_dir)
|
|
279
|
+
return reg_dir / f"pid_{pid}.json"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _atomic_write_json(target: Path, payload: dict[str, Any]) -> None:
|
|
283
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
285
|
+
dir=str(target.parent),
|
|
286
|
+
prefix=f".{target.name}.",
|
|
287
|
+
suffix=".tmp",
|
|
288
|
+
)
|
|
289
|
+
tmp_path = Path(tmp_name)
|
|
290
|
+
try:
|
|
291
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
292
|
+
json.dump(payload, fh, indent=2)
|
|
293
|
+
fh.write("\n")
|
|
294
|
+
os.replace(str(tmp_path), str(target))
|
|
295
|
+
finally:
|
|
296
|
+
if tmp_path.exists():
|
|
297
|
+
with contextlib.suppress(OSError):
|
|
298
|
+
tmp_path.unlink()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
# Cross-Platform OS Process Inspection (Windows + POSIX)
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _win32_inspect_process(pid: int) -> LiveProcessInfo | None:
|
|
307
|
+
"""Inspect a Windows process using Win32 kernel32 APIs without external dependencies."""
|
|
308
|
+
if sys.platform != "win32" or pid <= 0:
|
|
309
|
+
return None
|
|
310
|
+
try:
|
|
311
|
+
windll = getattr(ctypes, "windll", None)
|
|
312
|
+
if windll is None:
|
|
313
|
+
return None
|
|
314
|
+
kernel32 = windll.kernel32
|
|
315
|
+
# SYNCHRONIZE (0x00100000) | PROCESS_QUERY_LIMITED_INFORMATION (0x00001000)
|
|
316
|
+
access = 0x00100000 | 0x00001000
|
|
317
|
+
handle = kernel32.OpenProcess(access, False, int(pid))
|
|
318
|
+
if not handle:
|
|
319
|
+
return None
|
|
320
|
+
try:
|
|
321
|
+
exit_code = ctypes.c_ulong(0)
|
|
322
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
323
|
+
return None
|
|
324
|
+
# STILL_ACTIVE == 259; also check WaitForSingleObject(handle, 0) == WAIT_TIMEOUT (0x102)
|
|
325
|
+
wait_res = kernel32.WaitForSingleObject(handle, 0)
|
|
326
|
+
if exit_code.value != 259 or wait_res == 0:
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
# Creation time via GetProcessTimes
|
|
330
|
+
creation_time = ctypes.c_ulonglong(0)
|
|
331
|
+
exit_time = ctypes.c_ulonglong(0)
|
|
332
|
+
kernel_time = ctypes.c_ulonglong(0)
|
|
333
|
+
user_time = ctypes.c_ulonglong(0)
|
|
334
|
+
create_token = ""
|
|
335
|
+
if kernel32.GetProcessTimes(
|
|
336
|
+
handle,
|
|
337
|
+
ctypes.byref(creation_time),
|
|
338
|
+
ctypes.byref(exit_time),
|
|
339
|
+
ctypes.byref(kernel_time),
|
|
340
|
+
ctypes.byref(user_time),
|
|
341
|
+
):
|
|
342
|
+
create_token = str(creation_time.value)
|
|
343
|
+
|
|
344
|
+
# Executable path via QueryFullProcessImageNameW
|
|
345
|
+
buf_len = ctypes.c_ulong(1024)
|
|
346
|
+
buf = ctypes.create_unicode_buffer(1024)
|
|
347
|
+
exe_name = ""
|
|
348
|
+
if kernel32.QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(buf_len)):
|
|
349
|
+
exe_name = Path(buf.value).name.lower()
|
|
350
|
+
|
|
351
|
+
return LiveProcessInfo(
|
|
352
|
+
pid=pid,
|
|
353
|
+
ppid=0,
|
|
354
|
+
create_token=create_token,
|
|
355
|
+
executable=exe_name,
|
|
356
|
+
command_summary=exe_name,
|
|
357
|
+
is_zombie=False,
|
|
358
|
+
)
|
|
359
|
+
finally:
|
|
360
|
+
kernel32.CloseHandle(handle)
|
|
361
|
+
except Exception:
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class _DarwinProcBsdInfo(ctypes.Structure):
|
|
366
|
+
"""macOS `<sys/proc_info.h>` `struct proc_bsdinfo` (136 bytes)."""
|
|
367
|
+
|
|
368
|
+
_fields_ = [
|
|
369
|
+
("pbi_flags", ctypes.c_uint32),
|
|
370
|
+
("pbi_status", ctypes.c_uint32),
|
|
371
|
+
("pbi_xstatus", ctypes.c_uint32),
|
|
372
|
+
("pbi_pid", ctypes.c_uint32),
|
|
373
|
+
("pbi_ppid", ctypes.c_uint32),
|
|
374
|
+
("pbi_uid", ctypes.c_uint32),
|
|
375
|
+
("pbi_gid", ctypes.c_uint32),
|
|
376
|
+
("pbi_ruid", ctypes.c_uint32),
|
|
377
|
+
("pbi_rgid", ctypes.c_uint32),
|
|
378
|
+
("pbi_svuid", ctypes.c_uint32),
|
|
379
|
+
("pbi_svgid", ctypes.c_uint32),
|
|
380
|
+
("rfu_1", ctypes.c_uint32),
|
|
381
|
+
("pbi_comm", ctypes.c_char * 16),
|
|
382
|
+
("pbi_name", ctypes.c_char * 32),
|
|
383
|
+
("pbi_nfiles", ctypes.c_uint32),
|
|
384
|
+
("pbi_pgid", ctypes.c_uint32),
|
|
385
|
+
("pbi_pjobc", ctypes.c_uint32),
|
|
386
|
+
("e_tdev", ctypes.c_uint32),
|
|
387
|
+
("e_tpgid", ctypes.c_uint32),
|
|
388
|
+
("pbi_nice", ctypes.c_int32),
|
|
389
|
+
("pbi_start_tvsec", ctypes.c_uint64),
|
|
390
|
+
("pbi_start_tvusec", ctypes.c_uint64),
|
|
391
|
+
]
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _darwin_inspect_process(pid: int) -> LiveProcessInfo | None:
|
|
395
|
+
"""Inspect a macOS process directly via `libSystem.B.dylib` `proc_pidinfo` without spawning `ps`."""
|
|
396
|
+
if sys.platform != "darwin" or pid <= 0:
|
|
397
|
+
return None
|
|
398
|
+
try:
|
|
399
|
+
libc = ctypes.CDLL("/usr/lib/libSystem.B.dylib")
|
|
400
|
+
info = _DarwinProcBsdInfo()
|
|
401
|
+
# PROC_PIDTBSDINFO == 3, SZOMB == 5
|
|
402
|
+
expected_size = ctypes.sizeof(info)
|
|
403
|
+
res = int(libc.proc_pidinfo(int(pid), 3, 0, ctypes.byref(info), expected_size))
|
|
404
|
+
if res != expected_size:
|
|
405
|
+
return None
|
|
406
|
+
zombie = int(info.pbi_status) == 5
|
|
407
|
+
raw_name = bytes(info.pbi_name).split(b"\x00", 1)[0].decode("utf-8", errors="replace").strip()
|
|
408
|
+
raw_comm = bytes(info.pbi_comm).split(b"\x00", 1)[0].decode("utf-8", errors="replace").strip()
|
|
409
|
+
exe_name = (raw_name or raw_comm or "python").lower()
|
|
410
|
+
create_tok = f"{int(info.pbi_start_tvsec)}:{int(info.pbi_start_tvusec)}"
|
|
411
|
+
return LiveProcessInfo(
|
|
412
|
+
pid=pid,
|
|
413
|
+
ppid=int(info.pbi_ppid),
|
|
414
|
+
create_token=create_tok,
|
|
415
|
+
executable=exe_name,
|
|
416
|
+
command_summary=exe_name,
|
|
417
|
+
is_zombie=zombie,
|
|
418
|
+
)
|
|
419
|
+
except Exception:
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _posix_inspect_process(pid: int) -> LiveProcessInfo | None:
|
|
424
|
+
"""Inspect a POSIX (Linux / macOS) process via `/proc`, `proc_pidinfo`, or `ps`."""
|
|
425
|
+
if sys.platform == "win32" or pid <= 0:
|
|
426
|
+
return None
|
|
427
|
+
|
|
428
|
+
# Check if PID exists at all
|
|
429
|
+
try:
|
|
430
|
+
os.kill(pid, 0)
|
|
431
|
+
except ProcessLookupError:
|
|
432
|
+
return None
|
|
433
|
+
except PermissionError:
|
|
434
|
+
pass
|
|
435
|
+
except OSError:
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
# 1. Fast path on macOS: native kernel `proc_pidinfo` (zero subprocesses, sandbox-safe)
|
|
439
|
+
if sys.platform == "darwin":
|
|
440
|
+
darwin_info = _darwin_inspect_process(pid)
|
|
441
|
+
if darwin_info is None or darwin_info.is_zombie:
|
|
442
|
+
return None
|
|
443
|
+
return darwin_info
|
|
444
|
+
|
|
445
|
+
# 2. Fast path on Linux: /proc/<pid>/stat
|
|
446
|
+
proc_stat = Path(f"/proc/{pid}/stat")
|
|
447
|
+
if proc_stat.exists():
|
|
448
|
+
try:
|
|
449
|
+
raw = proc_stat.read_text(encoding="utf-8", errors="replace")
|
|
450
|
+
rparen = raw.rfind(")")
|
|
451
|
+
lparen = raw.find("(")
|
|
452
|
+
if lparen != -1 and rparen != -1:
|
|
453
|
+
comm = raw[lparen + 1 : rparen]
|
|
454
|
+
fields_after = raw[rparen + 2 :].split()
|
|
455
|
+
state = fields_after[0] if len(fields_after) > 0 else ""
|
|
456
|
+
ppid = int(fields_after[1]) if len(fields_after) > 1 else 0
|
|
457
|
+
starttime = fields_after[19] if len(fields_after) > 19 else ""
|
|
458
|
+
is_zombie = state.upper().startswith("Z")
|
|
459
|
+
if is_zombie:
|
|
460
|
+
return None
|
|
461
|
+
cmdline_path = Path(f"/proc/{pid}/cmdline")
|
|
462
|
+
cmd_summary = comm
|
|
463
|
+
if cmdline_path.exists():
|
|
464
|
+
raw_cmd = cmdline_path.read_bytes().replace(b"\x00", b" ").decode(
|
|
465
|
+
"utf-8", errors="replace"
|
|
466
|
+
).strip()
|
|
467
|
+
if raw_cmd:
|
|
468
|
+
cmd_summary = raw_cmd[:200]
|
|
469
|
+
return LiveProcessInfo(
|
|
470
|
+
pid=pid,
|
|
471
|
+
ppid=ppid,
|
|
472
|
+
create_token=starttime,
|
|
473
|
+
executable=comm.lower(),
|
|
474
|
+
command_summary=cmd_summary.lower(),
|
|
475
|
+
is_zombie=False,
|
|
476
|
+
)
|
|
477
|
+
except Exception:
|
|
478
|
+
pass
|
|
479
|
+
|
|
480
|
+
# 3. Fallback on BSD / other POSIX: `ps -p <pid> -o ppid=,stat=,lstart=,comm=`
|
|
481
|
+
try:
|
|
482
|
+
res = subprocess.run(
|
|
483
|
+
["ps", "-p", str(pid), "-o", "ppid=,stat=,lstart=,comm="],
|
|
484
|
+
capture_output=True,
|
|
485
|
+
text=True,
|
|
486
|
+
timeout=2.0,
|
|
487
|
+
check=False,
|
|
488
|
+
)
|
|
489
|
+
if res.returncode == 0 and res.stdout.strip():
|
|
490
|
+
line = res.stdout.strip().splitlines()[0].strip()
|
|
491
|
+
parts = line.split()
|
|
492
|
+
if len(parts) >= 8:
|
|
493
|
+
ppid = int(parts[0])
|
|
494
|
+
stat_str = parts[1]
|
|
495
|
+
if stat_str.upper().startswith("Z"):
|
|
496
|
+
return None
|
|
497
|
+
lstart = " ".join(parts[2:7])
|
|
498
|
+
comm_str = " ".join(parts[7:])
|
|
499
|
+
exe_name = Path(comm_str).name.lower()
|
|
500
|
+
return LiveProcessInfo(
|
|
501
|
+
pid=pid,
|
|
502
|
+
ppid=ppid,
|
|
503
|
+
create_token=lstart,
|
|
504
|
+
executable=exe_name,
|
|
505
|
+
command_summary=comm_str.lower(),
|
|
506
|
+
is_zombie=False,
|
|
507
|
+
)
|
|
508
|
+
except Exception:
|
|
509
|
+
pass
|
|
510
|
+
|
|
511
|
+
# `os.kill(pid, 0)` succeeded above, so the process is alive even if `ps` was restricted
|
|
512
|
+
return LiveProcessInfo(
|
|
513
|
+
pid=pid,
|
|
514
|
+
ppid=0,
|
|
515
|
+
create_token="",
|
|
516
|
+
executable="",
|
|
517
|
+
command_summary="",
|
|
518
|
+
is_zombie=False,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def get_live_process_info(pid: int) -> LiveProcessInfo | None:
|
|
523
|
+
"""Return live OS process information for `pid`, or `None` if dead/zombie."""
|
|
524
|
+
if pid <= 0:
|
|
525
|
+
return None
|
|
526
|
+
if sys.platform == "win32":
|
|
527
|
+
return _win32_inspect_process(pid)
|
|
528
|
+
return _posix_inspect_process(pid)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def is_pid_alive(pid: int) -> bool:
|
|
532
|
+
"""Return True if `pid` is currently alive and not a zombie process."""
|
|
533
|
+
return get_live_process_info(pid) is not None
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def is_parent_alive(
|
|
537
|
+
parent_pid: int,
|
|
538
|
+
parent_create_token: str = "",
|
|
539
|
+
*,
|
|
540
|
+
current_ppid: int | None = None,
|
|
541
|
+
) -> bool:
|
|
542
|
+
"""Return True if the parent process `parent_pid` is still alive and owns this child."""
|
|
543
|
+
if parent_pid <= 1:
|
|
544
|
+
return False
|
|
545
|
+
|
|
546
|
+
if sys.platform != "win32":
|
|
547
|
+
actual_ppid = current_ppid if current_ppid is not None else os.getppid()
|
|
548
|
+
if actual_ppid != parent_pid:
|
|
549
|
+
# Reparented to PID 1 / launchd / systemd --user
|
|
550
|
+
return False
|
|
551
|
+
|
|
552
|
+
live = get_live_process_info(parent_pid)
|
|
553
|
+
if live is None or live.is_zombie:
|
|
554
|
+
return False
|
|
555
|
+
|
|
556
|
+
if parent_create_token and live.create_token and live.create_token != parent_create_token:
|
|
557
|
+
# Parent PID was recycled by the OS for a newer process
|
|
558
|
+
return False
|
|
559
|
+
|
|
560
|
+
return True
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def verify_codegraph_ownership(
|
|
564
|
+
record: ProcessRecord,
|
|
565
|
+
*,
|
|
566
|
+
live_info: LiveProcessInfo | None = None,
|
|
567
|
+
) -> OwnershipState:
|
|
568
|
+
"""Verify that `record` corresponds to a live CodeGraph-owned process and not a reused PID."""
|
|
569
|
+
if record.ownership_marker != OWNERSHIP_MARKER or record.pid <= 0:
|
|
570
|
+
return OwnershipState.INVALID_MARKER
|
|
571
|
+
|
|
572
|
+
info = live_info if live_info is not None else get_live_process_info(record.pid)
|
|
573
|
+
if info is None or info.is_zombie:
|
|
574
|
+
return OwnershipState.DEAD_PROCESS
|
|
575
|
+
|
|
576
|
+
# 1. Creation timestamp/token check protects against PID reuse after a crash
|
|
577
|
+
if (
|
|
578
|
+
record.process_create_token
|
|
579
|
+
and info.create_token
|
|
580
|
+
and record.process_create_token != info.create_token
|
|
581
|
+
):
|
|
582
|
+
return OwnershipState.PID_REUSED_UNRELATED
|
|
583
|
+
|
|
584
|
+
# 2. Executable / command signature check
|
|
585
|
+
if info.executable:
|
|
586
|
+
rec_exe = Path(record.executable).name.lower() if record.executable else ""
|
|
587
|
+
live_exe = Path(info.executable).name.lower()
|
|
588
|
+
allowed_tokens = ("python", "py", "codegraph", "pytest", "uv")
|
|
589
|
+
matches_known = any(
|
|
590
|
+
tok in live_exe or tok in info.command_summary for tok in allowed_tokens
|
|
591
|
+
)
|
|
592
|
+
matches_recorded = bool(rec_exe and (rec_exe in live_exe or live_exe in rec_exe))
|
|
593
|
+
if not matches_known and not matches_recorded:
|
|
594
|
+
return OwnershipState.PID_REUSED_UNRELATED
|
|
595
|
+
|
|
596
|
+
return OwnershipState.VERIFIED_CODEGRAPH_OWNED
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# ---------------------------------------------------------------------------
|
|
600
|
+
# Process Registration, Discovery & Safe Termination
|
|
601
|
+
# ---------------------------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def build_current_process_record(
|
|
605
|
+
repository: Path,
|
|
606
|
+
mode: str = "mcp_stdio",
|
|
607
|
+
profile: str = "full",
|
|
608
|
+
argv_signature: tuple[str, ...] = ("codegraph", "mcp", "serve"),
|
|
609
|
+
) -> ProcessRecord:
|
|
610
|
+
"""Capture current process and parent process ownership metadata."""
|
|
611
|
+
pid = os.getpid()
|
|
612
|
+
ppid = os.getppid()
|
|
613
|
+
self_info = get_live_process_info(pid)
|
|
614
|
+
parent_info = get_live_process_info(ppid) if ppid > 1 else None
|
|
615
|
+
|
|
616
|
+
return ProcessRecord(
|
|
617
|
+
schema_version=PROCESS_SCHEMA_VERSION,
|
|
618
|
+
ownership_marker=OWNERSHIP_MARKER,
|
|
619
|
+
session_id=uuid.uuid4().hex,
|
|
620
|
+
pid=pid,
|
|
621
|
+
parent_pid=ppid,
|
|
622
|
+
process_create_token=self_info.create_token if self_info else "",
|
|
623
|
+
parent_create_token=parent_info.create_token if parent_info else "",
|
|
624
|
+
executable=Path(sys.executable).name if sys.executable else "python",
|
|
625
|
+
argv_signature=argv_signature,
|
|
626
|
+
repository=str(repository.resolve()),
|
|
627
|
+
mode=mode,
|
|
628
|
+
profile=profile,
|
|
629
|
+
platform=sys.platform,
|
|
630
|
+
started_at=datetime.now(UTC).isoformat(),
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def register_process_record(
|
|
635
|
+
record: ProcessRecord,
|
|
636
|
+
process_dir: Path | None = None,
|
|
637
|
+
) -> Path:
|
|
638
|
+
"""Persist `record` atomically to `~/.codegraph/processes/pid_<pid>.json`."""
|
|
639
|
+
meta_file = _metadata_file_for_pid(record.pid, process_dir)
|
|
640
|
+
_atomic_write_json(meta_file, record.as_dict())
|
|
641
|
+
return meta_file
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def unregister_process_record(
|
|
645
|
+
pid: int,
|
|
646
|
+
session_id: str | None = None,
|
|
647
|
+
process_dir: Path | None = None,
|
|
648
|
+
) -> None:
|
|
649
|
+
"""Remove `pid_<pid>.json` if it belongs to `pid` (and `session_id` if specified)."""
|
|
650
|
+
meta_file = _metadata_file_for_pid(pid, process_dir)
|
|
651
|
+
if not meta_file.exists():
|
|
652
|
+
return
|
|
653
|
+
try:
|
|
654
|
+
if session_id is not None:
|
|
655
|
+
raw = json.loads(meta_file.read_text(encoding="utf-8"))
|
|
656
|
+
if raw.get("session_id") != session_id:
|
|
657
|
+
return
|
|
658
|
+
meta_file.unlink(missing_ok=True)
|
|
659
|
+
except OSError:
|
|
660
|
+
pass
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def discover_codegraph_processes(
|
|
664
|
+
process_dir: Path | None = None,
|
|
665
|
+
*,
|
|
666
|
+
clean_stale: bool = True,
|
|
667
|
+
) -> ProcessDiscoveryResult:
|
|
668
|
+
"""Scan `process_dir` for tracked CodeGraph processes, cleaning up stale/reused PID files."""
|
|
669
|
+
reg_dir = get_process_registry_dir(process_dir)
|
|
670
|
+
if not reg_dir.exists():
|
|
671
|
+
return ProcessDiscoveryResult(active_processes=())
|
|
672
|
+
|
|
673
|
+
active: list[ActiveProcessStatus] = []
|
|
674
|
+
stale_pids: list[int] = []
|
|
675
|
+
reused_pids: list[int] = []
|
|
676
|
+
|
|
677
|
+
for meta_file in sorted(reg_dir.glob("pid_*.json")):
|
|
678
|
+
try:
|
|
679
|
+
raw = json.loads(meta_file.read_text(encoding="utf-8"))
|
|
680
|
+
if not isinstance(raw, dict):
|
|
681
|
+
if clean_stale:
|
|
682
|
+
meta_file.unlink(missing_ok=True)
|
|
683
|
+
continue
|
|
684
|
+
record = ProcessRecord.from_dict(raw)
|
|
685
|
+
except Exception:
|
|
686
|
+
if clean_stale:
|
|
687
|
+
with contextlib.suppress(OSError):
|
|
688
|
+
meta_file.unlink(missing_ok=True)
|
|
689
|
+
continue
|
|
690
|
+
|
|
691
|
+
verdict = verify_codegraph_ownership(record)
|
|
692
|
+
if verdict == OwnershipState.DEAD_PROCESS:
|
|
693
|
+
stale_pids.append(record.pid)
|
|
694
|
+
if clean_stale:
|
|
695
|
+
with contextlib.suppress(OSError):
|
|
696
|
+
meta_file.unlink(missing_ok=True)
|
|
697
|
+
continue
|
|
698
|
+
|
|
699
|
+
if verdict in (OwnershipState.PID_REUSED_UNRELATED, OwnershipState.INVALID_MARKER):
|
|
700
|
+
reused_pids.append(record.pid)
|
|
701
|
+
if clean_stale:
|
|
702
|
+
with contextlib.suppress(OSError):
|
|
703
|
+
meta_file.unlink(missing_ok=True)
|
|
704
|
+
continue
|
|
705
|
+
|
|
706
|
+
live_self = get_live_process_info(record.pid)
|
|
707
|
+
observed_ppid = (
|
|
708
|
+
live_self.ppid
|
|
709
|
+
if (live_self is not None and live_self.ppid > 0)
|
|
710
|
+
else record.parent_pid
|
|
711
|
+
)
|
|
712
|
+
parent_ok = is_parent_alive(
|
|
713
|
+
record.parent_pid,
|
|
714
|
+
record.parent_create_token,
|
|
715
|
+
current_ppid=observed_ppid,
|
|
716
|
+
)
|
|
717
|
+
active.append(
|
|
718
|
+
ActiveProcessStatus(
|
|
719
|
+
record=record,
|
|
720
|
+
ownership_state=verdict,
|
|
721
|
+
parent_alive=parent_ok,
|
|
722
|
+
orphaned=not parent_ok,
|
|
723
|
+
safe_to_stop=(verdict == OwnershipState.VERIFIED_CODEGRAPH_OWNED),
|
|
724
|
+
metadata_file=meta_file,
|
|
725
|
+
)
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
return ProcessDiscoveryResult(
|
|
729
|
+
active_processes=tuple(active),
|
|
730
|
+
stale_cleaned_pids=tuple(stale_pids),
|
|
731
|
+
reused_skipped_pids=tuple(reused_pids),
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _terminate_verified_pid(pid: int, force: bool = False) -> bool:
|
|
736
|
+
"""Send graceful or forced termination to a verified CodeGraph-owned PID."""
|
|
737
|
+
if pid <= 0 or pid == os.getpid():
|
|
738
|
+
return False
|
|
739
|
+
|
|
740
|
+
if sys.platform == "win32":
|
|
741
|
+
try:
|
|
742
|
+
windll = getattr(ctypes, "windll", None)
|
|
743
|
+
if windll is not None:
|
|
744
|
+
kernel32 = windll.kernel32
|
|
745
|
+
# PROCESS_TERMINATE == 0x0001
|
|
746
|
+
handle = kernel32.OpenProcess(0x0001, False, int(pid))
|
|
747
|
+
if not handle:
|
|
748
|
+
return not is_pid_alive(pid)
|
|
749
|
+
try:
|
|
750
|
+
ok = bool(kernel32.TerminateProcess(handle, 0))
|
|
751
|
+
return ok or not is_pid_alive(pid)
|
|
752
|
+
finally:
|
|
753
|
+
kernel32.CloseHandle(handle)
|
|
754
|
+
except Exception:
|
|
755
|
+
pass
|
|
756
|
+
with contextlib.suppress(OSError):
|
|
757
|
+
os.kill(pid, signal.SIGTERM)
|
|
758
|
+
return not is_pid_alive(pid)
|
|
759
|
+
|
|
760
|
+
sig = signal.SIGKILL if force else signal.SIGTERM
|
|
761
|
+
try:
|
|
762
|
+
os.kill(pid, sig)
|
|
763
|
+
return True
|
|
764
|
+
except ProcessLookupError:
|
|
765
|
+
return True
|
|
766
|
+
except PermissionError:
|
|
767
|
+
return False
|
|
768
|
+
except OSError:
|
|
769
|
+
return not is_pid_alive(pid)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def stop_codegraph_processes(
|
|
773
|
+
*,
|
|
774
|
+
repository: Path | None = None,
|
|
775
|
+
stop_all: bool = False,
|
|
776
|
+
only_orphaned: bool = False,
|
|
777
|
+
timeout_sec: float = 3.0,
|
|
778
|
+
process_dir: Path | None = None,
|
|
779
|
+
) -> StopProcessesReport:
|
|
780
|
+
"""Safely stop verified CodeGraph-owned background processes.
|
|
781
|
+
|
|
782
|
+
Guarantees:
|
|
783
|
+
- Never touches processes that fail `verify_codegraph_ownership`.
|
|
784
|
+
- When `repository` is specified and `stop_all=False`, only stops processes for `repository`.
|
|
785
|
+
- Requests graceful shutdown first (`SIGTERM`), escalating to `SIGKILL` only after `timeout_sec`.
|
|
786
|
+
"""
|
|
787
|
+
discovery = discover_codegraph_processes(process_dir=process_dir, clean_stale=True)
|
|
788
|
+
target_repo = str(repository.resolve()) if repository is not None else None
|
|
789
|
+
current_pid = os.getpid()
|
|
790
|
+
|
|
791
|
+
candidates: list[ActiveProcessStatus] = []
|
|
792
|
+
for proc_status in discovery.active_processes:
|
|
793
|
+
if proc_status.record.pid == current_pid:
|
|
794
|
+
continue
|
|
795
|
+
if not proc_status.safe_to_stop:
|
|
796
|
+
continue
|
|
797
|
+
if only_orphaned and not proc_status.orphaned:
|
|
798
|
+
continue
|
|
799
|
+
if (
|
|
800
|
+
not stop_all
|
|
801
|
+
and target_repo is not None
|
|
802
|
+
and proc_status.record.repository != target_repo
|
|
803
|
+
):
|
|
804
|
+
continue
|
|
805
|
+
candidates.append(proc_status)
|
|
806
|
+
|
|
807
|
+
outcomes: list[StoppedProcessItem] = []
|
|
808
|
+
for cand in candidates:
|
|
809
|
+
pid = cand.record.pid
|
|
810
|
+
# Re-verify ownership right before sending any signal
|
|
811
|
+
recheck = verify_codegraph_ownership(cand.record)
|
|
812
|
+
if recheck == OwnershipState.DEAD_PROCESS:
|
|
813
|
+
unregister_process_record(pid, cand.record.session_id, process_dir=process_dir)
|
|
814
|
+
outcomes.append(
|
|
815
|
+
StoppedProcessItem(
|
|
816
|
+
pid=pid,
|
|
817
|
+
parent_pid=cand.record.parent_pid,
|
|
818
|
+
repository=cand.record.repository,
|
|
819
|
+
stopped=True,
|
|
820
|
+
forced=False,
|
|
821
|
+
detail="Process already exited.",
|
|
822
|
+
)
|
|
823
|
+
)
|
|
824
|
+
continue
|
|
825
|
+
|
|
826
|
+
if recheck != OwnershipState.VERIFIED_CODEGRAPH_OWNED:
|
|
827
|
+
unregister_process_record(pid, cand.record.session_id, process_dir=process_dir)
|
|
828
|
+
outcomes.append(
|
|
829
|
+
StoppedProcessItem(
|
|
830
|
+
pid=pid,
|
|
831
|
+
parent_pid=cand.record.parent_pid,
|
|
832
|
+
repository=cand.record.repository,
|
|
833
|
+
stopped=False,
|
|
834
|
+
forced=False,
|
|
835
|
+
detail="Skipped: PID ownership changed.",
|
|
836
|
+
)
|
|
837
|
+
)
|
|
838
|
+
continue
|
|
839
|
+
|
|
840
|
+
_terminate_verified_pid(pid, force=False)
|
|
841
|
+
deadline = time.monotonic() + max(timeout_sec, 0.1)
|
|
842
|
+
while time.monotonic() < deadline:
|
|
843
|
+
if not is_pid_alive(pid):
|
|
844
|
+
break
|
|
845
|
+
time.sleep(0.05)
|
|
846
|
+
|
|
847
|
+
forced = False
|
|
848
|
+
if is_pid_alive(pid):
|
|
849
|
+
# Escalate after bounded timeout, re-verifying ownership first
|
|
850
|
+
if verify_codegraph_ownership(cand.record) == OwnershipState.VERIFIED_CODEGRAPH_OWNED:
|
|
851
|
+
forced = True
|
|
852
|
+
_terminate_verified_pid(pid, force=True)
|
|
853
|
+
force_deadline = time.monotonic() + 1.0
|
|
854
|
+
while time.monotonic() < force_deadline:
|
|
855
|
+
if not is_pid_alive(pid):
|
|
856
|
+
break
|
|
857
|
+
time.sleep(0.05)
|
|
858
|
+
|
|
859
|
+
exited = not is_pid_alive(pid)
|
|
860
|
+
if exited:
|
|
861
|
+
unregister_process_record(pid, cand.record.session_id, process_dir=process_dir)
|
|
862
|
+
detail = (
|
|
863
|
+
"Process exited cleanly."
|
|
864
|
+
if not forced
|
|
865
|
+
else "Process force-terminated after timeout."
|
|
866
|
+
)
|
|
867
|
+
else:
|
|
868
|
+
detail = "Process did not exit within timeout."
|
|
869
|
+
|
|
870
|
+
outcomes.append(
|
|
871
|
+
StoppedProcessItem(
|
|
872
|
+
pid=pid,
|
|
873
|
+
parent_pid=cand.record.parent_pid,
|
|
874
|
+
repository=cand.record.repository,
|
|
875
|
+
stopped=exited,
|
|
876
|
+
forced=forced,
|
|
877
|
+
detail=detail,
|
|
878
|
+
)
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
all_ok = all(o.stopped for o in outcomes)
|
|
882
|
+
return StopProcessesReport(
|
|
883
|
+
status="ok" if all_ok else "partial",
|
|
884
|
+
stopped_processes=tuple(outcomes),
|
|
885
|
+
stale_cleaned_count=len(discovery.stale_cleaned_pids),
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
# ---------------------------------------------------------------------------
|
|
890
|
+
# MCP Stdio Lifecycle & Shutdown Controller
|
|
891
|
+
# ---------------------------------------------------------------------------
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _is_stream_closed_or_eof(stream: Any) -> bool:
|
|
895
|
+
"""Return True if `stream` is closed or its underlying file descriptor is invalid."""
|
|
896
|
+
if stream is None:
|
|
897
|
+
return True
|
|
898
|
+
if getattr(stream, "closed", False):
|
|
899
|
+
return True
|
|
900
|
+
try:
|
|
901
|
+
fd = stream.fileno()
|
|
902
|
+
except (AttributeError, OSError, ValueError):
|
|
903
|
+
return False
|
|
904
|
+
if fd < 0:
|
|
905
|
+
return True
|
|
906
|
+
try:
|
|
907
|
+
os.fstat(fd)
|
|
908
|
+
return False
|
|
909
|
+
except OSError:
|
|
910
|
+
return True
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
@dataclass
|
|
914
|
+
class MCPLifecycleController:
|
|
915
|
+
"""Manages deterministic startup, parent/transport monitoring, and teardown for `codegraph mcp serve`."""
|
|
916
|
+
|
|
917
|
+
repository: Path
|
|
918
|
+
profile: str = "full"
|
|
919
|
+
mode: str = "mcp_stdio"
|
|
920
|
+
process_dir: Path | None = None
|
|
921
|
+
poll_interval_sec: float = 0.25
|
|
922
|
+
force_exit_on_async_stop: bool = False
|
|
923
|
+
record: ProcessRecord = field(init=False)
|
|
924
|
+
metadata_path: Path | None = field(default=None, init=False)
|
|
925
|
+
shutdown_reason: str | None = field(default=None, init=False)
|
|
926
|
+
_stop_event: threading.Event = field(default_factory=threading.Event, init=False)
|
|
927
|
+
_shutdown_lock: threading.Lock = field(default_factory=threading.Lock, init=False)
|
|
928
|
+
_shutdown_completed: bool = field(default=False, init=False)
|
|
929
|
+
_close_callbacks: list[Callable[[], None]] = field(default_factory=list, init=False)
|
|
930
|
+
_child_processes: list[subprocess.Popen[Any]] = field(default_factory=list, init=False)
|
|
931
|
+
_watchdog_thread: threading.Thread | None = field(default=None, init=False)
|
|
932
|
+
|
|
933
|
+
def __post_init__(self) -> None:
|
|
934
|
+
env_poll = os.environ.get("CODEGRAPH_PARENT_POLL_SEC", "").strip()
|
|
935
|
+
if env_poll:
|
|
936
|
+
with contextlib.suppress(ValueError):
|
|
937
|
+
self.poll_interval_sec = max(float(env_poll), 0.05)
|
|
938
|
+
self.record = build_current_process_record(
|
|
939
|
+
repository=self.repository,
|
|
940
|
+
mode=self.mode,
|
|
941
|
+
profile=self.profile,
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
def add_cleanup_callback(self, callback: Callable[[], None]) -> None:
|
|
945
|
+
"""Register a resource cleanup callback invoked during shutdown."""
|
|
946
|
+
self._close_callbacks.append(callback)
|
|
947
|
+
|
|
948
|
+
def register_child_process(self, proc: subprocess.Popen[Any]) -> None:
|
|
949
|
+
"""Track a child subprocess so it is terminated when the MCP server shuts down."""
|
|
950
|
+
self._child_processes.append(proc)
|
|
951
|
+
|
|
952
|
+
def start(self) -> None:
|
|
953
|
+
"""Clean stale registry files, write current process record, and start parent/transport watchdog."""
|
|
954
|
+
discover_codegraph_processes(process_dir=self.process_dir, clean_stale=True)
|
|
955
|
+
self.metadata_path = register_process_record(self.record, process_dir=self.process_dir)
|
|
956
|
+
atexit.register(self._atexit_cleanup)
|
|
957
|
+
|
|
958
|
+
self._watchdog_thread = threading.Thread(
|
|
959
|
+
target=self._watchdog_loop,
|
|
960
|
+
name="codegraph-mcp-lifecycle-watchdog",
|
|
961
|
+
daemon=True,
|
|
962
|
+
)
|
|
963
|
+
self._watchdog_thread.start()
|
|
964
|
+
|
|
965
|
+
def _watchdog_loop(self) -> None:
|
|
966
|
+
"""Low-overhead event-backed watchdog detecting parent termination or closed stdin."""
|
|
967
|
+
while not self._stop_event.wait(timeout=self.poll_interval_sec):
|
|
968
|
+
if _is_stream_closed_or_eof(sys.stdin):
|
|
969
|
+
self.shutdown("stdin_closed")
|
|
970
|
+
return
|
|
971
|
+
if not is_parent_alive(
|
|
972
|
+
self.record.parent_pid,
|
|
973
|
+
self.record.parent_create_token,
|
|
974
|
+
):
|
|
975
|
+
self.shutdown("parent_process_terminated")
|
|
976
|
+
return
|
|
977
|
+
|
|
978
|
+
def _atexit_cleanup(self) -> None:
|
|
979
|
+
self.shutdown("atexit")
|
|
980
|
+
|
|
981
|
+
def shutdown(self, reason: str = "normal_exit") -> None:
|
|
982
|
+
"""Idempotently stop workers, close resources, terminate child processes, and remove PID metadata."""
|
|
983
|
+
with self._shutdown_lock:
|
|
984
|
+
if self._shutdown_completed:
|
|
985
|
+
return
|
|
986
|
+
self._shutdown_completed = True
|
|
987
|
+
self.shutdown_reason = reason
|
|
988
|
+
self._stop_event.set()
|
|
989
|
+
|
|
990
|
+
# 1. Execute all registered resource/SQLite/governor cleanup callbacks
|
|
991
|
+
for cb in reversed(self._close_callbacks):
|
|
992
|
+
with contextlib.suppress(Exception):
|
|
993
|
+
cb()
|
|
994
|
+
|
|
995
|
+
# 2. Terminate any child subprocesses spawned by CodeGraph
|
|
996
|
+
for child in self._child_processes:
|
|
997
|
+
with contextlib.suppress(Exception):
|
|
998
|
+
if child.poll() is None:
|
|
999
|
+
child.terminate()
|
|
1000
|
+
try:
|
|
1001
|
+
child.wait(timeout=1.0)
|
|
1002
|
+
except subprocess.TimeoutExpired:
|
|
1003
|
+
child.kill()
|
|
1004
|
+
|
|
1005
|
+
# 3. Remove process metadata file
|
|
1006
|
+
unregister_process_record(
|
|
1007
|
+
self.record.pid,
|
|
1008
|
+
session_id=self.record.session_id,
|
|
1009
|
+
process_dir=self.process_dir,
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
# 4. Flush standard output/error streams safely
|
|
1013
|
+
with contextlib.suppress(Exception):
|
|
1014
|
+
sys.stdout.flush()
|
|
1015
|
+
with contextlib.suppress(Exception):
|
|
1016
|
+
sys.stderr.flush()
|
|
1017
|
+
|
|
1018
|
+
# 5. If running stdio MCP and parent died or a termination signal arrived while
|
|
1019
|
+
# `anyio.wrap_file(sys.stdin)` has a worker thread blocked in C-level `readline()`,
|
|
1020
|
+
# ensure the process exits cleanly after all cleanup above has completed.
|
|
1021
|
+
if self.force_exit_on_async_stop and (
|
|
1022
|
+
reason.startswith("parent_")
|
|
1023
|
+
or reason.startswith("stdin_")
|
|
1024
|
+
or reason.startswith("signal_")
|
|
1025
|
+
):
|
|
1026
|
+
def _post_cleanup_exit() -> None:
|
|
1027
|
+
time.sleep(0.10)
|
|
1028
|
+
os._exit(0)
|
|
1029
|
+
|
|
1030
|
+
exiter = threading.Thread(
|
|
1031
|
+
target=_post_cleanup_exit,
|
|
1032
|
+
name="codegraph-mcp-post-cleanup-exit",
|
|
1033
|
+
daemon=True,
|
|
1034
|
+
)
|
|
1035
|
+
exiter.start()
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def run_mcp_stdio_server(
|
|
1039
|
+
server: Any,
|
|
1040
|
+
repository: Path,
|
|
1041
|
+
*,
|
|
1042
|
+
profile: str = "full",
|
|
1043
|
+
process_dir: Path | None = None,
|
|
1044
|
+
poll_interval_sec: float = 0.25,
|
|
1045
|
+
) -> int:
|
|
1046
|
+
"""Run `server` in stdio MCP mode with deterministic lifecycle, signal, and parent-exit handling."""
|
|
1047
|
+
import anyio
|
|
1048
|
+
|
|
1049
|
+
controller = MCPLifecycleController(
|
|
1050
|
+
repository=repository,
|
|
1051
|
+
profile=profile,
|
|
1052
|
+
mode="mcp_stdio",
|
|
1053
|
+
process_dir=process_dir,
|
|
1054
|
+
poll_interval_sec=poll_interval_sec,
|
|
1055
|
+
force_exit_on_async_stop=True,
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
# Register cleanup for any attached indexer on the server
|
|
1059
|
+
indexer = getattr(server, "_codegraph_indexer", None)
|
|
1060
|
+
if indexer is not None:
|
|
1061
|
+
def _cleanup_indexer() -> None:
|
|
1062
|
+
gov = getattr(indexer, "governor", None)
|
|
1063
|
+
if gov is not None and hasattr(gov, "shutdown"):
|
|
1064
|
+
gov.shutdown()
|
|
1065
|
+
|
|
1066
|
+
controller.add_cleanup_callback(_cleanup_indexer)
|
|
1067
|
+
|
|
1068
|
+
prev_handlers: dict[int, Any] = {}
|
|
1069
|
+
handled_signals: list[int] = [signal.SIGINT, signal.SIGTERM]
|
|
1070
|
+
for opt_sig_name in ("SIGHUP", "SIGBREAK"):
|
|
1071
|
+
opt_sig = getattr(signal, opt_sig_name, None)
|
|
1072
|
+
if opt_sig is not None:
|
|
1073
|
+
handled_signals.append(opt_sig)
|
|
1074
|
+
|
|
1075
|
+
def _on_signal(signum: int, _frame: Any) -> None:
|
|
1076
|
+
controller.shutdown(f"signal_{signum}")
|
|
1077
|
+
|
|
1078
|
+
for sig in handled_signals:
|
|
1079
|
+
with contextlib.suppress(OSError, ValueError):
|
|
1080
|
+
prev_handlers[sig] = signal.getsignal(sig)
|
|
1081
|
+
signal.signal(sig, _on_signal)
|
|
1082
|
+
|
|
1083
|
+
controller.start()
|
|
1084
|
+
|
|
1085
|
+
async def _run_with_lifecycle() -> None:
|
|
1086
|
+
async with anyio.create_task_group() as tg:
|
|
1087
|
+
async def _monitor_stop() -> None:
|
|
1088
|
+
while not controller._stop_event.is_set():
|
|
1089
|
+
await anyio.sleep(min(controller.poll_interval_sec, 0.1))
|
|
1090
|
+
tg.cancel_scope.cancel()
|
|
1091
|
+
|
|
1092
|
+
async def _serve_stdio() -> None:
|
|
1093
|
+
try:
|
|
1094
|
+
await server.run_stdio_async()
|
|
1095
|
+
finally:
|
|
1096
|
+
controller.shutdown("stdio_eof")
|
|
1097
|
+
tg.cancel_scope.cancel()
|
|
1098
|
+
|
|
1099
|
+
tg.start_soon(_monitor_stop)
|
|
1100
|
+
tg.start_soon(_serve_stdio)
|
|
1101
|
+
|
|
1102
|
+
try:
|
|
1103
|
+
anyio.run(_run_with_lifecycle)
|
|
1104
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1105
|
+
controller.shutdown("interrupted")
|
|
1106
|
+
except BaseException:
|
|
1107
|
+
controller.shutdown("exception")
|
|
1108
|
+
raise
|
|
1109
|
+
finally:
|
|
1110
|
+
controller.shutdown("normal_exit")
|
|
1111
|
+
for sig, prev in prev_handlers.items():
|
|
1112
|
+
with contextlib.suppress(OSError, ValueError):
|
|
1113
|
+
signal.signal(sig, prev)
|
|
1114
|
+
|
|
1115
|
+
return 0
|