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.
@@ -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