h2h-dev-tools 0.2.1__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.
- h2h_dev_tools/__init__.py +1 -0
- h2h_dev_tools/__main__.py +43 -0
- h2h_dev_tools/atomic_files.py +32 -0
- h2h_dev_tools/bundle.py +107 -0
- h2h_dev_tools/bundle_verify.py +255 -0
- h2h_dev_tools/change_scope.py +167 -0
- h2h_dev_tools/ci_dependencies.py +70 -0
- h2h_dev_tools/dependency_audit.py +220 -0
- h2h_dev_tools/dependency_manifest.py +23 -0
- h2h_dev_tools/distribution_check.py +70 -0
- h2h_dev_tools/documentation.py +175 -0
- h2h_dev_tools/formal.py +209 -0
- h2h_dev_tools/git-flow-merge.sh +263 -0
- h2h_dev_tools/install-git-hooks.sh +34 -0
- h2h_dev_tools/migrate_v1.py +61 -0
- h2h_dev_tools/offline_wheel.py +138 -0
- h2h_dev_tools/primary-branch.sh +52 -0
- h2h_dev_tools/process.py +873 -0
- h2h_dev_tools/publish_version.py +106 -0
- h2h_dev_tools/rebuild-env.sh +80 -0
- h2h_dev_tools/version_policy.py +391 -0
- h2h_dev_tools-0.2.1.dist-info/METADATA +221 -0
- h2h_dev_tools-0.2.1.dist-info/RECORD +26 -0
- h2h_dev_tools-0.2.1.dist-info/WHEEL +4 -0
- h2h_dev_tools-0.2.1.dist-info/entry_points.txt +2 -0
- h2h_dev_tools-0.2.1.dist-info/licenses/LICENSE +674 -0
h2h_dev_tools/process.py
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
"""Own commands through POSIX sessions or start-gated Windows Job Objects.
|
|
2
|
+
|
|
3
|
+
A successful command requires an empty owned tree, not merely an exited leader.
|
|
4
|
+
Callers own their command selection, absolute deadline and cleanup reserve.
|
|
5
|
+
Detached POSIX sessions and daemon-owned resources are outside this contract.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ctypes
|
|
11
|
+
import math
|
|
12
|
+
import ntpath
|
|
13
|
+
import os
|
|
14
|
+
import shlex
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
20
|
+
from contextlib import contextmanager, nullcontext
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from types import FrameType
|
|
24
|
+
from typing import Any, BinaryIO, Final, cast
|
|
25
|
+
|
|
26
|
+
TERMINATION_GRACE_SECONDS = 2.0
|
|
27
|
+
TERMINATION_POLL_SECONDS = 0.05
|
|
28
|
+
TIMEOUT_EXIT_CODE = 124
|
|
29
|
+
TERMINATION_FAILED_EXIT_CODE = 125
|
|
30
|
+
INTERRUPTED_EXIT_CODE = 130
|
|
31
|
+
_WINDOWS_SUPERVISOR_MODE: Final = "--internal-windows-supervisor"
|
|
32
|
+
_WINDOWS_START_TOKEN: Final = b"\x01"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ProcessCommand:
|
|
37
|
+
"""Explicit command and execution context; no repository discovery."""
|
|
38
|
+
|
|
39
|
+
label: str
|
|
40
|
+
argv: tuple[str, ...]
|
|
41
|
+
cwd: Path
|
|
42
|
+
environment: dict[str, str] | None = None
|
|
43
|
+
stdout: BinaryIO | int | None = None
|
|
44
|
+
stderr: BinaryIO | int | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RunnerSignalInterrupt(BaseException):
|
|
48
|
+
"""A POSIX termination signal converted into controlled runner shutdown."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, signal_number: int) -> None:
|
|
51
|
+
super().__init__(signal_number)
|
|
52
|
+
self.signal_number = signal_number
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ProcessOwnershipError(RuntimeError):
|
|
56
|
+
"""The runner could not verify that its managed command process tree exited."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class SignalController:
|
|
61
|
+
"""Defer asynchronous interruption only across ownership-critical regions."""
|
|
62
|
+
|
|
63
|
+
pending_signal: int | None = None
|
|
64
|
+
defer_depth: int = 0
|
|
65
|
+
interruption_started: bool = False
|
|
66
|
+
|
|
67
|
+
def receive(self, signal_number: int) -> None:
|
|
68
|
+
if self.interruption_started:
|
|
69
|
+
return
|
|
70
|
+
if self.defer_depth:
|
|
71
|
+
if self.pending_signal is None:
|
|
72
|
+
self.pending_signal = signal_number
|
|
73
|
+
return
|
|
74
|
+
self._raise(signal_number)
|
|
75
|
+
|
|
76
|
+
@contextmanager
|
|
77
|
+
def defer(self) -> Iterator[None]:
|
|
78
|
+
self.defer_depth += 1
|
|
79
|
+
body_failed = True
|
|
80
|
+
try:
|
|
81
|
+
yield
|
|
82
|
+
body_failed = False
|
|
83
|
+
finally:
|
|
84
|
+
self.defer_depth -= 1
|
|
85
|
+
if self.defer_depth == 0 and not body_failed:
|
|
86
|
+
self.raise_pending()
|
|
87
|
+
|
|
88
|
+
def raise_pending(self) -> None:
|
|
89
|
+
if self.pending_signal is None:
|
|
90
|
+
return
|
|
91
|
+
pending = self.pending_signal
|
|
92
|
+
self.pending_signal = None
|
|
93
|
+
self._raise(pending)
|
|
94
|
+
|
|
95
|
+
def _raise(self, signal_number: int) -> None:
|
|
96
|
+
self.interruption_started = True
|
|
97
|
+
if signal_number == signal.SIGINT:
|
|
98
|
+
raise KeyboardInterrupt
|
|
99
|
+
raise RunnerSignalInterrupt(signal_number)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@contextmanager
|
|
103
|
+
def controlled_termination_signals(
|
|
104
|
+
*, posix_signals: tuple[int, ...] | None = None
|
|
105
|
+
) -> Iterator[SignalController]:
|
|
106
|
+
"""Control platform termination signals and restore prior handlers."""
|
|
107
|
+
|
|
108
|
+
signal_numbers: tuple[int, ...]
|
|
109
|
+
if os.name == "nt":
|
|
110
|
+
break_signal = cast(int | None, getattr(signal, "SIGBREAK", None))
|
|
111
|
+
if break_signal is None:
|
|
112
|
+
raise RuntimeError("Windows requires SIGBREAK support")
|
|
113
|
+
signal_numbers = (signal.SIGINT, break_signal)
|
|
114
|
+
else:
|
|
115
|
+
signal_numbers = (
|
|
116
|
+
(signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
|
|
117
|
+
if posix_signals is None
|
|
118
|
+
else posix_signals
|
|
119
|
+
)
|
|
120
|
+
previous_handlers = [
|
|
121
|
+
(signal_number, signal.getsignal(signal_number))
|
|
122
|
+
for signal_number in signal_numbers
|
|
123
|
+
]
|
|
124
|
+
controller = SignalController()
|
|
125
|
+
|
|
126
|
+
def interrupt(signal_number: int, frame: FrameType | None) -> None:
|
|
127
|
+
del frame
|
|
128
|
+
controller.receive(signal_number)
|
|
129
|
+
|
|
130
|
+
installed: list[int] = []
|
|
131
|
+
try:
|
|
132
|
+
for signal_number, _previous_handler in previous_handlers:
|
|
133
|
+
signal.signal(signal_number, interrupt)
|
|
134
|
+
installed.append(signal_number)
|
|
135
|
+
yield controller
|
|
136
|
+
finally:
|
|
137
|
+
previous_by_signal = dict(previous_handlers)
|
|
138
|
+
for signal_number in reversed(installed):
|
|
139
|
+
signal.signal(signal_number, previous_by_signal[signal_number])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@contextmanager
|
|
143
|
+
def defer_signal_interrupts(
|
|
144
|
+
controller: SignalController | None,
|
|
145
|
+
) -> Iterator[None]:
|
|
146
|
+
if controller is None:
|
|
147
|
+
yield
|
|
148
|
+
return
|
|
149
|
+
with controller.defer():
|
|
150
|
+
yield
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class WindowsJob:
|
|
154
|
+
"""A non-inheritable Windows Job with kill-on-owner-close semantics."""
|
|
155
|
+
|
|
156
|
+
_KILL_ON_JOB_CLOSE: Final = 0x00002000
|
|
157
|
+
_EXTENDED_LIMIT_INFORMATION: Final = 9
|
|
158
|
+
_BASIC_ACCOUNTING_INFORMATION: Final = 1
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
kernel32: Any,
|
|
163
|
+
handle: int,
|
|
164
|
+
accounting_information_type: type[ctypes.Structure],
|
|
165
|
+
) -> None:
|
|
166
|
+
self._kernel32 = kernel32
|
|
167
|
+
self._handle: int | None = handle
|
|
168
|
+
self._accounting_information_type = accounting_information_type
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def create(cls) -> WindowsJob:
|
|
172
|
+
if os.name != "nt":
|
|
173
|
+
raise RuntimeError("Windows Job Objects are unavailable")
|
|
174
|
+
from ctypes import wintypes
|
|
175
|
+
|
|
176
|
+
class _IoCounters(ctypes.Structure):
|
|
177
|
+
_fields_ = [
|
|
178
|
+
("ReadOperationCount", ctypes.c_ulonglong),
|
|
179
|
+
("WriteOperationCount", ctypes.c_ulonglong),
|
|
180
|
+
("OtherOperationCount", ctypes.c_ulonglong),
|
|
181
|
+
("ReadTransferCount", ctypes.c_ulonglong),
|
|
182
|
+
("WriteTransferCount", ctypes.c_ulonglong),
|
|
183
|
+
("OtherTransferCount", ctypes.c_ulonglong),
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
class _BasicLimitInformation(ctypes.Structure):
|
|
187
|
+
_fields_ = [
|
|
188
|
+
("PerProcessUserTimeLimit", ctypes.c_longlong),
|
|
189
|
+
("PerJobUserTimeLimit", ctypes.c_longlong),
|
|
190
|
+
("LimitFlags", wintypes.DWORD),
|
|
191
|
+
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
192
|
+
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
193
|
+
("ActiveProcessLimit", wintypes.DWORD),
|
|
194
|
+
("Affinity", ctypes.c_size_t),
|
|
195
|
+
("PriorityClass", wintypes.DWORD),
|
|
196
|
+
("SchedulingClass", wintypes.DWORD),
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
class _ExtendedLimitInformation(ctypes.Structure):
|
|
200
|
+
_fields_ = [
|
|
201
|
+
("BasicLimitInformation", _BasicLimitInformation),
|
|
202
|
+
("IoInfo", _IoCounters),
|
|
203
|
+
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
204
|
+
("JobMemoryLimit", ctypes.c_size_t),
|
|
205
|
+
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
206
|
+
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
class _BasicAccountingInformation(ctypes.Structure):
|
|
210
|
+
_fields_ = [
|
|
211
|
+
("TotalUserTime", ctypes.c_longlong),
|
|
212
|
+
("TotalKernelTime", ctypes.c_longlong),
|
|
213
|
+
("ThisPeriodTotalUserTime", ctypes.c_longlong),
|
|
214
|
+
("ThisPeriodTotalKernelTime", ctypes.c_longlong),
|
|
215
|
+
("TotalPageFaultCount", wintypes.DWORD),
|
|
216
|
+
("TotalProcesses", wintypes.DWORD),
|
|
217
|
+
("ActiveProcesses", wintypes.DWORD),
|
|
218
|
+
("TotalTerminatedProcesses", wintypes.DWORD),
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
win_dll = getattr(ctypes, "WinDLL", None)
|
|
222
|
+
if not callable(win_dll):
|
|
223
|
+
raise RuntimeError("Windows Job Object APIs are unavailable")
|
|
224
|
+
kernel32 = win_dll("kernel32", use_last_error=True)
|
|
225
|
+
kernel32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
|
|
226
|
+
kernel32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
227
|
+
kernel32.SetInformationJobObject.argtypes = [
|
|
228
|
+
wintypes.HANDLE,
|
|
229
|
+
ctypes.c_int,
|
|
230
|
+
ctypes.c_void_p,
|
|
231
|
+
wintypes.DWORD,
|
|
232
|
+
]
|
|
233
|
+
kernel32.SetInformationJobObject.restype = wintypes.BOOL
|
|
234
|
+
kernel32.AssignProcessToJobObject.argtypes = [
|
|
235
|
+
wintypes.HANDLE,
|
|
236
|
+
wintypes.HANDLE,
|
|
237
|
+
]
|
|
238
|
+
kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
|
|
239
|
+
kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT]
|
|
240
|
+
kernel32.TerminateJobObject.restype = wintypes.BOOL
|
|
241
|
+
kernel32.QueryInformationJobObject.argtypes = [
|
|
242
|
+
wintypes.HANDLE,
|
|
243
|
+
ctypes.c_int,
|
|
244
|
+
ctypes.c_void_p,
|
|
245
|
+
wintypes.DWORD,
|
|
246
|
+
ctypes.c_void_p,
|
|
247
|
+
]
|
|
248
|
+
kernel32.QueryInformationJobObject.restype = wintypes.BOOL
|
|
249
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
250
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
251
|
+
|
|
252
|
+
raw_handle = kernel32.CreateJobObjectW(None, None)
|
|
253
|
+
if not raw_handle:
|
|
254
|
+
raise cls._last_error("CreateJobObjectW")
|
|
255
|
+
handle = int(raw_handle)
|
|
256
|
+
information = _ExtendedLimitInformation()
|
|
257
|
+
information.BasicLimitInformation.LimitFlags = cls._KILL_ON_JOB_CLOSE
|
|
258
|
+
if not kernel32.SetInformationJobObject(
|
|
259
|
+
handle,
|
|
260
|
+
cls._EXTENDED_LIMIT_INFORMATION,
|
|
261
|
+
ctypes.byref(information),
|
|
262
|
+
ctypes.sizeof(information),
|
|
263
|
+
):
|
|
264
|
+
error = cls._last_error("SetInformationJobObject")
|
|
265
|
+
kernel32.CloseHandle(handle)
|
|
266
|
+
raise error
|
|
267
|
+
return cls(kernel32, handle, _BasicAccountingInformation)
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def _last_error(operation: str) -> OSError:
|
|
271
|
+
get_last_error = cast(Any, getattr(ctypes, "get_last_error", lambda: 0))
|
|
272
|
+
code = int(get_last_error())
|
|
273
|
+
return OSError(code, f"{operation} failed with Windows error {code}")
|
|
274
|
+
|
|
275
|
+
def assign(self, process: subprocess.Popen[bytes]) -> None:
|
|
276
|
+
process_handle = getattr(process, "_handle", None)
|
|
277
|
+
if process_handle is None:
|
|
278
|
+
raise RuntimeError("Python did not expose the child process handle")
|
|
279
|
+
if self._handle is None:
|
|
280
|
+
raise RuntimeError("Windows Job Object is already closed")
|
|
281
|
+
if not self._kernel32.AssignProcessToJobObject(
|
|
282
|
+
self._handle,
|
|
283
|
+
int(process_handle),
|
|
284
|
+
):
|
|
285
|
+
raise self._last_error("AssignProcessToJobObject")
|
|
286
|
+
|
|
287
|
+
def terminate(self) -> None:
|
|
288
|
+
if self._handle is None:
|
|
289
|
+
return
|
|
290
|
+
if not self._kernel32.TerminateJobObject(self._handle, 1):
|
|
291
|
+
raise self._last_error("TerminateJobObject")
|
|
292
|
+
|
|
293
|
+
def active_processes(self) -> int:
|
|
294
|
+
if self._handle is None:
|
|
295
|
+
return 0
|
|
296
|
+
information = self._accounting_information_type()
|
|
297
|
+
if not self._kernel32.QueryInformationJobObject(
|
|
298
|
+
self._handle,
|
|
299
|
+
self._BASIC_ACCOUNTING_INFORMATION,
|
|
300
|
+
ctypes.byref(information),
|
|
301
|
+
ctypes.sizeof(information),
|
|
302
|
+
None,
|
|
303
|
+
):
|
|
304
|
+
raise self._last_error("QueryInformationJobObject")
|
|
305
|
+
return int(information.ActiveProcesses)
|
|
306
|
+
|
|
307
|
+
def wait_empty(self, *, deadline: float) -> bool:
|
|
308
|
+
while self.active_processes() != 0:
|
|
309
|
+
remaining_seconds = seconds_remaining(deadline)
|
|
310
|
+
if remaining_seconds == 0:
|
|
311
|
+
return False
|
|
312
|
+
time.sleep(min(TERMINATION_POLL_SECONDS, remaining_seconds))
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
def close(self) -> None:
|
|
316
|
+
handle = self._handle
|
|
317
|
+
if handle is None:
|
|
318
|
+
return
|
|
319
|
+
if not self._kernel32.CloseHandle(handle):
|
|
320
|
+
raise self._last_error("CloseHandle")
|
|
321
|
+
self._handle = None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@dataclass(frozen=True)
|
|
325
|
+
class OwnedProcess:
|
|
326
|
+
process: subprocess.Popen[bytes]
|
|
327
|
+
windows_job: WindowsJob | None = None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _start_posix_process(phase: ProcessCommand) -> subprocess.Popen[bytes]:
|
|
331
|
+
command = phase.argv
|
|
332
|
+
return subprocess.Popen(
|
|
333
|
+
command,
|
|
334
|
+
cwd=phase.cwd,
|
|
335
|
+
env=phase.environment,
|
|
336
|
+
stdout=phase.stdout,
|
|
337
|
+
stderr=phase.stderr,
|
|
338
|
+
start_new_session=True,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _windows_supervisor_launch(phase: ProcessCommand) -> tuple[str, dict[str, str]]:
|
|
343
|
+
executable = sys.executable
|
|
344
|
+
base_executable = getattr(sys, "_base_executable", None)
|
|
345
|
+
if not isinstance(base_executable, str) or not base_executable:
|
|
346
|
+
raise RuntimeError("Windows Python did not expose its base executable")
|
|
347
|
+
environment = dict(os.environ if phase.environment is None else phase.environment)
|
|
348
|
+
environment.pop("__PYVENV_LAUNCHER__", None)
|
|
349
|
+
if ntpath.normcase(ntpath.normpath(base_executable)) != ntpath.normcase(
|
|
350
|
+
ntpath.normpath(executable)
|
|
351
|
+
):
|
|
352
|
+
if not Path(base_executable).is_file():
|
|
353
|
+
raise RuntimeError("Windows Python base executable is unavailable")
|
|
354
|
+
executable = base_executable
|
|
355
|
+
return executable, environment
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _cleanup_windows_supervisor(
|
|
359
|
+
process: subprocess.Popen[bytes],
|
|
360
|
+
job: WindowsJob,
|
|
361
|
+
*,
|
|
362
|
+
assigned: bool,
|
|
363
|
+
deadline: float,
|
|
364
|
+
) -> bool:
|
|
365
|
+
cleanup_succeeded = True
|
|
366
|
+
try:
|
|
367
|
+
if process.stdin is not None:
|
|
368
|
+
try:
|
|
369
|
+
process.stdin.close()
|
|
370
|
+
except OSError:
|
|
371
|
+
cleanup_succeeded = False
|
|
372
|
+
if assigned:
|
|
373
|
+
try:
|
|
374
|
+
job.terminate()
|
|
375
|
+
except OSError:
|
|
376
|
+
cleanup_succeeded = False
|
|
377
|
+
try:
|
|
378
|
+
cleanup_succeeded = (
|
|
379
|
+
job.wait_empty(deadline=deadline) and cleanup_succeeded
|
|
380
|
+
)
|
|
381
|
+
except OSError:
|
|
382
|
+
cleanup_succeeded = False
|
|
383
|
+
elif process.poll() is None:
|
|
384
|
+
try:
|
|
385
|
+
process.kill()
|
|
386
|
+
except OSError:
|
|
387
|
+
cleanup_succeeded = False
|
|
388
|
+
try:
|
|
389
|
+
process.wait(timeout=seconds_remaining(deadline))
|
|
390
|
+
except OSError, subprocess.TimeoutExpired:
|
|
391
|
+
cleanup_succeeded = False
|
|
392
|
+
finally:
|
|
393
|
+
# Startup can fail before an OwnedProcess is returned. Its local Job
|
|
394
|
+
# still needs the kill-on-close backstop if waiting or probing raises.
|
|
395
|
+
try:
|
|
396
|
+
job.close()
|
|
397
|
+
except OSError:
|
|
398
|
+
cleanup_succeeded = False
|
|
399
|
+
return cleanup_succeeded and process.poll() is not None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _start_windows_process(
|
|
403
|
+
phase: ProcessCommand,
|
|
404
|
+
*,
|
|
405
|
+
deadline: float,
|
|
406
|
+
signal_controller: SignalController | None,
|
|
407
|
+
) -> OwnedProcess:
|
|
408
|
+
command = phase.argv
|
|
409
|
+
creation_flag = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
410
|
+
if not isinstance(creation_flag, int) or creation_flag == 0:
|
|
411
|
+
raise ProcessOwnershipError(phase.label) from None
|
|
412
|
+
job: WindowsJob | None = None
|
|
413
|
+
supervisor: subprocess.Popen[bytes] | None = None
|
|
414
|
+
assigned = False
|
|
415
|
+
cleanup_performed = False
|
|
416
|
+
try:
|
|
417
|
+
with defer_signal_interrupts(signal_controller):
|
|
418
|
+
try:
|
|
419
|
+
job = WindowsJob.create()
|
|
420
|
+
executable, environment = _windows_supervisor_launch(phase)
|
|
421
|
+
supervisor = subprocess.Popen(
|
|
422
|
+
(
|
|
423
|
+
executable,
|
|
424
|
+
str(Path(__file__).resolve()),
|
|
425
|
+
_WINDOWS_SUPERVISOR_MODE,
|
|
426
|
+
*command,
|
|
427
|
+
),
|
|
428
|
+
cwd=phase.cwd,
|
|
429
|
+
env=environment,
|
|
430
|
+
stdout=phase.stdout,
|
|
431
|
+
stderr=phase.stderr,
|
|
432
|
+
stdin=subprocess.PIPE,
|
|
433
|
+
creationflags=creation_flag,
|
|
434
|
+
)
|
|
435
|
+
job.assign(supervisor)
|
|
436
|
+
assigned = True
|
|
437
|
+
if signal_controller is not None:
|
|
438
|
+
signal_controller.raise_pending()
|
|
439
|
+
if supervisor.stdin is None:
|
|
440
|
+
raise RuntimeError(
|
|
441
|
+
"Windows command supervisor start gate is unavailable"
|
|
442
|
+
)
|
|
443
|
+
written = supervisor.stdin.write(_WINDOWS_START_TOKEN)
|
|
444
|
+
if written != len(_WINDOWS_START_TOKEN):
|
|
445
|
+
raise BlockingIOError(
|
|
446
|
+
"Windows command supervisor start gate was partial"
|
|
447
|
+
)
|
|
448
|
+
supervisor.stdin.close()
|
|
449
|
+
except BaseException as startup_error:
|
|
450
|
+
cleaned = True
|
|
451
|
+
if job is None:
|
|
452
|
+
cleaned = supervisor is None
|
|
453
|
+
elif supervisor is None:
|
|
454
|
+
try:
|
|
455
|
+
job.close()
|
|
456
|
+
except OSError:
|
|
457
|
+
cleaned = False
|
|
458
|
+
else:
|
|
459
|
+
cleanup_performed = True
|
|
460
|
+
cleaned = _cleanup_windows_supervisor(
|
|
461
|
+
supervisor,
|
|
462
|
+
job,
|
|
463
|
+
assigned=assigned,
|
|
464
|
+
deadline=deadline,
|
|
465
|
+
)
|
|
466
|
+
if not cleaned:
|
|
467
|
+
raise ProcessOwnershipError(phase.label) from startup_error
|
|
468
|
+
if signal_controller is not None:
|
|
469
|
+
signal_controller.raise_pending()
|
|
470
|
+
if isinstance(
|
|
471
|
+
startup_error,
|
|
472
|
+
(KeyboardInterrupt, RunnerSignalInterrupt),
|
|
473
|
+
):
|
|
474
|
+
raise
|
|
475
|
+
raise ProcessOwnershipError(phase.label) from startup_error
|
|
476
|
+
return OwnedProcess(supervisor, job)
|
|
477
|
+
except (KeyboardInterrupt, RunnerSignalInterrupt) as startup_error:
|
|
478
|
+
if job is not None and supervisor is not None and not cleanup_performed:
|
|
479
|
+
cleaned = _cleanup_windows_supervisor(
|
|
480
|
+
supervisor,
|
|
481
|
+
job,
|
|
482
|
+
assigned=assigned,
|
|
483
|
+
deadline=deadline,
|
|
484
|
+
)
|
|
485
|
+
if not cleaned:
|
|
486
|
+
raise ProcessOwnershipError(phase.label) from startup_error
|
|
487
|
+
raise
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def start_owned_process(
|
|
491
|
+
phase: ProcessCommand,
|
|
492
|
+
*,
|
|
493
|
+
deadline: float,
|
|
494
|
+
signal_controller: SignalController | None,
|
|
495
|
+
) -> OwnedProcess:
|
|
496
|
+
if os.name == "nt":
|
|
497
|
+
return _start_windows_process(
|
|
498
|
+
phase,
|
|
499
|
+
deadline=deadline,
|
|
500
|
+
signal_controller=signal_controller,
|
|
501
|
+
)
|
|
502
|
+
try:
|
|
503
|
+
return OwnedProcess(_start_posix_process(phase))
|
|
504
|
+
except OSError as error:
|
|
505
|
+
raise ProcessOwnershipError(phase.label) from error
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def seconds_remaining(deadline: float) -> float:
|
|
509
|
+
return max(0.0, deadline - time.monotonic())
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _wait_for_process_until(
|
|
513
|
+
process: subprocess.Popen[bytes], *, deadline: float
|
|
514
|
+
) -> bool:
|
|
515
|
+
remaining_seconds = seconds_remaining(deadline)
|
|
516
|
+
if remaining_seconds == 0:
|
|
517
|
+
return process.poll() is not None
|
|
518
|
+
try:
|
|
519
|
+
process.wait(timeout=remaining_seconds)
|
|
520
|
+
except subprocess.TimeoutExpired:
|
|
521
|
+
return False
|
|
522
|
+
return True
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _wait_for_posix_process_group_exit(
|
|
526
|
+
process: subprocess.Popen[bytes],
|
|
527
|
+
*,
|
|
528
|
+
kill_process_group: Callable[[int, int], None],
|
|
529
|
+
deadline: float,
|
|
530
|
+
) -> bool:
|
|
531
|
+
while True:
|
|
532
|
+
# poll() reaps an exited leader; group membership, rather than leader
|
|
533
|
+
# state, remains the authority for whether descendants survived.
|
|
534
|
+
process.poll()
|
|
535
|
+
try:
|
|
536
|
+
kill_process_group(process.pid, 0)
|
|
537
|
+
except ProcessLookupError:
|
|
538
|
+
return _wait_for_process_until(process, deadline=deadline)
|
|
539
|
+
except PermissionError:
|
|
540
|
+
pass
|
|
541
|
+
remaining_seconds = seconds_remaining(deadline)
|
|
542
|
+
if remaining_seconds <= 0:
|
|
543
|
+
return False
|
|
544
|
+
time.sleep(min(TERMINATION_POLL_SECONDS, remaining_seconds))
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _terminate_posix_process_group(
|
|
548
|
+
process: subprocess.Popen[bytes], *, deadline: float
|
|
549
|
+
) -> bool:
|
|
550
|
+
kill_process_group = cast(
|
|
551
|
+
Callable[[int, int], None] | None,
|
|
552
|
+
getattr(os, "killpg", None),
|
|
553
|
+
)
|
|
554
|
+
if kill_process_group is None:
|
|
555
|
+
return False
|
|
556
|
+
|
|
557
|
+
try:
|
|
558
|
+
kill_process_group(process.pid, signal.SIGTERM)
|
|
559
|
+
except ProcessLookupError:
|
|
560
|
+
return _wait_for_process_until(process, deadline=deadline)
|
|
561
|
+
except OSError:
|
|
562
|
+
return False
|
|
563
|
+
now = time.monotonic()
|
|
564
|
+
graceful_deadline = min(
|
|
565
|
+
deadline,
|
|
566
|
+
now + min(TERMINATION_GRACE_SECONDS, max(0.0, deadline - now) / 2),
|
|
567
|
+
)
|
|
568
|
+
if _wait_for_posix_process_group_exit(
|
|
569
|
+
process,
|
|
570
|
+
kill_process_group=kill_process_group,
|
|
571
|
+
deadline=graceful_deadline,
|
|
572
|
+
):
|
|
573
|
+
return True
|
|
574
|
+
try:
|
|
575
|
+
kill_process_group(process.pid, signal.SIGKILL)
|
|
576
|
+
except ProcessLookupError:
|
|
577
|
+
return _wait_for_process_until(process, deadline=deadline)
|
|
578
|
+
except OSError:
|
|
579
|
+
return False
|
|
580
|
+
return _wait_for_posix_process_group_exit(
|
|
581
|
+
process,
|
|
582
|
+
kill_process_group=kill_process_group,
|
|
583
|
+
deadline=deadline,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def wait_for_owned_tree_exit(
|
|
588
|
+
owner: OwnedProcess,
|
|
589
|
+
*,
|
|
590
|
+
deadline: float,
|
|
591
|
+
) -> bool:
|
|
592
|
+
if owner.windows_job is not None:
|
|
593
|
+
try:
|
|
594
|
+
return owner.windows_job.wait_empty(
|
|
595
|
+
deadline=deadline
|
|
596
|
+
) and _wait_for_process_until(
|
|
597
|
+
owner.process,
|
|
598
|
+
deadline=deadline,
|
|
599
|
+
)
|
|
600
|
+
except OSError:
|
|
601
|
+
return False
|
|
602
|
+
kill_process_group = cast(
|
|
603
|
+
Callable[[int, int], None] | None,
|
|
604
|
+
getattr(os, "killpg", None),
|
|
605
|
+
)
|
|
606
|
+
if kill_process_group is None:
|
|
607
|
+
return False
|
|
608
|
+
return _wait_for_posix_process_group_exit(
|
|
609
|
+
owner.process,
|
|
610
|
+
kill_process_group=kill_process_group,
|
|
611
|
+
deadline=deadline,
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _report_diagnostic(message: str) -> None:
|
|
616
|
+
"""Keep a failed diagnostic stream from interrupting ownership cleanup."""
|
|
617
|
+
|
|
618
|
+
try:
|
|
619
|
+
print(message, file=sys.stderr, flush=True)
|
|
620
|
+
except Exception:
|
|
621
|
+
# Diagnostics are secondary to process ownership. Even an unusual stream
|
|
622
|
+
# implementation must not prevent termination, fallback or Job closing.
|
|
623
|
+
pass
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _taskkill_windows_tree(
|
|
627
|
+
process: subprocess.Popen[bytes], *, deadline: float
|
|
628
|
+
) -> bool:
|
|
629
|
+
remaining_seconds = seconds_remaining(deadline)
|
|
630
|
+
if remaining_seconds == 0:
|
|
631
|
+
return False
|
|
632
|
+
try:
|
|
633
|
+
completed = subprocess.run(
|
|
634
|
+
("taskkill", "/PID", str(process.pid), "/T", "/F"),
|
|
635
|
+
check=False,
|
|
636
|
+
capture_output=True,
|
|
637
|
+
text=True,
|
|
638
|
+
timeout=min(TERMINATION_GRACE_SECONDS, remaining_seconds),
|
|
639
|
+
)
|
|
640
|
+
except OSError, subprocess.TimeoutExpired:
|
|
641
|
+
return False
|
|
642
|
+
if completed.returncode != 0:
|
|
643
|
+
detail = completed.stderr.strip() or completed.stdout.strip()
|
|
644
|
+
_report_diagnostic(
|
|
645
|
+
f"taskkill failed with exit {completed.returncode}"
|
|
646
|
+
+ (f": {detail}" if detail else "")
|
|
647
|
+
)
|
|
648
|
+
return False
|
|
649
|
+
return True
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _terminate_windows_tree(
|
|
653
|
+
owner: OwnedProcess,
|
|
654
|
+
*,
|
|
655
|
+
deadline: float,
|
|
656
|
+
) -> bool:
|
|
657
|
+
job = owner.windows_job
|
|
658
|
+
if job is None or seconds_remaining(deadline) == 0:
|
|
659
|
+
return False
|
|
660
|
+
try:
|
|
661
|
+
job.terminate()
|
|
662
|
+
except OSError as error:
|
|
663
|
+
_report_diagnostic(str(error))
|
|
664
|
+
if not _taskkill_windows_tree(owner.process, deadline=deadline):
|
|
665
|
+
return False
|
|
666
|
+
return wait_for_owned_tree_exit(owner, deadline=deadline)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def terminate_owned_tree(
|
|
670
|
+
owner: OwnedProcess,
|
|
671
|
+
*,
|
|
672
|
+
deadline: float,
|
|
673
|
+
) -> bool:
|
|
674
|
+
"""Terminate and verify the managed command process tree before deadline."""
|
|
675
|
+
|
|
676
|
+
if owner.windows_job is not None:
|
|
677
|
+
return _terminate_windows_tree(owner, deadline=deadline)
|
|
678
|
+
return _terminate_posix_process_group(owner.process, deadline=deadline)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def close_owner(owner: OwnedProcess) -> bool:
|
|
682
|
+
job = owner.windows_job
|
|
683
|
+
if job is None:
|
|
684
|
+
return True
|
|
685
|
+
try:
|
|
686
|
+
job.close()
|
|
687
|
+
except OSError as error:
|
|
688
|
+
_report_diagnostic(str(error))
|
|
689
|
+
return False
|
|
690
|
+
return True
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def run_command(
|
|
694
|
+
phase: ProcessCommand,
|
|
695
|
+
*,
|
|
696
|
+
hard_deadline: float | None,
|
|
697
|
+
termination_reserve_seconds: float = 6.0,
|
|
698
|
+
signal_controller: SignalController | None = None,
|
|
699
|
+
) -> int:
|
|
700
|
+
"""Run and close an owned command, controlling signals by default.
|
|
701
|
+
|
|
702
|
+
Automatic signal handling requires the main thread. Callers that already
|
|
703
|
+
manage process-wide handlers may pass their existing signal controller.
|
|
704
|
+
"""
|
|
705
|
+
|
|
706
|
+
controller_context = (
|
|
707
|
+
controlled_termination_signals()
|
|
708
|
+
if signal_controller is None
|
|
709
|
+
else nullcontext(signal_controller)
|
|
710
|
+
)
|
|
711
|
+
with controller_context as controller:
|
|
712
|
+
return _run_command(
|
|
713
|
+
phase,
|
|
714
|
+
hard_deadline=hard_deadline,
|
|
715
|
+
termination_reserve_seconds=termination_reserve_seconds,
|
|
716
|
+
signal_controller=controller,
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _run_command(
|
|
721
|
+
phase: ProcessCommand,
|
|
722
|
+
*,
|
|
723
|
+
hard_deadline: float | None,
|
|
724
|
+
termination_reserve_seconds: float,
|
|
725
|
+
signal_controller: SignalController,
|
|
726
|
+
) -> int:
|
|
727
|
+
if (
|
|
728
|
+
not math.isfinite(termination_reserve_seconds)
|
|
729
|
+
or termination_reserve_seconds <= 0
|
|
730
|
+
or (hard_deadline is not None and not math.isfinite(hard_deadline))
|
|
731
|
+
):
|
|
732
|
+
raise ValueError(
|
|
733
|
+
"Deadline must be finite and cleanup reserve finite and positive"
|
|
734
|
+
)
|
|
735
|
+
started_at = time.monotonic()
|
|
736
|
+
budget_detail = (
|
|
737
|
+
"unbounded"
|
|
738
|
+
if hard_deadline is None
|
|
739
|
+
else f"{max(0.0, hard_deadline - started_at):.1f}s total remaining"
|
|
740
|
+
)
|
|
741
|
+
print(f"\n==> {phase.label} ({budget_detail})", flush=True)
|
|
742
|
+
print("+", shlex.join(phase.argv), flush=True)
|
|
743
|
+
execution_deadline = (
|
|
744
|
+
None if hard_deadline is None else hard_deadline - termination_reserve_seconds
|
|
745
|
+
)
|
|
746
|
+
if execution_deadline is not None and execution_deadline <= started_at:
|
|
747
|
+
_report_diagnostic(
|
|
748
|
+
f"command budget lacks the {termination_reserve_seconds:.1f}s "
|
|
749
|
+
f"termination reserve before {phase.label}"
|
|
750
|
+
)
|
|
751
|
+
return TIMEOUT_EXIT_CODE
|
|
752
|
+
ownership_deadline = (
|
|
753
|
+
hard_deadline
|
|
754
|
+
if hard_deadline is not None
|
|
755
|
+
else started_at + termination_reserve_seconds
|
|
756
|
+
)
|
|
757
|
+
owner: OwnedProcess | None = None
|
|
758
|
+
tree_empty_proven = False
|
|
759
|
+
termination_deadline: float | None = None
|
|
760
|
+
return_code = TERMINATION_FAILED_EXIT_CODE
|
|
761
|
+
try:
|
|
762
|
+
# Do not deliver a controlled platform signal after Popen creates the
|
|
763
|
+
# child but before this frame owns its process tree. The handler records
|
|
764
|
+
# a pending signal and delivers it after ownership is established.
|
|
765
|
+
with signal_controller.defer():
|
|
766
|
+
owner = start_owned_process(
|
|
767
|
+
phase,
|
|
768
|
+
deadline=ownership_deadline,
|
|
769
|
+
signal_controller=signal_controller,
|
|
770
|
+
)
|
|
771
|
+
timeout_seconds = (
|
|
772
|
+
None
|
|
773
|
+
if execution_deadline is None
|
|
774
|
+
else max(0.0, execution_deadline - time.monotonic())
|
|
775
|
+
)
|
|
776
|
+
try:
|
|
777
|
+
return_code = owner.process.wait(timeout=timeout_seconds)
|
|
778
|
+
except subprocess.TimeoutExpired:
|
|
779
|
+
termination_deadline = hard_deadline
|
|
780
|
+
return_code = TIMEOUT_EXIT_CODE
|
|
781
|
+
_report_diagnostic(
|
|
782
|
+
f"command budget expired during {phase.label}; "
|
|
783
|
+
"terminating its process tree"
|
|
784
|
+
)
|
|
785
|
+
else:
|
|
786
|
+
cleanup_started = time.monotonic()
|
|
787
|
+
termination_deadline = (
|
|
788
|
+
cleanup_started + termination_reserve_seconds
|
|
789
|
+
if hard_deadline is None
|
|
790
|
+
else hard_deadline
|
|
791
|
+
)
|
|
792
|
+
clean_exit_deadline = min(
|
|
793
|
+
termination_deadline,
|
|
794
|
+
cleanup_started + TERMINATION_POLL_SECONDS * 5,
|
|
795
|
+
)
|
|
796
|
+
with signal_controller.defer():
|
|
797
|
+
tree_empty_proven = wait_for_owned_tree_exit(
|
|
798
|
+
owner, deadline=clean_exit_deadline
|
|
799
|
+
)
|
|
800
|
+
if not tree_empty_proven:
|
|
801
|
+
_report_diagnostic(
|
|
802
|
+
"command leader exited with surviving process-tree members "
|
|
803
|
+
f"during {phase.label}"
|
|
804
|
+
)
|
|
805
|
+
raise ProcessOwnershipError(phase.label)
|
|
806
|
+
except KeyboardInterrupt, RunnerSignalInterrupt:
|
|
807
|
+
_report_diagnostic(
|
|
808
|
+
f"command interrupted during {phase.label}; terminating its process tree"
|
|
809
|
+
)
|
|
810
|
+
raise
|
|
811
|
+
finally:
|
|
812
|
+
if owner is not None:
|
|
813
|
+
# Every exit, including wait or diagnostic failure, passes through
|
|
814
|
+
# bounded teardown. Defer signals through Job closure as well, and
|
|
815
|
+
# never let failed termination skip the kill-on-close backstop.
|
|
816
|
+
with signal_controller.defer():
|
|
817
|
+
try:
|
|
818
|
+
if not tree_empty_proven:
|
|
819
|
+
if termination_deadline is None:
|
|
820
|
+
termination_deadline = (
|
|
821
|
+
time.monotonic() + termination_reserve_seconds
|
|
822
|
+
)
|
|
823
|
+
if hard_deadline is not None:
|
|
824
|
+
termination_deadline = min(
|
|
825
|
+
hard_deadline, termination_deadline
|
|
826
|
+
)
|
|
827
|
+
try:
|
|
828
|
+
tree_empty_proven = terminate_owned_tree(
|
|
829
|
+
owner, deadline=termination_deadline
|
|
830
|
+
)
|
|
831
|
+
except Exception as error:
|
|
832
|
+
raise ProcessOwnershipError(phase.label) from error
|
|
833
|
+
if not tree_empty_proven:
|
|
834
|
+
_report_diagnostic(
|
|
835
|
+
"could not verify process-tree termination for "
|
|
836
|
+
f"{phase.label}"
|
|
837
|
+
)
|
|
838
|
+
raise ProcessOwnershipError(phase.label)
|
|
839
|
+
finally:
|
|
840
|
+
if not close_owner(owner):
|
|
841
|
+
raise ProcessOwnershipError(phase.label)
|
|
842
|
+
print(f"owned_tree_empty={tree_empty_proven}", flush=True)
|
|
843
|
+
finished_at = time.monotonic()
|
|
844
|
+
elapsed = finished_at - started_at
|
|
845
|
+
if hard_deadline is not None and finished_at > hard_deadline and return_code == 0:
|
|
846
|
+
return_code = TIMEOUT_EXIT_CODE
|
|
847
|
+
print(f"<== {phase.label}: {elapsed:.1f}s (exit {return_code})", flush=True)
|
|
848
|
+
return return_code
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _windows_supervisor_main(command: Sequence[str]) -> int:
|
|
852
|
+
"""Wait for Job ownership before starting the requested command phase."""
|
|
853
|
+
|
|
854
|
+
if os.name != "nt" or not command:
|
|
855
|
+
return TERMINATION_FAILED_EXIT_CODE
|
|
856
|
+
try:
|
|
857
|
+
token = sys.stdin.buffer.read(len(_WINDOWS_START_TOKEN))
|
|
858
|
+
except OSError:
|
|
859
|
+
return TERMINATION_FAILED_EXIT_CODE
|
|
860
|
+
if token != _WINDOWS_START_TOKEN:
|
|
861
|
+
return TERMINATION_FAILED_EXIT_CODE
|
|
862
|
+
try:
|
|
863
|
+
process = subprocess.Popen(tuple(command))
|
|
864
|
+
except OSError as error:
|
|
865
|
+
print(f"could not start command: {error}", file=sys.stderr, flush=True)
|
|
866
|
+
return TERMINATION_FAILED_EXIT_CODE
|
|
867
|
+
return process.wait()
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
if __name__ == "__main__":
|
|
871
|
+
if len(sys.argv) < 2 or sys.argv[1] != _WINDOWS_SUPERVISOR_MODE:
|
|
872
|
+
raise SystemExit(TERMINATION_FAILED_EXIT_CODE)
|
|
873
|
+
raise SystemExit(_windows_supervisor_main(sys.argv[2:]))
|