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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- 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)
|