graphite-code 0.3.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.
Files changed (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,879 @@
1
+ """Bounded subprocess transport with sanitized defaults or exact child environments."""
2
+ from __future__ import annotations
3
+
4
+ import errno
5
+ import math
6
+ import os
7
+ import select
8
+ import signal
9
+ import subprocess
10
+ import sys
11
+ import threading
12
+ import time
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any, Callable, Protocol
17
+
18
+ OUTPUT_LIMIT_BYTES = 32 * 1024
19
+ INPUT_LIMIT_BYTES = 1024 * 1024
20
+ _CLEANUP_SECONDS = 1.0
21
+ _GRACEFUL_CLEANUP_SECONDS = 0.1
22
+ # Time left to the child to notice EOF and exit after a deferred stdin close is
23
+ # forced. Only reached when the caller's predicate never passes -- the healthy
24
+ # path closes as soon as the transcript is complete, long before this.
25
+ _STDIN_CLOSE_RESERVE_SECONDS = 2.0
26
+ _SAFE_ERROR_CODES = frozenset(
27
+ {
28
+ "timeout",
29
+ "output_limit",
30
+ "nonzero",
31
+ "launch_failed",
32
+ "io_failed",
33
+ "input_failed",
34
+ "cleanup_failed",
35
+ "invalid_timeout",
36
+ "invalid_environment",
37
+ "input_limit",
38
+ "cancelled",
39
+ }
40
+ )
41
+ _ESSENTIAL_ENV = (
42
+ "SYSTEMROOT",
43
+ "WINDIR",
44
+ "COMSPEC",
45
+ "PATHEXT",
46
+ "PATH",
47
+ "TEMP",
48
+ "TMP",
49
+ "HOME",
50
+ "USERPROFILE",
51
+ "LOCALAPPDATA",
52
+ "APPDATA",
53
+ )
54
+
55
+
56
+ def _observed_number(value: object) -> float | None:
57
+ """Coerce an observation to a finite, non-negative number, else ``None``.
58
+
59
+ Same discipline as ``os_error``'s coercion, and for the same reason: these
60
+ values are only ever read by diagnostics, so a malformed one must degrade to
61
+ "not observed" rather than propagate. ``bool`` is rejected explicitly -- it
62
+ is an ``int`` subclass, and ``True`` would otherwise be reported as ``1``.
63
+ """
64
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
65
+ return None
66
+ if not math.isfinite(value) or value < 0:
67
+ return None
68
+ return value
69
+
70
+
71
+ class ProbeProcessError(Exception):
72
+ """A classified transport failure containing no process or environment data.
73
+
74
+ ``os_error`` is the OS error number behind the classification, when there
75
+ was one. A number is safe to surface where a message is not: it carries no
76
+ path, no argv and no environment, whereas ``strerror`` routinely embeds a
77
+ filename.
78
+
79
+ It exists because the code alone cannot distinguish causes that need
80
+ opposite handling -- notably a genuine pipe fault from this module's own
81
+ `_cancel_synchronous_io` aborting a worker's in-flight I/O during cleanup
82
+ (graphite#41). Issue #29 lost several rounds to exactly this: an error
83
+ classified down to a label that discarded the fact which discriminated.
84
+
85
+ ``cleanup_failed`` says containment did not complete -- a process may have
86
+ been left running. It is a SEPARATE fact from ``code``, and carrying it
87
+ separately is the point: it used to be written over the code, so a run that
88
+ had already determined why it failed reported `cleanup_failed` instead
89
+ (graphite#46). Both facts are true at once and only the transport knows
90
+ both, so it reports both.
91
+ """
92
+
93
+ def __init__(
94
+ self,
95
+ code: str,
96
+ os_error: int | None = None,
97
+ *,
98
+ cleanup_failed: bool = False,
99
+ elapsed_seconds: float | None = None,
100
+ budget_seconds: float | None = None,
101
+ stdout_bytes: int | None = None,
102
+ stderr_bytes: int | None = None,
103
+ ) -> None:
104
+ self.code = code if code in _SAFE_ERROR_CODES else "unexpected"
105
+ # Coerced, never trusted: an OSError subclass can carry anything in
106
+ # `errno`, and only an int may reach the message.
107
+ self.os_error = os_error if isinstance(os_error, int) and not isinstance(os_error, bool) else None
108
+ self.cleanup_failed = bool(cleanup_failed)
109
+ # What the run had observed when it gave up (graphite#51).
110
+ #
111
+ # Numbers, for exactly the reason `os_error` is a number: a COUNT is
112
+ # safe to carry where the bytes it counts are not. None means the
113
+ # failure happened before there was anything to observe -- an invalid
114
+ # timeout, a launch that never happened -- and must stay
115
+ # distinguishable from a measured zero.
116
+ #
117
+ # `elapsed_seconds` against `budget_seconds` is the discriminating pair,
118
+ # and the whole point of the field:
119
+ #
120
+ # elapsed ~= budget -> the deadline fired on time and the child did
121
+ # not answer within it;
122
+ # elapsed >> budget -> OUR OWN deadline was late, i.e. this process
123
+ # was starved of CPU.
124
+ #
125
+ # That second case is the load hypothesis, and until now it was
126
+ # indistinguishable in the log from a merely slow child. `stdout_bytes`
127
+ # splits the first case further: zero means the child never produced a
128
+ # byte, non-zero means it was alive and progressing.
129
+ self.elapsed_seconds = _observed_number(elapsed_seconds)
130
+ self.budget_seconds = _observed_number(budget_seconds)
131
+ self.stdout_bytes = _observed_number(stdout_bytes)
132
+ self.stderr_bytes = _observed_number(stderr_bytes)
133
+ message = "probe input failed" if self.code == "input_failed" else f"probe process failed: {self.code}"
134
+ if self.os_error is not None:
135
+ message = f"{message} (os={self.os_error})"
136
+ # Only when it is not already the code, so the common case does not read
137
+ # "cleanup_failed (cleanup also failed)". A bare boolean is safe to
138
+ # surface where a message is not -- it names no path, pid or argv.
139
+ if self.cleanup_failed and self.code != "cleanup_failed":
140
+ message = f"{message} (cleanup also failed)"
141
+ super().__init__(message)
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class ProbeProcessResult:
146
+ returncode: int
147
+ stdout: bytes
148
+ stderr: bytes
149
+ duration_seconds: float
150
+ # Input-side evidence. A child may legitimately stop reading before we
151
+ # finish writing (see write_input), which is tolerated -- but that leaves a
152
+ # short stream indistinguishable, from the outside, from a child that read
153
+ # everything and chose to answer less. These two fields keep that
154
+ # distinction observable so a failing probe can say which happened.
155
+ # Defaulted so the many positional constructions in tests stay valid, and
156
+ # so a hand-built result reads as "input delivered in full".
157
+ input_bytes: int = 0
158
+ input_complete: bool = True
159
+ # Seconds between closing the child's stdin and the child exiting.
160
+ #
161
+ # `write_input` closes stdin the moment the payload is written, so a server
162
+ # that reads EOF as end-of-session can tear down while messages it already
163
+ # received are still unprocessed -- answering `initialize`, never answering
164
+ # `tools/list`, and exiting 0 (issue #29). A near-zero interval says the
165
+ # close and the exit are the same event; a larger one says the child died
166
+ # of something else. Without it the two are indistinguishable from outside.
167
+ #
168
+ # -1.0 when there was no stdin to close, so "not measured" cannot be
169
+ # mistaken for "died instantly".
170
+ stdin_close_to_exit_seconds: float = -1.0
171
+
172
+
173
+ def sanitized_probe_environment(source: Mapping[str, str] | None = None) -> dict[str, str]:
174
+ """Return OS essentials plus the exact Graphite package path, excluding ambient secrets."""
175
+ ambient = os.environ if source is None else source
176
+ env = {name: ambient[name] for name in _ESSENTIAL_ENV if name in ambient}
177
+ env["PYTHONPATH"] = str(Path(__file__).resolve().parent.parent)
178
+ env["PYTHONIOENCODING"] = "utf-8"
179
+ env["PYTHONUTF8"] = "1"
180
+ return env
181
+
182
+
183
+ def _validated_environment(environment: Mapping[str, str] | None) -> dict[str, str]:
184
+ validated = sanitized_probe_environment() if environment is None else dict(environment)
185
+ if any(
186
+ not isinstance(key, str)
187
+ or not isinstance(value, str)
188
+ or not key
189
+ or "=" in key
190
+ or "\0" in key
191
+ or "\0" in value
192
+ for key, value in validated.items()
193
+ ):
194
+ raise ProbeProcessError("invalid_environment")
195
+ return validated
196
+
197
+
198
+ class _Process(Protocol):
199
+ pid: int
200
+ stdin: Any
201
+ stdout: Any
202
+ stderr: Any
203
+ returncode: int | None
204
+ def wait(self, timeout: float) -> int: ...
205
+ def kill(self) -> bool | None: ...
206
+
207
+
208
+ class _PosixProcess:
209
+ """Observe leader exit without reaping it until group containment completes."""
210
+
211
+ def __init__(self, process: subprocess.Popen[bytes]) -> None:
212
+ self._process = process
213
+ self.pid = process.pid
214
+ self.stdin = process.stdin
215
+ self.stdout = process.stdout
216
+ self.stderr = process.stderr
217
+ self.returncode: int | None = None
218
+ self._reaped = False
219
+ self._kqueue: Any | None = None
220
+ self._kqueue_exit_flags = 0
221
+ if not callable(getattr(os, "waitid", None)):
222
+ if sys.platform != "darwin" or not hasattr(select, "kqueue"):
223
+ raise RuntimeError("non-reaping process observation unavailable")
224
+ self._kqueue = select.kqueue()
225
+ note_exit_status = getattr(select, "KQ_NOTE_EXITSTATUS", 0x04000000)
226
+ self._kqueue_exit_flags = select.KQ_NOTE_EXIT | note_exit_status
227
+ event = select.kevent(
228
+ self.pid,
229
+ filter=select.KQ_FILTER_PROC,
230
+ flags=select.KQ_EV_ADD | select.KQ_EV_ENABLE | select.KQ_EV_ONESHOT,
231
+ fflags=self._kqueue_exit_flags,
232
+ )
233
+ self._kqueue.control([event], 0, 0)
234
+
235
+ def _observe(self) -> int | None:
236
+ if self.returncode is not None:
237
+ return self.returncode
238
+ if self._kqueue is not None:
239
+ events = self._kqueue.control(None, 1, 0)
240
+ if not events:
241
+ return None
242
+ if events[0].fflags & self._kqueue_exit_flags != self._kqueue_exit_flags:
243
+ raise OSError("kqueue exit status unavailable")
244
+ status = events[0].data
245
+ signal_number = status & 0x7F
246
+ self.returncode = -signal_number if signal_number else (status >> 8) & 0xFF
247
+ else:
248
+ result = os.waitid(os.P_PID, self.pid, os.WEXITED | os.WNOHANG | os.WNOWAIT)
249
+ if result is None:
250
+ return None
251
+ self.returncode = result.si_status if result.si_code == os.CLD_EXITED else -result.si_status
252
+ return self.returncode
253
+
254
+ def poll(self) -> int | None:
255
+ return self._observe()
256
+
257
+ def wait(self, timeout: float) -> int:
258
+ deadline = time.monotonic() + timeout
259
+ while True:
260
+ result = self._observe()
261
+ if result is not None:
262
+ return result
263
+ remaining = deadline - time.monotonic()
264
+ if remaining <= 0:
265
+ raise subprocess.TimeoutExpired([], timeout)
266
+ time.sleep(min(0.01, remaining))
267
+
268
+ def kill(self) -> bool:
269
+ try:
270
+ os.kill(self.pid, signal.SIGKILL)
271
+ return True
272
+ except ProcessLookupError:
273
+ return True
274
+ except OSError:
275
+ return False
276
+
277
+ def reap(self, deadline: float) -> bool:
278
+ if self._reaped:
279
+ return True
280
+ while True:
281
+ try:
282
+ waited_pid, status = os.waitpid(self.pid, os.WNOHANG)
283
+ except ChildProcessError:
284
+ return False
285
+ if waited_pid == self.pid:
286
+ self._reaped = True
287
+ self._process.returncode = os.waitstatus_to_exitcode(status)
288
+ self.returncode = self._process.returncode
289
+ if self._kqueue is not None:
290
+ self._kqueue.close()
291
+ self._kqueue = None
292
+ return True
293
+ remaining = deadline - time.monotonic()
294
+ if remaining <= 0:
295
+ return False
296
+ time.sleep(min(0.01, remaining))
297
+
298
+
299
+ def _launch_process(
300
+ argv: list[str],
301
+ *,
302
+ cwd: Path,
303
+ input_data: bytes | None,
304
+ environment: Mapping[str, str],
305
+ ) -> _Process:
306
+ if os.name == "nt":
307
+ from .windows_job import launch
308
+
309
+ return launch(argv, cwd=cwd, environment=environment, with_stdin=input_data is not None)
310
+ if not callable(getattr(os, "waitid", None)) and (sys.platform != "darwin" or not hasattr(select, "kqueue")):
311
+ raise RuntimeError("non-reaping process observation unavailable")
312
+ process = subprocess.Popen(
313
+ argv,
314
+ cwd=cwd,
315
+ env=environment,
316
+ shell=False,
317
+ stdin=subprocess.PIPE if input_data is not None else subprocess.DEVNULL,
318
+ stdout=subprocess.PIPE,
319
+ stderr=subprocess.PIPE,
320
+ start_new_session=True,
321
+ )
322
+ try:
323
+ return _PosixProcess(process)
324
+ except Exception:
325
+ process.kill()
326
+ try:
327
+ process.wait(timeout=0.5)
328
+ except (OSError, subprocess.TimeoutExpired):
329
+ pass
330
+ for pipe in (process.stdin, process.stdout, process.stderr):
331
+ if pipe is not None:
332
+ pipe.close()
333
+ raise
334
+
335
+
336
+ def run_bounded_process(
337
+ argv: list[str],
338
+ *,
339
+ cwd: Path,
340
+ stdin: bytes | str | None = None,
341
+ timeout_seconds: float,
342
+ max_output_bytes: int = OUTPUT_LIMIT_BYTES,
343
+ max_input_bytes: int = INPUT_LIMIT_BYTES,
344
+ check: bool = True,
345
+ environment: Mapping[str, str] | None = None,
346
+ cancelled: Callable[[], bool] | None = None,
347
+ stdin_close_when: Callable[[bytes], bool] | None = None,
348
+ ) -> ProbeProcessResult:
349
+ """Run one isolated process tree under a single hard transport deadline.
350
+
351
+ ``environment=None`` selects the sanitized probe default. An explicit mapping is the
352
+ complete, non-inheriting child environment, including when the mapping is empty.
353
+
354
+ ``stdin_close_when`` defers closing the child's stdin until the predicate
355
+ accepts the stdout captured so far. Default ``None`` keeps the original
356
+ behaviour -- close as soon as the payload is written -- because for a child
357
+ that reads to EOF before doing anything, deferring would waste the whole
358
+ budget.
359
+
360
+ It exists because an immediate close makes EOF arrive while the child is
361
+ still working, and a request/response server reads EOF as end-of-session:
362
+ it tears down the write side with a reply still in flight, answering the
363
+ first request and silently dropping the second (graphite#29). The close is
364
+ still bounded by the run's own deadline, so a predicate that never passes
365
+ degrades to the old timing rather than hanging.
366
+ """
367
+ if (
368
+ not math.isfinite(timeout_seconds)
369
+ or timeout_seconds <= 0
370
+ or isinstance(max_output_bytes, bool)
371
+ or not isinstance(max_output_bytes, int)
372
+ or max_output_bytes <= 0
373
+ or isinstance(max_input_bytes, bool)
374
+ or not isinstance(max_input_bytes, int)
375
+ or max_input_bytes <= 0
376
+ or max_input_bytes > 4 * 1024 * 1024
377
+ ):
378
+ raise ProbeProcessError("invalid_timeout")
379
+ if isinstance(stdin, str):
380
+ try:
381
+ input_data = stdin.encode("utf-8")
382
+ except UnicodeEncodeError:
383
+ raise ProbeProcessError("input_limit") from None
384
+ else:
385
+ input_data = stdin
386
+ if input_data is not None and len(input_data) > max_input_bytes:
387
+ raise ProbeProcessError("input_limit")
388
+
389
+ cancellation = cancelled or (lambda: False)
390
+ try:
391
+ if cancellation():
392
+ raise ProbeProcessError("cancelled")
393
+ except ProbeProcessError:
394
+ raise
395
+ except Exception:
396
+ raise ProbeProcessError("io_failed") from None
397
+
398
+ started = time.monotonic()
399
+ deadline = started + timeout_seconds
400
+ try:
401
+ validated_environment = _validated_environment(environment)
402
+ except ProbeProcessError:
403
+ raise
404
+ except Exception:
405
+ raise ProbeProcessError("launch_failed") from None
406
+ try:
407
+ process = _launch_process(argv, cwd=cwd, input_data=input_data, environment=validated_environment)
408
+ except Exception:
409
+ raise ProbeProcessError("launch_failed") from None
410
+
411
+ outputs = {"stdout": bytearray(), "stderr": bytearray()}
412
+ overflow: threading.Event | None = None
413
+ io_failed: threading.Event | None = None
414
+ writer_failed: threading.Event | None = None
415
+ input_truncated: threading.Event | None = None
416
+ cleanup_started: threading.Event | None = None
417
+ workers: list[threading.Thread] = []
418
+ worker_pipes: list[tuple[threading.Thread, Any]] = []
419
+ failure_code: str | None = None
420
+ cleanup_ok = True
421
+
422
+ def read_pipe(name: str, pipe: Any) -> None:
423
+ try:
424
+ while True:
425
+ chunk = pipe.read(4096)
426
+ if not chunk:
427
+ break
428
+ remaining = max_output_bytes + 1 - len(outputs[name])
429
+ if remaining > 0:
430
+ outputs[name].extend(chunk[:remaining])
431
+ if len(outputs[name]) > max_output_bytes:
432
+ overflow.set()
433
+ break
434
+ except (OSError, ValueError):
435
+ if cleanup_started is not None and not cleanup_started.is_set() and io_failed is not None:
436
+ io_failed.set()
437
+
438
+ # One-slot mailboxes: the writer runs on its own thread, so the timestamp
439
+ # has to cross back without another lock.
440
+ stdin_closed_at: list[float | None] = [None]
441
+ exited_at: list[float | None] = [None]
442
+
443
+ # The close can now come from either the writer thread (immediate) or the
444
+ # polling loop (deferred), so it needs to be idempotent and serialized --
445
+ # two closes would race on the timestamp and on the handle.
446
+ stdin_close_lock = threading.Lock()
447
+ stdin_is_closed = [False]
448
+ write_completed = threading.Event()
449
+ # The OS error number behind an `input_failed`, carried back from the writer
450
+ # thread so the classification does not discard it (graphite#41).
451
+ writer_error: list[int | None] = [None]
452
+
453
+ def close_stdin() -> None:
454
+ with stdin_close_lock:
455
+ if stdin_is_closed[0]:
456
+ return
457
+ stdin_is_closed[0] = True
458
+ try:
459
+ if process.stdin is not None:
460
+ process.stdin.close()
461
+ except (OSError, ValueError):
462
+ pass
463
+ # Stamped after the close returns, so the interval measures the
464
+ # child's life beyond EOF rather than our own write time.
465
+ stdin_closed_at[0] = time.monotonic()
466
+
467
+ def write_input() -> None:
468
+ if input_data is None or process.stdin is None:
469
+ return
470
+ defer = stdin_close_when is not None
471
+ try:
472
+ process.stdin.write(input_data)
473
+ process.stdin.flush()
474
+ except BrokenPipeError:
475
+ # The child stopped reading -- it exited, or closed stdin -- before
476
+ # we finished writing. That is legitimate rather than a transport
477
+ # fault: `_MCP_BOOTSTRAP` rejects invalid bindings with
478
+ # SystemExit(70) *before* its first stdin read, so a correctly
479
+ # refusing bootstrap never reads the input we are still sending.
480
+ # The child's exit status is the verdict; bytes it declined to read
481
+ # are not evidence of anything. Reporting this as `input_failed`
482
+ # discarded the real return code whenever the write lost that race,
483
+ # which is the intermittent `probe input failed` in issue #29.
484
+ # BrokenPipeError subclasses OSError, so it must be caught first.
485
+ # Tolerating it silently would erase the only signal that the child
486
+ # saw a short stream, so record it as evidence instead.
487
+ defer = False
488
+ if input_truncated is not None:
489
+ input_truncated.set()
490
+ except (OSError, ValueError) as exc:
491
+ defer = False
492
+ # winerror first: on Windows an aborted synchronous I/O reports
493
+ # there, and `errno` is the coarser translation of it.
494
+ writer_error[0] = getattr(exc, "winerror", None) or getattr(exc, "errno", None)
495
+ if writer_error[0] == errno.EINVAL:
496
+ # Windows' other spelling of "the reader is gone". Measured:
497
+ # 3 of 10 CI runs failed as `input_failed (os=22)` across two
498
+ # different tests, while the same write raises BrokenPipeError
499
+ # 40/40 locally -- which is why it never reproduced off CI
500
+ # (graphite#41). Same condition as the branch above, so it gets
501
+ # the same verdict: legitimate, recorded, not a fault.
502
+ #
503
+ # Widened by exactly one number on purpose. An OSError carrying
504
+ # no errno stays a genuine transport fault, which
505
+ # test_probe_transport_rechecks_late_writer_failure pins.
506
+ if input_truncated is not None:
507
+ input_truncated.set()
508
+ elif writer_failed is not None:
509
+ writer_failed.set()
510
+ finally:
511
+ # Only a fully delivered payload is worth waiting on. If the write
512
+ # broke or failed there is nothing more to send, so hold nothing
513
+ # open -- deferring past a failed write would just delay the
514
+ # child's EOF for a response that is never coming.
515
+ if defer:
516
+ write_completed.set()
517
+ else:
518
+ close_stdin()
519
+
520
+ try:
521
+ overflow = threading.Event()
522
+ io_failed = threading.Event()
523
+ writer_failed = threading.Event()
524
+ input_truncated = threading.Event()
525
+ cleanup_started = threading.Event()
526
+ for name, pipe in (("stdout", process.stdout), ("stderr", process.stderr)):
527
+ thread = threading.Thread(target=read_pipe, args=(name, pipe), daemon=True)
528
+ thread.start()
529
+ workers.append(thread)
530
+ worker_pipes.append((thread, pipe))
531
+ writer = threading.Thread(target=write_input, daemon=True)
532
+ writer.start()
533
+ workers.append(writer)
534
+ worker_pipes.append((writer, process.stdin))
535
+ cleanup_reserve = min(_CLEANUP_SECONDS, max(0.05, timeout_seconds * 0.4))
536
+ execution_deadline = deadline - cleanup_reserve
537
+ # Latest moment a deferred close may still leave the child room to see
538
+ # EOF and exit inside the execution window.
539
+ stdin_close_deadline = execution_deadline - min(
540
+ _STDIN_CLOSE_RESERVE_SECONDS, max(0.05, timeout_seconds * 0.25)
541
+ )
542
+ while failure_code is None:
543
+ if stdin_close_when is not None and write_completed.is_set() and not stdin_is_closed[0]:
544
+ if time.monotonic() >= stdin_close_deadline:
545
+ close_stdin()
546
+ else:
547
+ try:
548
+ # A torn read can only ever yield a prefix of what the
549
+ # reader has appended, which reads as "not complete
550
+ # yet" and is retried on the next tick -- so no lock is
551
+ # needed against the reader thread here.
552
+ if stdin_close_when(bytes(outputs["stdout"])):
553
+ close_stdin()
554
+ except Exception:
555
+ # A predicate that raises must not strand the child on
556
+ # an open pipe; fall back to the old timing.
557
+ close_stdin()
558
+ try:
559
+ if cancellation():
560
+ failure_code = "cancelled"
561
+ break
562
+ except Exception:
563
+ failure_code = "io_failed"
564
+ break
565
+ if overflow.is_set():
566
+ failure_code = "output_limit"
567
+ break
568
+ if writer_failed.is_set():
569
+ failure_code = "input_failed"
570
+ break
571
+ remaining = execution_deadline - time.monotonic()
572
+ if remaining <= 0:
573
+ failure_code = "timeout"
574
+ break
575
+ try:
576
+ process.wait(timeout=min(0.05, remaining))
577
+ exited_at[0] = time.monotonic()
578
+ break
579
+ except subprocess.TimeoutExpired:
580
+ continue
581
+ except (OSError, ValueError):
582
+ failure_code = "io_failed"
583
+ break
584
+ except Exception:
585
+ failure_code = "io_failed"
586
+ finally:
587
+ # Never leave a deferred close outstanding: a child blocked reading an
588
+ # open pipe would otherwise have to be killed on a path where letting it
589
+ # see EOF and exit on its own is cleaner and faster.
590
+ #
591
+ # Strictly gated on the writer having finished. Closing the handle while
592
+ # that thread is still inside `write` blocks until the write drains --
593
+ # which against a child that never reads means waiting out the full
594
+ # payload, turning a 0.25s timeout into seconds. Non-deferred runs are
595
+ # untouched; their close already happened on the writer thread, and
596
+ # `_cleanup_process_transport` handles a writer still stuck in one.
597
+ if stdin_close_when is not None and write_completed.is_set():
598
+ close_stdin()
599
+ if cleanup_started is not None:
600
+ cleanup_started.set()
601
+ # Recorded, NOT assigned over `failure_code`. Every recheck below is
602
+ # guarded by `failure_code is None`; this one used to be the exception,
603
+ # so a run that already knew it had timed out came back as
604
+ # `cleanup_failed` (graphite#46). The precedence is now explicit and
605
+ # pinned by test: transport failure, then the child's own exit status,
606
+ # and `cleanup_failed` only when there is nothing else to report.
607
+ cleanup_ok = _cleanup_process_transport(process, workers, worker_pipes, deadline)
608
+
609
+ # These events can be set after the direct child exits, so recheck only
610
+ # after cleanup and every bounded join has completed.
611
+ if failure_code is None and writer_failed is not None and writer_failed.is_set():
612
+ failure_code = "input_failed"
613
+ elif failure_code is None and overflow is not None and overflow.is_set():
614
+ failure_code = "output_limit"
615
+ elif failure_code is None and io_failed is not None and io_failed.is_set():
616
+ failure_code = "io_failed"
617
+ elif failure_code is None and any(thread.is_alive() for thread in workers):
618
+ failure_code = "timeout"
619
+ # Observations every failure below carries. A raise from here on has run the
620
+ # child, so there is always something to report -- and reporting nothing is
621
+ # exactly the defect being fixed (graphite#51): the deep-probe diagnostic
622
+ # read its fields off a result that a transport failure never produces, and
623
+ # printed `<none>` for all of them on the one failure it existed to explain.
624
+ # Any new raise added below MUST pass these four.
625
+ elapsed = time.monotonic() - started
626
+ stdout_seen = len(outputs["stdout"])
627
+ stderr_seen = len(outputs["stderr"])
628
+ if failure_code is not None:
629
+ raise ProbeProcessError(
630
+ failure_code,
631
+ writer_error[0] if failure_code == "input_failed" else None,
632
+ cleanup_failed=not cleanup_ok,
633
+ elapsed_seconds=elapsed,
634
+ budget_seconds=timeout_seconds,
635
+ stdout_bytes=stdout_seen,
636
+ stderr_bytes=stderr_seen,
637
+ )
638
+ if process.returncode is None or (check and process.returncode != 0):
639
+ raise ProbeProcessError(
640
+ "nonzero",
641
+ cleanup_failed=not cleanup_ok,
642
+ elapsed_seconds=elapsed,
643
+ budget_seconds=timeout_seconds,
644
+ stdout_bytes=stdout_seen,
645
+ stderr_bytes=stderr_seen,
646
+ )
647
+ # Last, so it never speaks over a diagnosis -- but still raised rather than
648
+ # returned, because a run that may have leaked a process is not a success.
649
+ if not cleanup_ok:
650
+ raise ProbeProcessError(
651
+ "cleanup_failed",
652
+ cleanup_failed=True,
653
+ elapsed_seconds=elapsed,
654
+ budget_seconds=timeout_seconds,
655
+ stdout_bytes=stdout_seen,
656
+ stderr_bytes=stderr_seen,
657
+ )
658
+ closed_at = stdin_closed_at[0]
659
+ finished_at = exited_at[0]
660
+ # Both stamps are required: a child with no stdin, or one reaped on a path
661
+ # that never observed the exit, has no interval to report and must say so
662
+ # rather than claim an instant death.
663
+ # A close that lands AFTER the exit is also "not measured": with a deferred
664
+ # close the child can exit on its own while stdin is still open, and it then
665
+ # never saw the EOF at all. Reporting 0.0 there would read as "died the
666
+ # instant its stdin closed" -- the exact conclusion this field exists to
667
+ # support or refute.
668
+ outlived_close = (
669
+ max(0.0, finished_at - closed_at)
670
+ if closed_at is not None and finished_at is not None and closed_at <= finished_at
671
+ else -1.0
672
+ )
673
+ return ProbeProcessResult(
674
+ process.returncode,
675
+ bytes(outputs["stdout"]),
676
+ bytes(outputs["stderr"]),
677
+ time.monotonic() - started,
678
+ len(input_data) if input_data is not None else 0,
679
+ input_truncated is None or not input_truncated.is_set(),
680
+ outlived_close,
681
+ )
682
+
683
+
684
+ def _cleanup_process_transport(
685
+ process: _Process,
686
+ workers: list[threading.Thread],
687
+ worker_pipes: list[tuple[threading.Thread, Any]],
688
+ deadline: float,
689
+ ) -> bool:
690
+ cleanup_ok = _terminate_process_tree(process, deadline) is not False
691
+ try:
692
+ process.wait(timeout=max(0.0, deadline - time.monotonic()))
693
+ except (subprocess.TimeoutExpired, OSError, ValueError):
694
+ try:
695
+ if process.kill() is False:
696
+ cleanup_ok = False
697
+ except (OSError, ValueError):
698
+ cleanup_ok = False
699
+ for thread in workers:
700
+ if thread.is_alive():
701
+ _cancel_synchronous_io(thread)
702
+ thread.join(min(0.2, max(0.0, deadline - time.monotonic())))
703
+ if thread.is_alive():
704
+ _cancel_synchronous_io(thread)
705
+ thread.join(min(0.2, max(0.0, deadline - time.monotonic())))
706
+ active_pipes = {id(pipe) for thread, pipe in worker_pipes if pipe is not None and thread.is_alive()}
707
+ for pipe in (process.stdin, process.stdout, process.stderr):
708
+ if pipe is not None and id(pipe) not in active_pipes:
709
+ try:
710
+ pipe.close()
711
+ except (OSError, ValueError):
712
+ cleanup_ok = False
713
+ close_handles = getattr(process, "close_handles", None)
714
+ if close_handles is not None:
715
+ try:
716
+ if close_handles() is False:
717
+ cleanup_ok = False
718
+ except (OSError, ValueError):
719
+ cleanup_ok = False
720
+ reap = getattr(process, "reap", None)
721
+ if reap is not None:
722
+ try:
723
+ if reap(deadline) is False:
724
+ cleanup_ok = False
725
+ except (OSError, ValueError):
726
+ cleanup_ok = False
727
+ return cleanup_ok
728
+
729
+
730
+ def _cancel_synchronous_io(thread: threading.Thread) -> None:
731
+ if os.name != "nt" or thread.native_id is None:
732
+ return
733
+ try:
734
+ import ctypes
735
+
736
+ from ctypes import wintypes
737
+
738
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
739
+ kernel32.OpenThread.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
740
+ kernel32.OpenThread.restype = wintypes.HANDLE
741
+ kernel32.CancelSynchronousIo.argtypes = (wintypes.HANDLE,)
742
+ kernel32.CancelSynchronousIo.restype = wintypes.BOOL
743
+ kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
744
+ kernel32.CloseHandle.restype = wintypes.BOOL
745
+ handle = kernel32.OpenThread(0x0001, False, thread.native_id)
746
+ if handle:
747
+ try:
748
+ kernel32.CancelSynchronousIo(handle)
749
+ finally:
750
+ kernel32.CloseHandle(handle)
751
+ except (AttributeError, OSError, ValueError):
752
+ pass
753
+
754
+
755
+ def _gracefully_signal_process_tree(process: _Process) -> bool:
756
+ try:
757
+ if os.name == "nt":
758
+ if getattr(process, "poll", lambda: process.returncode)() is not None:
759
+ return False
760
+ process.send_signal(signal.CTRL_BREAK_EVENT)
761
+ else:
762
+ os.killpg(process.pid, signal.SIGTERM)
763
+ return True
764
+ except (AttributeError, OSError, ValueError):
765
+ return False
766
+
767
+
768
+ def _darwin_group_holds_only_the_zombie_leader(process: _Process, error: OSError) -> bool:
769
+ """Say whether darwin's EPERM means "nothing left to signal" rather than "not allowed".
770
+
771
+ MEASURED on macos-latest 3.12.10 against ubuntu-latest as the control, four
772
+ process states, with the errno printed for each:
773
+
774
+ leader alive killpg -> OK (both platforms)
775
+ leader exited, unreaped zombie killpg -> EPERM darwin
776
+ killpg -> SUCCESS Linux
777
+ leader exited + live DESCENDANT killpg -> OK (both platforms)
778
+ after reap killpg -> ESRCH (both platforms)
779
+
780
+ This module deliberately holds the exited leader as an unreaped zombie so
781
+ its pgid cannot be recycled under the signals that follow. On darwin that
782
+ makes the group unsignalable, so EVERY successful probe reported a failed
783
+ containment -- 46 of the 62 failures in graphite#46.
784
+
785
+ EPERM is genuinely ambiguous on darwin: "forbidden" and "nothing signalable"
786
+ share it. Reading it as the latter is licensed by the third row -- a live
787
+ descendant makes darwin answer OK -- and by the fact that this transport
788
+ CREATES the group itself via `setsid()`, from its own uid and a sanitized
789
+ environment, so a member it may not signal is not reachable. State that
790
+ reasoning wherever this is touched; without it the check is error-swallowing.
791
+
792
+ Gated on the leader having exited, which is not decoration: on the timeout
793
+ path `returncode` is None and the group holds a LIVE leader, where EPERM
794
+ cannot mean "only zombies" and stays a failure. Verified at the call site
795
+ rather than inferred -- 0 on the success path, None on the timeout path.
796
+ """
797
+ if sys.platform != "darwin" or not isinstance(error, PermissionError):
798
+ return False
799
+ if process.returncode is None:
800
+ # `returncode` is only set where the run HAPPENED to observe the exit.
801
+ # The success and timeout paths reach `process.wait` and so carry a
802
+ # current value, but `output_limit` and `cancelled` break out of the
803
+ # loop before it -- measured: a child that overflows the limit and exits
804
+ # immediately still reads None here. Trusting the cache would call a
805
+ # contained tree a leak, intermittently, depending on whether the child
806
+ # won the race to exit. `poll` is the same non-reaping observation the
807
+ # run itself uses, and a leader that really is alive still answers None.
808
+ poll = getattr(process, "poll", None)
809
+ if callable(poll):
810
+ try:
811
+ poll()
812
+ except (OSError, ValueError):
813
+ return False
814
+ return process.returncode is not None
815
+
816
+
817
+ def _force_kill_process_tree(process: _Process, deadline: float) -> bool:
818
+ cleanup_ok = True
819
+ if os.name != "nt":
820
+ try:
821
+ os.killpg(process.pid, signal.SIGKILL)
822
+ except ProcessLookupError:
823
+ return True
824
+ except PermissionError as exc:
825
+ if _darwin_group_holds_only_the_zombie_leader(process, exc):
826
+ return True
827
+ cleanup_ok = False
828
+ except (OSError, ValueError):
829
+ cleanup_ok = False
830
+ elif hasattr(process, "terminate_tree"):
831
+ try:
832
+ if process.terminate_tree() is False:
833
+ cleanup_ok = False
834
+ except (OSError, ValueError):
835
+ cleanup_ok = False
836
+ if process.returncode is None:
837
+ try:
838
+ if process.kill() is False:
839
+ cleanup_ok = False
840
+ except (OSError, ValueError):
841
+ cleanup_ok = False
842
+ return cleanup_ok
843
+
844
+
845
+ def _posix_process_group_exists(process_group_id: int) -> bool:
846
+ try:
847
+ os.killpg(process_group_id, 0)
848
+ return True
849
+ except ProcessLookupError:
850
+ return False
851
+ except PermissionError:
852
+ return True
853
+ except OSError:
854
+ # Unknown probe failures must fail safe: assume the group still exists.
855
+ return True
856
+
857
+
858
+ def _terminate_process_tree(process: _Process, deadline: float) -> bool:
859
+ """Gracefully signal the process group, then force-kill the tree within the deadline."""
860
+ signaled = _gracefully_signal_process_tree(process)
861
+ if signaled and os.name != "nt":
862
+ grace_deadline = min(deadline, time.monotonic() + _GRACEFUL_CLEANUP_SECONDS)
863
+ group_exists = _posix_process_group_exists(process.pid)
864
+ while group_exists:
865
+ remaining = grace_deadline - time.monotonic()
866
+ if remaining <= 0:
867
+ break
868
+ time.sleep(min(0.01, remaining))
869
+ group_exists = _posix_process_group_exists(process.pid)
870
+ if not group_exists:
871
+ return True
872
+ elif signaled:
873
+ remaining = max(0.0, deadline - time.monotonic())
874
+ if remaining > 0:
875
+ try:
876
+ process.wait(timeout=min(_GRACEFUL_CLEANUP_SECONDS, remaining))
877
+ except (subprocess.TimeoutExpired, OSError, ValueError):
878
+ pass
879
+ return _force_kill_process_tree(process, deadline)