loki-mode 7.129.4 → 8.0.0
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.
- package/README.md +81 -5
- package/SKILL.md +66 -5
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +37 -0
- package/autonomy/completion-council.sh +862 -28
- package/autonomy/council-v2.sh +39 -0
- package/autonomy/crash.sh +3 -2
- package/autonomy/grill.sh +42 -0
- package/autonomy/hooks/validate-bash.sh +301 -4
- package/autonomy/lib/cockpit-render.sh +8 -2
- package/autonomy/lib/cr-rematerialize.py +101 -4
- package/autonomy/lib/deadline.py +957 -0
- package/autonomy/lib/dependency-setup.sh +205 -0
- package/autonomy/lib/done-recognition.sh +45 -0
- package/autonomy/lib/efficiency_cost.py +13 -6
- package/autonomy/lib/no_mock_scan.py +793 -0
- package/autonomy/lib/prd-enrich.sh +42 -0
- package/autonomy/lib/proof-analytics-props.py +69 -0
- package/autonomy/lib/proof-generator.py +282 -95
- package/autonomy/lib/proof-template.html +15 -12
- package/autonomy/lib/proof-verify.py +151 -102
- package/autonomy/lib/requirements_contract.py +848 -0
- package/autonomy/lib/sdk-mode.sh +103 -0
- package/autonomy/lib/secret-scan.sh +139 -0
- package/autonomy/lib/spec-expand.sh +208 -0
- package/autonomy/lib/tree_digest.py +266 -0
- package/autonomy/lib/voter-agents.sh +58 -8
- package/autonomy/lib/workspace_diff.py +124 -0
- package/autonomy/loki +189 -17
- package/autonomy/playwright-verify.sh +501 -0
- package/autonomy/prd-checklist.sh +17 -8
- package/autonomy/provider-offer.sh +111 -0
- package/autonomy/run.sh +4424 -678
- package/autonomy/sandbox.sh +42 -0
- package/autonomy/spec-interrogation.sh +148 -5
- package/autonomy/spec.sh +118 -1
- package/autonomy/telemetry.sh +133 -7
- package/autonomy/verify.sh +107 -0
- package/bin/loki +110 -3
- package/completions/_loki +10 -0
- package/completions/loki.bash +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +96 -7
- package/dashboard/build_supervisor.py +1619 -0
- package/dashboard/control.py +8 -0
- package/dashboard/prompt_optimizer.py +7 -0
- package/dashboard/server.py +577 -22
- package/dashboard/static/trust.html +1 -1
- package/docs/ARCHITECTURE-OVERVIEW.md +207 -0
- package/docs/AUTONOMI-ECOSYSTEM.md +107 -0
- package/docs/INSTALLATION.md +19 -2
- package/docs/MODEL-EQUIVALENCE-HARNESS-PLAN.md +70 -0
- package/docs/PLANS-INDEX.md +33 -0
- package/docs/PRIVACY.md +29 -1
- package/docs/SIGNED-RECEIPTS.md +102 -0
- package/docs/SONNET5-DEFAULT-PLAN.md +1 -1
- package/docs/V8-ACCEPTANCE-TRIAGE-2026-07-24.md +114 -0
- package/docs/V8-AGENT-SDK-PLAN.md +692 -0
- package/docs/V8-COMPLEXITY-AUDIT-2026-07-24.md +45 -0
- package/docs/V8-MAJOR-RELEASE-PLAN.md +137 -0
- package/docs/V8-OVERNIGHT-PLAN-2026-07-25.md +82 -0
- package/docs/V8-RUNTIME-TRUTH-2026-07-25.md +232 -0
- package/docs/V8-SDK-RESEARCH-RAW.md +129 -0
- package/docs/alternative-installations.md +4 -4
- package/loki-ts/dist/loki.js +674 -285
- package/loki-ts/package.json +2 -0
- package/mcp/__init__.py +1 -1
- package/package.json +7 -6
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/claude.sh +75 -0
- package/providers/codex.sh +85 -13
- package/references/sdk-mode.md +106 -0
- package/skills/model-selection.md +1 -1
- package/skills/quality-gates.md +22 -0
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run one command inside a reconciled process session with idle and hard deadlines."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import ctypes
|
|
7
|
+
import errno
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import select
|
|
11
|
+
import selectors
|
|
12
|
+
import secrets
|
|
13
|
+
import signal
|
|
14
|
+
import stat
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import BinaryIO, NamedTuple
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
TIMEOUT_EXIT = 124
|
|
23
|
+
INTERNAL_EXIT = 125
|
|
24
|
+
_DARWIN_LIBPROC = None
|
|
25
|
+
_DARWIN_LIBC = None
|
|
26
|
+
_LINEAGE_POLL_SECONDS = 0.02
|
|
27
|
+
_QUIESCENCE_SECONDS = 0.20
|
|
28
|
+
_LINEAGE_DISCOVERY_WINDOW_SECONDS = 0.20
|
|
29
|
+
_RECONCILE_SCHEDULING_MARGIN_SECONDS = 0.30
|
|
30
|
+
_LINEAGE_ENV_NAME = "LOKI_DEADLINE_LINEAGE"
|
|
31
|
+
_CONTROL_ENV_NAME = "LOKI_DEADLINE_CONTROL_FILE"
|
|
32
|
+
_EXEC_BOOTSTRAP = """
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
gate = int(sys.argv[1])
|
|
37
|
+
released = os.read(gate, 1)
|
|
38
|
+
os.close(gate)
|
|
39
|
+
if released != b"1":
|
|
40
|
+
raise SystemExit(125)
|
|
41
|
+
try:
|
|
42
|
+
os.execvpe(sys.argv[2], sys.argv[2:], os.environ)
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
raise SystemExit(127)
|
|
45
|
+
except PermissionError:
|
|
46
|
+
raise SystemExit(126)
|
|
47
|
+
except OSError:
|
|
48
|
+
raise SystemExit(125)
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _DarwinProcBsdInfo(ctypes.Structure):
|
|
53
|
+
_fields_ = [
|
|
54
|
+
("pbi_flags", ctypes.c_uint32),
|
|
55
|
+
("pbi_status", ctypes.c_uint32),
|
|
56
|
+
("pbi_xstatus", ctypes.c_uint32),
|
|
57
|
+
("pbi_pid", ctypes.c_uint32),
|
|
58
|
+
("pbi_ppid", ctypes.c_uint32),
|
|
59
|
+
("pbi_uid", ctypes.c_uint32),
|
|
60
|
+
("pbi_gid", ctypes.c_uint32),
|
|
61
|
+
("pbi_ruid", ctypes.c_uint32),
|
|
62
|
+
("pbi_rgid", ctypes.c_uint32),
|
|
63
|
+
("pbi_svuid", ctypes.c_uint32),
|
|
64
|
+
("pbi_svgid", ctypes.c_uint32),
|
|
65
|
+
("rfu_1", ctypes.c_uint32),
|
|
66
|
+
("pbi_comm", ctypes.c_char * 16),
|
|
67
|
+
("pbi_name", ctypes.c_char * 32),
|
|
68
|
+
("pbi_nfiles", ctypes.c_uint32),
|
|
69
|
+
("pbi_pgid", ctypes.c_uint32),
|
|
70
|
+
("pbi_pjobc", ctypes.c_uint32),
|
|
71
|
+
("e_tdev", ctypes.c_uint32),
|
|
72
|
+
("e_tpgid", ctypes.c_uint32),
|
|
73
|
+
("pbi_nice", ctypes.c_int32),
|
|
74
|
+
("pbi_start_tvsec", ctypes.c_uint64),
|
|
75
|
+
("pbi_start_tvusec", ctypes.c_uint64),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _DarwinProcFdInfo(ctypes.Structure):
|
|
80
|
+
_fields_ = [
|
|
81
|
+
("proc_fd", ctypes.c_int32),
|
|
82
|
+
("proc_fdtype", ctypes.c_uint32),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _DarwinProcFileInfo(ctypes.Structure):
|
|
87
|
+
_fields_ = [
|
|
88
|
+
("fi_openflags", ctypes.c_uint32),
|
|
89
|
+
("fi_status", ctypes.c_uint32),
|
|
90
|
+
("fi_offset", ctypes.c_int64),
|
|
91
|
+
("fi_type", ctypes.c_int32),
|
|
92
|
+
("fi_guardflags", ctypes.c_uint32),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _DarwinVinfoStat(ctypes.Structure):
|
|
97
|
+
_fields_ = [
|
|
98
|
+
("vst_dev", ctypes.c_uint32),
|
|
99
|
+
("vst_mode", ctypes.c_uint16),
|
|
100
|
+
("vst_nlink", ctypes.c_uint16),
|
|
101
|
+
("vst_ino", ctypes.c_uint64),
|
|
102
|
+
("vst_uid", ctypes.c_uint32),
|
|
103
|
+
("vst_gid", ctypes.c_uint32),
|
|
104
|
+
("vst_times", ctypes.c_int64 * 10),
|
|
105
|
+
("vst_blksize", ctypes.c_int32),
|
|
106
|
+
("vst_flags", ctypes.c_uint32),
|
|
107
|
+
("vst_gen", ctypes.c_uint32),
|
|
108
|
+
("vst_rdev", ctypes.c_uint32),
|
|
109
|
+
("vst_qspare", ctypes.c_int64 * 2),
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _DarwinPipeInfo(ctypes.Structure):
|
|
114
|
+
_fields_ = [
|
|
115
|
+
("pipe_stat", _DarwinVinfoStat),
|
|
116
|
+
("pipe_handle", ctypes.c_uint64),
|
|
117
|
+
("pipe_peerhandle", ctypes.c_uint64),
|
|
118
|
+
("pipe_status", ctypes.c_int32),
|
|
119
|
+
("rfu_1", ctypes.c_int32),
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _DarwinPipeFdInfo(ctypes.Structure):
|
|
124
|
+
_fields_ = [
|
|
125
|
+
("pfi", _DarwinProcFileInfo),
|
|
126
|
+
("pipeinfo", _DarwinPipeInfo),
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _darwin_libproc():
|
|
131
|
+
global _DARWIN_LIBPROC
|
|
132
|
+
if _DARWIN_LIBPROC is None:
|
|
133
|
+
library = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True)
|
|
134
|
+
library.proc_listallpids.argtypes = [
|
|
135
|
+
ctypes.POINTER(ctypes.c_int),
|
|
136
|
+
ctypes.c_int,
|
|
137
|
+
]
|
|
138
|
+
library.proc_listallpids.restype = ctypes.c_int
|
|
139
|
+
library.proc_pidinfo.argtypes = [
|
|
140
|
+
ctypes.c_int,
|
|
141
|
+
ctypes.c_int,
|
|
142
|
+
ctypes.c_uint64,
|
|
143
|
+
ctypes.c_void_p,
|
|
144
|
+
ctypes.c_int,
|
|
145
|
+
]
|
|
146
|
+
library.proc_pidinfo.restype = ctypes.c_int
|
|
147
|
+
library.proc_pidfdinfo.argtypes = [
|
|
148
|
+
ctypes.c_int,
|
|
149
|
+
ctypes.c_int,
|
|
150
|
+
ctypes.c_int,
|
|
151
|
+
ctypes.c_void_p,
|
|
152
|
+
ctypes.c_int,
|
|
153
|
+
]
|
|
154
|
+
library.proc_pidfdinfo.restype = ctypes.c_int
|
|
155
|
+
_DARWIN_LIBPROC = library
|
|
156
|
+
return _DARWIN_LIBPROC
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _darwin_libc():
|
|
160
|
+
global _DARWIN_LIBC
|
|
161
|
+
if _DARWIN_LIBC is None:
|
|
162
|
+
library = ctypes.CDLL(None, use_errno=True)
|
|
163
|
+
library.sysctl.argtypes = [
|
|
164
|
+
ctypes.POINTER(ctypes.c_int),
|
|
165
|
+
ctypes.c_uint,
|
|
166
|
+
ctypes.c_void_p,
|
|
167
|
+
ctypes.POINTER(ctypes.c_size_t),
|
|
168
|
+
ctypes.c_void_p,
|
|
169
|
+
ctypes.c_size_t,
|
|
170
|
+
]
|
|
171
|
+
library.sysctl.restype = ctypes.c_int
|
|
172
|
+
_DARWIN_LIBC = library
|
|
173
|
+
return _DARWIN_LIBC
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _candidate_pids() -> list[int]:
|
|
177
|
+
if sys.platform == "darwin":
|
|
178
|
+
library = _darwin_libproc()
|
|
179
|
+
count = library.proc_listallpids(None, 0)
|
|
180
|
+
if count <= 0:
|
|
181
|
+
raise RuntimeError("attempt process table query failed")
|
|
182
|
+
capacity = count + 64
|
|
183
|
+
while True:
|
|
184
|
+
values = (ctypes.c_int * capacity)()
|
|
185
|
+
read = library.proc_listallpids(values, ctypes.sizeof(values))
|
|
186
|
+
if read <= 0:
|
|
187
|
+
raise RuntimeError("attempt process table query failed")
|
|
188
|
+
if read < capacity:
|
|
189
|
+
return [pid for pid in values[:read] if pid > 0]
|
|
190
|
+
capacity *= 2
|
|
191
|
+
if sys.platform.startswith("linux"):
|
|
192
|
+
try:
|
|
193
|
+
return [int(name) for name in os.listdir("/proc") if name.isdigit()]
|
|
194
|
+
except OSError as exc:
|
|
195
|
+
raise RuntimeError("attempt process table query failed") from exc
|
|
196
|
+
raise RuntimeError("attempt process table query is unsupported")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class _ProcessRecord(NamedTuple):
|
|
200
|
+
pid: int
|
|
201
|
+
ppid: int
|
|
202
|
+
session_id: int
|
|
203
|
+
identity: str
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _process_record(pid: int) -> _ProcessRecord | None:
|
|
207
|
+
if sys.platform == "darwin":
|
|
208
|
+
info = _DarwinProcBsdInfo()
|
|
209
|
+
size = ctypes.sizeof(info)
|
|
210
|
+
read = _darwin_libproc().proc_pidinfo(pid, 3, 0, ctypes.byref(info), size)
|
|
211
|
+
if read != size or info.pbi_pid != pid or info.pbi_status == 5:
|
|
212
|
+
return None
|
|
213
|
+
try:
|
|
214
|
+
session_id = os.getsid(pid)
|
|
215
|
+
except OSError as exc:
|
|
216
|
+
if exc.errno in (errno.ESRCH, errno.EPERM, errno.EACCES):
|
|
217
|
+
return None
|
|
218
|
+
raise RuntimeError("attempt session identity check failed") from exc
|
|
219
|
+
return _ProcessRecord(
|
|
220
|
+
pid,
|
|
221
|
+
int(info.pbi_ppid),
|
|
222
|
+
session_id,
|
|
223
|
+
f"darwin:{info.pbi_start_tvsec}:{info.pbi_start_tvusec}",
|
|
224
|
+
)
|
|
225
|
+
try:
|
|
226
|
+
with open(
|
|
227
|
+
f"/proc/{pid}/stat", encoding="utf-8", errors="replace"
|
|
228
|
+
) as handle:
|
|
229
|
+
raw = handle.read()
|
|
230
|
+
fields = raw.rsplit(")", 1)[1].strip().split()
|
|
231
|
+
if fields[0] == "Z":
|
|
232
|
+
return None
|
|
233
|
+
return _ProcessRecord(
|
|
234
|
+
pid,
|
|
235
|
+
int(fields[1]),
|
|
236
|
+
int(fields[3]),
|
|
237
|
+
f"proc:{fields[19]}",
|
|
238
|
+
)
|
|
239
|
+
except (OSError, IndexError, ValueError):
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _process_identity(pid: int) -> str:
|
|
244
|
+
record = _process_record(pid)
|
|
245
|
+
return record.identity if record is not None else ""
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _process_snapshot() -> dict[int, _ProcessRecord]:
|
|
249
|
+
snapshot: dict[int, _ProcessRecord] = {}
|
|
250
|
+
for pid in _candidate_pids():
|
|
251
|
+
record = _process_record(pid)
|
|
252
|
+
if record is not None:
|
|
253
|
+
snapshot[pid] = record
|
|
254
|
+
return snapshot
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _process_has_lineage_token(pid: int, token: str) -> bool:
|
|
258
|
+
needle = f"{_LINEAGE_ENV_NAME}={token}".encode("ascii")
|
|
259
|
+
if sys.platform == "darwin":
|
|
260
|
+
mib = (ctypes.c_int * 3)(1, 49, pid) # CTL_KERN, KERN_PROCARGS2
|
|
261
|
+
size = ctypes.c_size_t()
|
|
262
|
+
library = _darwin_libc()
|
|
263
|
+
if library.sysctl(mib, 3, None, ctypes.byref(size), None, 0) != 0:
|
|
264
|
+
return False
|
|
265
|
+
if size.value <= 0:
|
|
266
|
+
return False
|
|
267
|
+
buffer = ctypes.create_string_buffer(size.value)
|
|
268
|
+
if library.sysctl(
|
|
269
|
+
mib, 3, buffer, ctypes.byref(size), None, 0
|
|
270
|
+
) != 0:
|
|
271
|
+
return False
|
|
272
|
+
return needle in bytes(buffer.raw[: size.value]).split(b"\0")
|
|
273
|
+
if sys.platform.startswith("linux"):
|
|
274
|
+
try:
|
|
275
|
+
with open(f"/proc/{pid}/environ", "rb") as handle:
|
|
276
|
+
return needle in handle.read().split(b"\0")
|
|
277
|
+
except OSError:
|
|
278
|
+
return False
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _darwin_pipe_handles(pid: int, fd: int) -> frozenset[int]:
|
|
283
|
+
info = _DarwinPipeFdInfo()
|
|
284
|
+
size = ctypes.sizeof(info)
|
|
285
|
+
read = _darwin_libproc().proc_pidfdinfo(
|
|
286
|
+
pid, fd, 6, ctypes.byref(info), size # PROC_PIDFDPIPEINFO
|
|
287
|
+
)
|
|
288
|
+
if read != size:
|
|
289
|
+
return frozenset()
|
|
290
|
+
return frozenset(
|
|
291
|
+
value
|
|
292
|
+
for value in (
|
|
293
|
+
int(info.pipeinfo.pipe_handle),
|
|
294
|
+
int(info.pipeinfo.pipe_peerhandle),
|
|
295
|
+
)
|
|
296
|
+
if value != 0
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _process_has_lineage_pipe(pid: int, handles: frozenset[int]) -> bool:
|
|
301
|
+
if sys.platform != "darwin" or not handles:
|
|
302
|
+
return False
|
|
303
|
+
library = _darwin_libproc()
|
|
304
|
+
entry_size = ctypes.sizeof(_DarwinProcFdInfo)
|
|
305
|
+
required = library.proc_pidinfo(pid, 1, 0, None, 0) # PROC_PIDLISTFDS
|
|
306
|
+
if required <= 0:
|
|
307
|
+
return False
|
|
308
|
+
capacity = max(16, (required // entry_size) + 8)
|
|
309
|
+
entries = (_DarwinProcFdInfo * capacity)()
|
|
310
|
+
read = library.proc_pidinfo(
|
|
311
|
+
pid, 1, 0, ctypes.byref(entries), ctypes.sizeof(entries)
|
|
312
|
+
)
|
|
313
|
+
if read <= 0:
|
|
314
|
+
return False
|
|
315
|
+
for entry in entries[: read // entry_size]:
|
|
316
|
+
if entry.proc_fdtype != 6: # PROX_FDTYPE_PIPE
|
|
317
|
+
continue
|
|
318
|
+
if _darwin_pipe_handles(pid, int(entry.proc_fd)) & handles:
|
|
319
|
+
return True
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _configure_child_subreaper() -> bool:
|
|
324
|
+
"""Make Linux orphans observable without adding a runtime dependency."""
|
|
325
|
+
if not sys.platform.startswith("linux"):
|
|
326
|
+
return False
|
|
327
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
328
|
+
prctl = getattr(libc, "prctl", None)
|
|
329
|
+
if prctl is None:
|
|
330
|
+
raise RuntimeError("attempt child subreaper is unavailable")
|
|
331
|
+
prctl.argtypes = [
|
|
332
|
+
ctypes.c_int,
|
|
333
|
+
ctypes.c_ulong,
|
|
334
|
+
ctypes.c_ulong,
|
|
335
|
+
ctypes.c_ulong,
|
|
336
|
+
ctypes.c_ulong,
|
|
337
|
+
]
|
|
338
|
+
prctl.restype = ctypes.c_int
|
|
339
|
+
if prctl(36, 1, 0, 0, 0) != 0: # PR_SET_CHILD_SUBREAPER
|
|
340
|
+
raise RuntimeError("attempt child subreaper setup failed")
|
|
341
|
+
return True
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class _LineageTracker:
|
|
345
|
+
"""Retain birth-token-bound descendants across reparenting and setsid."""
|
|
346
|
+
|
|
347
|
+
def __init__(
|
|
348
|
+
self,
|
|
349
|
+
process: subprocess.Popen[bytes],
|
|
350
|
+
subreaper: bool,
|
|
351
|
+
token: str,
|
|
352
|
+
lineage_pipe_handles: frozenset[int],
|
|
353
|
+
):
|
|
354
|
+
root = _process_record(process.pid)
|
|
355
|
+
if root is None:
|
|
356
|
+
raise RuntimeError("provider attempt identity is unavailable")
|
|
357
|
+
if not (
|
|
358
|
+
_process_has_lineage_token(root.pid, token)
|
|
359
|
+
or _process_has_lineage_pipe(root.pid, lineage_pipe_handles)
|
|
360
|
+
):
|
|
361
|
+
raise RuntimeError("provider attempt lineage marker is unavailable")
|
|
362
|
+
self.root_pid = root.pid
|
|
363
|
+
self.session_id = root.session_id
|
|
364
|
+
self.subreaper = subreaper
|
|
365
|
+
self.token = token
|
|
366
|
+
self.lineage_pipe_handles = lineage_pipe_handles
|
|
367
|
+
self._marker_scan_until = 0.0
|
|
368
|
+
self._depths: dict[tuple[int, str], int] = {
|
|
369
|
+
(root.pid, root.identity): 0
|
|
370
|
+
}
|
|
371
|
+
self._proc_events = None
|
|
372
|
+
self._lineage_read_fd = -1
|
|
373
|
+
self._kernel_tracks_children = False
|
|
374
|
+
if sys.platform == "darwin":
|
|
375
|
+
try:
|
|
376
|
+
self._proc_events = select.kqueue()
|
|
377
|
+
self._kernel_tracks_children = self._watch_process(
|
|
378
|
+
root.pid, track_children=True
|
|
379
|
+
)
|
|
380
|
+
if not self._kernel_tracks_children:
|
|
381
|
+
self._marker_scan_until = (
|
|
382
|
+
time.monotonic() + _LINEAGE_DISCOVERY_WINDOW_SECONDS
|
|
383
|
+
)
|
|
384
|
+
except (AttributeError, OSError) as exc:
|
|
385
|
+
raise RuntimeError("attempt process lineage tracking failed") from exc
|
|
386
|
+
|
|
387
|
+
def _watch_process(self, pid: int, track_children: bool = False) -> bool:
|
|
388
|
+
if self._proc_events is None:
|
|
389
|
+
return False
|
|
390
|
+
fflags = select.KQ_NOTE_FORK | select.KQ_NOTE_EXIT
|
|
391
|
+
if track_children:
|
|
392
|
+
fflags |= select.KQ_NOTE_TRACK
|
|
393
|
+
change = select.kevent(
|
|
394
|
+
pid,
|
|
395
|
+
filter=select.KQ_FILTER_PROC,
|
|
396
|
+
flags=select.KQ_EV_ADD | select.KQ_EV_CLEAR,
|
|
397
|
+
fflags=fflags,
|
|
398
|
+
)
|
|
399
|
+
try:
|
|
400
|
+
self._proc_events.control([change], 0, 0)
|
|
401
|
+
except OSError as exc:
|
|
402
|
+
if track_children and exc.errno == errno.ENOTSUP:
|
|
403
|
+
self._watch_process(pid, track_children=False)
|
|
404
|
+
return False
|
|
405
|
+
if exc.errno == errno.ESRCH:
|
|
406
|
+
return False
|
|
407
|
+
raise
|
|
408
|
+
return track_children
|
|
409
|
+
|
|
410
|
+
def _depth_for_pid(self, pid: int) -> int:
|
|
411
|
+
depths = [
|
|
412
|
+
depth
|
|
413
|
+
for (tracked_pid, _identity), depth in self._depths.items()
|
|
414
|
+
if tracked_pid == pid
|
|
415
|
+
]
|
|
416
|
+
return max(depths, default=0)
|
|
417
|
+
|
|
418
|
+
def _capture_proc_events(self) -> None:
|
|
419
|
+
if self._proc_events is None:
|
|
420
|
+
return
|
|
421
|
+
while True:
|
|
422
|
+
try:
|
|
423
|
+
events = self._proc_events.control(None, 256, 0)
|
|
424
|
+
except OSError as exc:
|
|
425
|
+
raise RuntimeError("attempt process lineage event read failed") from exc
|
|
426
|
+
for event in events:
|
|
427
|
+
if event.fflags & select.KQ_NOTE_TRACKERR:
|
|
428
|
+
raise RuntimeError("attempt process lineage event overflow")
|
|
429
|
+
if (
|
|
430
|
+
not self._kernel_tracks_children
|
|
431
|
+
and event.fflags
|
|
432
|
+
& (select.KQ_NOTE_FORK | select.KQ_NOTE_CHILD)
|
|
433
|
+
):
|
|
434
|
+
self._marker_scan_until = max(
|
|
435
|
+
self._marker_scan_until,
|
|
436
|
+
time.monotonic() + _LINEAGE_DISCOVERY_WINDOW_SECONDS,
|
|
437
|
+
)
|
|
438
|
+
record = _process_record(int(event.ident))
|
|
439
|
+
if record is None:
|
|
440
|
+
continue
|
|
441
|
+
parent_pid = (
|
|
442
|
+
int(event.data)
|
|
443
|
+
if event.fflags & select.KQ_NOTE_CHILD
|
|
444
|
+
else record.ppid
|
|
445
|
+
)
|
|
446
|
+
self._depths.setdefault(
|
|
447
|
+
(record.pid, record.identity),
|
|
448
|
+
self._depth_for_pid(parent_pid) + 1,
|
|
449
|
+
)
|
|
450
|
+
if len(events) < 256:
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
def _snapshot(self) -> dict[int, _ProcessRecord]:
|
|
454
|
+
if not self._kernel_tracks_children:
|
|
455
|
+
return _process_snapshot()
|
|
456
|
+
snapshot: dict[int, _ProcessRecord] = {}
|
|
457
|
+
for pid, identity in tuple(self._depths):
|
|
458
|
+
record = _process_record(pid)
|
|
459
|
+
if record is not None and record.identity == identity:
|
|
460
|
+
snapshot[pid] = record
|
|
461
|
+
return snapshot
|
|
462
|
+
|
|
463
|
+
def refresh(self) -> list[tuple[int, str]]:
|
|
464
|
+
self._capture_proc_events()
|
|
465
|
+
snapshot = self._snapshot()
|
|
466
|
+
if time.monotonic() <= self._marker_scan_until:
|
|
467
|
+
for record in snapshot.values():
|
|
468
|
+
ref = (record.pid, record.identity)
|
|
469
|
+
if record.pid == os.getpid() or ref in self._depths:
|
|
470
|
+
continue
|
|
471
|
+
if _process_has_lineage_token(
|
|
472
|
+
record.pid, self.token
|
|
473
|
+
) or _process_has_lineage_pipe(
|
|
474
|
+
record.pid, self.lineage_pipe_handles
|
|
475
|
+
):
|
|
476
|
+
self._depths[ref] = self._depth_for_pid(record.ppid) + 1
|
|
477
|
+
self._watch_process(record.pid)
|
|
478
|
+
while True:
|
|
479
|
+
active_depths = {
|
|
480
|
+
pid: depth
|
|
481
|
+
for (pid, identity), depth in self._depths.items()
|
|
482
|
+
if pid in snapshot and snapshot[pid].identity == identity
|
|
483
|
+
}
|
|
484
|
+
additions: list[tuple[tuple[int, str], int]] = []
|
|
485
|
+
for record in snapshot.values():
|
|
486
|
+
ref = (record.pid, record.identity)
|
|
487
|
+
if ref in self._depths:
|
|
488
|
+
continue
|
|
489
|
+
parent_depth = active_depths.get(record.ppid)
|
|
490
|
+
if parent_depth is not None:
|
|
491
|
+
additions.append((ref, parent_depth + 1))
|
|
492
|
+
elif record.session_id == self.session_id:
|
|
493
|
+
additions.append((ref, 1))
|
|
494
|
+
elif self.subreaper and record.ppid == os.getpid():
|
|
495
|
+
# Linux reparents escaped double-fork descendants to this
|
|
496
|
+
# helper after PR_SET_CHILD_SUBREAPER. This process creates
|
|
497
|
+
# no other children after the provider leader.
|
|
498
|
+
additions.append((ref, 1))
|
|
499
|
+
if not additions:
|
|
500
|
+
break
|
|
501
|
+
for ref, depth in additions:
|
|
502
|
+
self._depths.setdefault(ref, depth)
|
|
503
|
+
self._watch_process(ref[0])
|
|
504
|
+
|
|
505
|
+
return sorted(
|
|
506
|
+
(
|
|
507
|
+
ref
|
|
508
|
+
for ref in self._depths
|
|
509
|
+
if ref[0] in snapshot and snapshot[ref[0]].identity == ref[1]
|
|
510
|
+
),
|
|
511
|
+
key=lambda ref: self._depths[ref],
|
|
512
|
+
reverse=True,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
def close(self) -> None:
|
|
516
|
+
if self._proc_events is not None:
|
|
517
|
+
self._proc_events.close()
|
|
518
|
+
self._proc_events = None
|
|
519
|
+
if self._lineage_read_fd >= 0:
|
|
520
|
+
os.close(self._lineage_read_fd)
|
|
521
|
+
self._lineage_read_fd = -1
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def spawn_tracked(
|
|
525
|
+
command: list[str], **popen_kwargs: object
|
|
526
|
+
) -> tuple[subprocess.Popen[bytes], _LineageTracker]:
|
|
527
|
+
"""Spawn a gated session leader and arm identity-bound lineage tracking."""
|
|
528
|
+
if not command or "start_new_session" in popen_kwargs:
|
|
529
|
+
raise ValueError("tracked spawn requires a command and owns its session")
|
|
530
|
+
|
|
531
|
+
process: subprocess.Popen[bytes] | None = None
|
|
532
|
+
gate_read = gate_write = -1
|
|
533
|
+
lineage_read = lineage_write = -1
|
|
534
|
+
try:
|
|
535
|
+
subreaper = _configure_child_subreaper()
|
|
536
|
+
token = secrets.token_hex(16)
|
|
537
|
+
child_env = dict(popen_kwargs.pop("env", os.environ))
|
|
538
|
+
child_env[_LINEAGE_ENV_NAME] = token
|
|
539
|
+
child_env.pop(_CONTROL_ENV_NAME, None)
|
|
540
|
+
inherited = tuple(popen_kwargs.pop("pass_fds", ()))
|
|
541
|
+
gate_read, gate_write = os.pipe()
|
|
542
|
+
lineage_read, lineage_write = os.pipe()
|
|
543
|
+
handles = (
|
|
544
|
+
_darwin_pipe_handles(os.getpid(), lineage_read)
|
|
545
|
+
if sys.platform == "darwin"
|
|
546
|
+
else frozenset()
|
|
547
|
+
)
|
|
548
|
+
if sys.platform == "darwin" and not handles:
|
|
549
|
+
raise RuntimeError("attempt lineage pipe identity is unavailable")
|
|
550
|
+
process = subprocess.Popen(
|
|
551
|
+
[sys.executable, "-c", _EXEC_BOOTSTRAP, str(gate_read), *command],
|
|
552
|
+
start_new_session=True,
|
|
553
|
+
env=child_env,
|
|
554
|
+
pass_fds=(*inherited, gate_read, lineage_write),
|
|
555
|
+
**popen_kwargs,
|
|
556
|
+
)
|
|
557
|
+
os.close(gate_read)
|
|
558
|
+
gate_read = -1
|
|
559
|
+
tracker = _LineageTracker(process, subreaper, token, handles)
|
|
560
|
+
tracker._lineage_read_fd = lineage_read
|
|
561
|
+
lineage_read = -1
|
|
562
|
+
os.write(gate_write, b"1")
|
|
563
|
+
os.close(gate_write)
|
|
564
|
+
gate_write = -1
|
|
565
|
+
os.close(lineage_write)
|
|
566
|
+
lineage_write = -1
|
|
567
|
+
return process, tracker
|
|
568
|
+
except BaseException:
|
|
569
|
+
for descriptor in (gate_read, gate_write, lineage_read, lineage_write):
|
|
570
|
+
if descriptor >= 0:
|
|
571
|
+
try:
|
|
572
|
+
os.close(descriptor)
|
|
573
|
+
except OSError:
|
|
574
|
+
pass
|
|
575
|
+
if process is not None and process.poll() is None:
|
|
576
|
+
process.terminate()
|
|
577
|
+
try:
|
|
578
|
+
process.wait(timeout=1)
|
|
579
|
+
except subprocess.TimeoutExpired:
|
|
580
|
+
process.kill()
|
|
581
|
+
process.wait(timeout=1)
|
|
582
|
+
raise
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _signal_process(member: tuple[int, str], sig: int) -> None:
|
|
586
|
+
pid, identity = member
|
|
587
|
+
try:
|
|
588
|
+
if _process_identity(pid) != identity:
|
|
589
|
+
return
|
|
590
|
+
os.kill(pid, sig)
|
|
591
|
+
except ProcessLookupError:
|
|
592
|
+
return
|
|
593
|
+
except OSError as exc:
|
|
594
|
+
if exc.errno != errno.ESRCH:
|
|
595
|
+
raise
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def reconcile_lineage(
|
|
599
|
+
process: subprocess.Popen[bytes], tracker: _LineageTracker, grace: float
|
|
600
|
+
) -> dict[str, object]:
|
|
601
|
+
"""Stop the exact tracked lineage and prove a bounded quiet interval."""
|
|
602
|
+
outcome: dict[str, object] = {
|
|
603
|
+
"checked": True,
|
|
604
|
+
"detected": False,
|
|
605
|
+
"quiescent": False,
|
|
606
|
+
"term_sent": [],
|
|
607
|
+
"kill_sent": [],
|
|
608
|
+
"remaining_pids": [],
|
|
609
|
+
"error": "",
|
|
610
|
+
}
|
|
611
|
+
sent: dict[int, set[tuple[int, str]]] = {
|
|
612
|
+
signal.SIGTERM: set(),
|
|
613
|
+
signal.SIGKILL: set(),
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
def refresh() -> list[tuple[int, str]]:
|
|
617
|
+
members = tracker.refresh()
|
|
618
|
+
if any(pid != process.pid for pid, _identity in members):
|
|
619
|
+
outcome["detected"] = True
|
|
620
|
+
return members
|
|
621
|
+
|
|
622
|
+
def signal_members(
|
|
623
|
+
members: list[tuple[int, str]], signum: int, field: str
|
|
624
|
+
) -> None:
|
|
625
|
+
recorded = outcome[field]
|
|
626
|
+
assert isinstance(recorded, list)
|
|
627
|
+
for member in members:
|
|
628
|
+
_signal_process(member, signum)
|
|
629
|
+
if member not in sent[signum]:
|
|
630
|
+
sent[signum].add(member)
|
|
631
|
+
if member[0] != process.pid:
|
|
632
|
+
recorded.append(member[0])
|
|
633
|
+
|
|
634
|
+
started = time.monotonic()
|
|
635
|
+
total_grace = max(
|
|
636
|
+
float(grace) + _QUIESCENCE_SECONDS,
|
|
637
|
+
_QUIESCENCE_SECONDS + _RECONCILE_SCHEDULING_MARGIN_SECONDS,
|
|
638
|
+
)
|
|
639
|
+
final_deadline = started + total_grace
|
|
640
|
+
term_deadline = final_deadline - (
|
|
641
|
+
_QUIESCENCE_SECONDS + _RECONCILE_SCHEDULING_MARGIN_SECONDS
|
|
642
|
+
)
|
|
643
|
+
members = refresh()
|
|
644
|
+
signal_members(members, signal.SIGTERM, "term_sent")
|
|
645
|
+
while members and time.monotonic() < term_deadline:
|
|
646
|
+
time.sleep(_LINEAGE_POLL_SECONDS)
|
|
647
|
+
members = refresh()
|
|
648
|
+
signal_members(members, signal.SIGTERM, "term_sent")
|
|
649
|
+
|
|
650
|
+
quiet_since: float | None = None
|
|
651
|
+
while time.monotonic() < final_deadline:
|
|
652
|
+
members = refresh()
|
|
653
|
+
if members:
|
|
654
|
+
quiet_since = None
|
|
655
|
+
signal_members(members, signal.SIGKILL, "kill_sent")
|
|
656
|
+
elif quiet_since is None:
|
|
657
|
+
quiet_since = time.monotonic()
|
|
658
|
+
elif time.monotonic() - quiet_since >= _QUIESCENCE_SECONDS:
|
|
659
|
+
break
|
|
660
|
+
time.sleep(_LINEAGE_POLL_SECONDS)
|
|
661
|
+
members = refresh()
|
|
662
|
+
outcome["remaining_pids"] = [pid for pid, _identity in members]
|
|
663
|
+
outcome["quiescent"] = bool(
|
|
664
|
+
not members
|
|
665
|
+
and quiet_since is not None
|
|
666
|
+
and time.monotonic() - quiet_since >= _QUIESCENCE_SECONDS
|
|
667
|
+
)
|
|
668
|
+
return outcome
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _stop_lineage(
|
|
672
|
+
process: subprocess.Popen[bytes], tracker: _LineageTracker, grace: float
|
|
673
|
+
) -> bool:
|
|
674
|
+
outcome = reconcile_lineage(process, tracker, grace)
|
|
675
|
+
members = outcome["remaining_pids"]
|
|
676
|
+
if not outcome["quiescent"]:
|
|
677
|
+
raise RuntimeError(
|
|
678
|
+
"provider attempt lineage is still active: "
|
|
679
|
+
+ ",".join(str(pid) for pid in members)
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
try:
|
|
683
|
+
process.wait(timeout=1)
|
|
684
|
+
except subprocess.TimeoutExpired:
|
|
685
|
+
raise RuntimeError("provider attempt leader did not stop")
|
|
686
|
+
return bool(outcome["detected"])
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _exit_code(returncode: int) -> int:
|
|
690
|
+
return 128 + abs(returncode) if returncode < 0 else returncode
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _write_output(chunk: bytes, stream: BinaryIO) -> bool:
|
|
694
|
+
try:
|
|
695
|
+
stream.write(chunk)
|
|
696
|
+
stream.flush()
|
|
697
|
+
return True
|
|
698
|
+
except BrokenPipeError:
|
|
699
|
+
return False
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _emit_timeout(reason: str, hard_seconds: int, idle_seconds: int) -> None:
|
|
703
|
+
record = {
|
|
704
|
+
"type": "loki_provider_deadline",
|
|
705
|
+
"reason": reason,
|
|
706
|
+
"hard_seconds": hard_seconds,
|
|
707
|
+
"idle_seconds": idle_seconds,
|
|
708
|
+
}
|
|
709
|
+
print(json.dumps(record, separators=(",", ":")), file=sys.stderr, flush=True)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _write_control(path: Path) -> bytes:
|
|
713
|
+
record = json.dumps(
|
|
714
|
+
{
|
|
715
|
+
"schema": "loki-deadline-control/v1",
|
|
716
|
+
"pid": os.getpid(),
|
|
717
|
+
"identity": _process_identity(os.getpid()),
|
|
718
|
+
},
|
|
719
|
+
sort_keys=True,
|
|
720
|
+
).encode("utf-8")
|
|
721
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
722
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
723
|
+
flags |= os.O_NOFOLLOW
|
|
724
|
+
descriptor = os.open(path, flags, 0o600)
|
|
725
|
+
try:
|
|
726
|
+
os.fchmod(descriptor, 0o600)
|
|
727
|
+
os.write(descriptor, record)
|
|
728
|
+
finally:
|
|
729
|
+
os.close(descriptor)
|
|
730
|
+
return record
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _clear_control(path: Path, expected: bytes) -> None:
|
|
734
|
+
try:
|
|
735
|
+
if path.read_bytes() == expected:
|
|
736
|
+
path.unlink()
|
|
737
|
+
except OSError:
|
|
738
|
+
pass
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def cancel_control(path: Path) -> int:
|
|
742
|
+
try:
|
|
743
|
+
info = os.lstat(path)
|
|
744
|
+
if (
|
|
745
|
+
not stat.S_ISREG(info.st_mode)
|
|
746
|
+
or info.st_uid != os.getuid()
|
|
747
|
+
or info.st_nlink != 1
|
|
748
|
+
or stat.S_IMODE(info.st_mode) != 0o600
|
|
749
|
+
or info.st_size > 1024
|
|
750
|
+
):
|
|
751
|
+
return INTERNAL_EXIT
|
|
752
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
753
|
+
if set(record) != {"schema", "pid", "identity"}:
|
|
754
|
+
return INTERNAL_EXIT
|
|
755
|
+
pid = record["pid"]
|
|
756
|
+
identity = record["identity"]
|
|
757
|
+
if (
|
|
758
|
+
record["schema"] != "loki-deadline-control/v1"
|
|
759
|
+
or not isinstance(pid, int)
|
|
760
|
+
or pid <= 1
|
|
761
|
+
or not isinstance(identity, str)
|
|
762
|
+
or not identity
|
|
763
|
+
or _process_identity(pid) != identity
|
|
764
|
+
):
|
|
765
|
+
return INTERNAL_EXIT
|
|
766
|
+
os.kill(pid, signal.SIGTERM)
|
|
767
|
+
return 0
|
|
768
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
769
|
+
return INTERNAL_EXIT
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _drain_output(process: subprocess.Popen[bytes]) -> None:
|
|
773
|
+
for pipe, stream in (
|
|
774
|
+
(process.stdout, sys.stdout.buffer),
|
|
775
|
+
(process.stderr, sys.stderr.buffer),
|
|
776
|
+
):
|
|
777
|
+
if pipe is None:
|
|
778
|
+
continue
|
|
779
|
+
while True:
|
|
780
|
+
try:
|
|
781
|
+
chunk = os.read(pipe.fileno(), 65536)
|
|
782
|
+
except BlockingIOError:
|
|
783
|
+
break
|
|
784
|
+
if not chunk:
|
|
785
|
+
break
|
|
786
|
+
if not _write_output(chunk, stream):
|
|
787
|
+
break
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _wait_for_output(
|
|
791
|
+
process: subprocess.Popen[bytes],
|
|
792
|
+
tracker: _LineageTracker,
|
|
793
|
+
hard_seconds: int,
|
|
794
|
+
idle_seconds: int,
|
|
795
|
+
) -> tuple[int | None, str | None]:
|
|
796
|
+
if process.stdout is None or process.stderr is None:
|
|
797
|
+
return INTERNAL_EXIT, None
|
|
798
|
+
|
|
799
|
+
selector = selectors.DefaultSelector()
|
|
800
|
+
streams = {
|
|
801
|
+
process.stdout.fileno(): sys.stdout.buffer,
|
|
802
|
+
process.stderr.fileno(): sys.stderr.buffer,
|
|
803
|
+
}
|
|
804
|
+
for output_fd, stream in streams.items():
|
|
805
|
+
os.set_blocking(output_fd, False)
|
|
806
|
+
selector.register(output_fd, selectors.EVENT_READ, stream)
|
|
807
|
+
started = last_activity = time.monotonic()
|
|
808
|
+
open_fds = set(streams)
|
|
809
|
+
|
|
810
|
+
try:
|
|
811
|
+
while True:
|
|
812
|
+
tracker.refresh()
|
|
813
|
+
now = time.monotonic()
|
|
814
|
+
hard_remaining = hard_seconds - (now - started)
|
|
815
|
+
idle_remaining = (
|
|
816
|
+
idle_seconds - (now - last_activity) if idle_seconds > 0 else hard_remaining
|
|
817
|
+
)
|
|
818
|
+
wait_seconds = max(
|
|
819
|
+
0.0,
|
|
820
|
+
min(hard_remaining, idle_remaining, _LINEAGE_POLL_SECONDS),
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
if open_fds:
|
|
824
|
+
for key, _events in selector.select(wait_seconds):
|
|
825
|
+
try:
|
|
826
|
+
chunk = os.read(key.fd, 65536)
|
|
827
|
+
except BlockingIOError:
|
|
828
|
+
continue
|
|
829
|
+
if chunk:
|
|
830
|
+
if not _write_output(chunk, key.data):
|
|
831
|
+
return 141, None
|
|
832
|
+
last_activity = time.monotonic()
|
|
833
|
+
else:
|
|
834
|
+
selector.unregister(key.fd)
|
|
835
|
+
open_fds.discard(key.fd)
|
|
836
|
+
elif wait_seconds > 0:
|
|
837
|
+
time.sleep(wait_seconds)
|
|
838
|
+
|
|
839
|
+
tracker.refresh()
|
|
840
|
+
|
|
841
|
+
returncode = process.poll()
|
|
842
|
+
if returncode is not None:
|
|
843
|
+
for output_fd in tuple(open_fds):
|
|
844
|
+
while True:
|
|
845
|
+
try:
|
|
846
|
+
chunk = os.read(output_fd, 65536)
|
|
847
|
+
except BlockingIOError:
|
|
848
|
+
break
|
|
849
|
+
if not chunk:
|
|
850
|
+
break
|
|
851
|
+
if not _write_output(chunk, streams[output_fd]):
|
|
852
|
+
return 141, None
|
|
853
|
+
return _exit_code(returncode), None
|
|
854
|
+
|
|
855
|
+
now = time.monotonic()
|
|
856
|
+
if now - started >= hard_seconds:
|
|
857
|
+
return None, "hard_timeout"
|
|
858
|
+
if idle_seconds > 0 and now - last_activity >= idle_seconds:
|
|
859
|
+
return None, "idle_timeout"
|
|
860
|
+
finally:
|
|
861
|
+
selector.close()
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def main() -> int:
|
|
865
|
+
if len(sys.argv) == 3 and sys.argv[1] == "cancel":
|
|
866
|
+
return cancel_control(Path(sys.argv[2]))
|
|
867
|
+
if len(sys.argv) < 5:
|
|
868
|
+
return INTERNAL_EXIT
|
|
869
|
+
try:
|
|
870
|
+
hard_seconds = int(sys.argv[1])
|
|
871
|
+
kill_grace = int(sys.argv[2])
|
|
872
|
+
if sys.argv[3] == "--":
|
|
873
|
+
idle_seconds = 0
|
|
874
|
+
command = sys.argv[4:]
|
|
875
|
+
elif len(sys.argv) >= 6 and sys.argv[4] == "--":
|
|
876
|
+
idle_seconds = int(sys.argv[3])
|
|
877
|
+
command = sys.argv[5:]
|
|
878
|
+
else:
|
|
879
|
+
return INTERNAL_EXIT
|
|
880
|
+
except ValueError:
|
|
881
|
+
return INTERNAL_EXIT
|
|
882
|
+
if hard_seconds <= 0 or kill_grace <= 0 or idle_seconds < 0 or not command:
|
|
883
|
+
return INTERNAL_EXIT
|
|
884
|
+
if idle_seconds >= hard_seconds and idle_seconds != 0:
|
|
885
|
+
return INTERNAL_EXIT
|
|
886
|
+
|
|
887
|
+
process: subprocess.Popen[bytes] | None = None
|
|
888
|
+
tracker: _LineageTracker | None = None
|
|
889
|
+
try:
|
|
890
|
+
process, tracker = spawn_tracked(
|
|
891
|
+
command,
|
|
892
|
+
stdout=subprocess.PIPE,
|
|
893
|
+
stderr=subprocess.PIPE,
|
|
894
|
+
bufsize=0,
|
|
895
|
+
)
|
|
896
|
+
except (OSError, RuntimeError):
|
|
897
|
+
return INTERNAL_EXIT
|
|
898
|
+
|
|
899
|
+
def forward_and_exit(signum: int, _frame: object) -> None:
|
|
900
|
+
try:
|
|
901
|
+
_stop_lineage(process, tracker, min(float(kill_grace), 0.5))
|
|
902
|
+
except (PermissionError, RuntimeError):
|
|
903
|
+
raise SystemExit(INTERNAL_EXIT)
|
|
904
|
+
raise SystemExit(128 + signum)
|
|
905
|
+
|
|
906
|
+
for forwarded in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
|
|
907
|
+
signal.signal(forwarded, forward_and_exit)
|
|
908
|
+
|
|
909
|
+
control_path: Path | None = None
|
|
910
|
+
control_record = b""
|
|
911
|
+
try:
|
|
912
|
+
try:
|
|
913
|
+
raw_control_path = os.environ.get(_CONTROL_ENV_NAME, "")
|
|
914
|
+
if raw_control_path:
|
|
915
|
+
control_path = Path(raw_control_path)
|
|
916
|
+
control_record = _write_control(control_path)
|
|
917
|
+
returncode, timeout_reason = _wait_for_output(
|
|
918
|
+
process, tracker, hard_seconds, idle_seconds
|
|
919
|
+
)
|
|
920
|
+
except (OSError, PermissionError, RuntimeError):
|
|
921
|
+
try:
|
|
922
|
+
_stop_lineage(process, tracker, float(kill_grace))
|
|
923
|
+
except (PermissionError, RuntimeError):
|
|
924
|
+
pass
|
|
925
|
+
return INTERNAL_EXIT
|
|
926
|
+
if timeout_reason is not None:
|
|
927
|
+
_emit_timeout(timeout_reason, hard_seconds, idle_seconds)
|
|
928
|
+
try:
|
|
929
|
+
_stop_lineage(process, tracker, float(kill_grace))
|
|
930
|
+
except (PermissionError, RuntimeError):
|
|
931
|
+
return INTERNAL_EXIT
|
|
932
|
+
_drain_output(process)
|
|
933
|
+
return TIMEOUT_EXIT
|
|
934
|
+
|
|
935
|
+
if returncode == 141:
|
|
936
|
+
try:
|
|
937
|
+
_stop_lineage(process, tracker, float(kill_grace))
|
|
938
|
+
except (PermissionError, RuntimeError):
|
|
939
|
+
return INTERNAL_EXIT
|
|
940
|
+
return 141
|
|
941
|
+
|
|
942
|
+
# A provider must not leave tool processes alive after its own exit.
|
|
943
|
+
try:
|
|
944
|
+
had_descendants = _stop_lineage(process, tracker, 0.1)
|
|
945
|
+
except (PermissionError, RuntimeError):
|
|
946
|
+
return INTERNAL_EXIT
|
|
947
|
+
if had_descendants:
|
|
948
|
+
return INTERNAL_EXIT
|
|
949
|
+
return returncode if returncode is not None else INTERNAL_EXIT
|
|
950
|
+
finally:
|
|
951
|
+
if control_path is not None and control_record:
|
|
952
|
+
_clear_control(control_path, control_record)
|
|
953
|
+
tracker.close()
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
if __name__ == "__main__":
|
|
957
|
+
raise SystemExit(main())
|