sequential-hooks 0.1.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.
- plugins/agy/_sequential_hooks/__init__.py +1 -0
- plugins/agy/_sequential_hooks/_adapter.py +566 -0
- plugins/agy/_sequential_hooks/_doctor.py +413 -0
- plugins/claude/_sequential_hooks/__init__.py +1 -0
- plugins/claude/_sequential_hooks/_adapter.py +560 -0
- plugins/claude/_sequential_hooks/_doctor.py +455 -0
- plugins/codex/_sequential_hooks/__init__.py +1 -0
- plugins/codex/_sequential_hooks/_adapter.py +466 -0
- plugins/codex/_sequential_hooks/_doctor.py +563 -0
- sequential_hooks/__init__.py +3 -0
- sequential_hooks/__main__.py +5 -0
- sequential_hooks/_arguments.py +222 -0
- sequential_hooks/_cleanup.py +88 -0
- sequential_hooks/_cli.py +440 -0
- sequential_hooks/_containment/__init__.py +410 -0
- sequential_hooks/_containment/_posix.py +236 -0
- sequential_hooks/_containment/_uncontained.py +239 -0
- sequential_hooks/_containment/_windows.py +853 -0
- sequential_hooks/_containment/_windows_api.py +570 -0
- sequential_hooks/_containment/_windows_launcher.py +189 -0
- sequential_hooks/_diagnostics.py +265 -0
- sequential_hooks/_doctor/__init__.py +342 -0
- sequential_hooks/_doctor/_command.py +469 -0
- sequential_hooks/_doctor/_common.py +448 -0
- sequential_hooks/_doctor/_types.py +94 -0
- sequential_hooks/_downstream.py +181 -0
- sequential_hooks/_executable.py +187 -0
- sequential_hooks/_executor.py +825 -0
- sequential_hooks/_registry.py +103 -0
- sequential_hooks/_runner.py +48 -0
- sequential_hooks/_types.py +125 -0
- sequential_hooks/hosts/__init__.py +130 -0
- sequential_hooks/hosts/_contract.py +434 -0
- sequential_hooks/hosts/_inspection.py +78 -0
- sequential_hooks/hosts/_json.py +150 -0
- sequential_hooks/hosts/_records.py +273 -0
- sequential_hooks/hosts/_skeleton.py +1326 -0
- sequential_hooks/py.typed +0 -0
- sequential_hooks-0.1.0.dist-info/METADATA +90 -0
- sequential_hooks-0.1.0.dist-info/RECORD +43 -0
- sequential_hooks-0.1.0.dist-info/WHEEL +4 -0
- sequential_hooks-0.1.0.dist-info/entry_points.txt +7 -0
- sequential_hooks-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
# pyright: reportPrivateUsage=false
|
|
2
|
+
|
|
3
|
+
"""Execute bounded child processes with platform containment."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from sequential_hooks._cleanup import CleanupState
|
|
18
|
+
from sequential_hooks._containment import (
|
|
19
|
+
CompletionFailureProvider,
|
|
20
|
+
ContainmentBackend,
|
|
21
|
+
ContainmentError,
|
|
22
|
+
ContainmentReleaseError,
|
|
23
|
+
ManagedStep,
|
|
24
|
+
WindowsExitCodeProvider,
|
|
25
|
+
current_backend,
|
|
26
|
+
)
|
|
27
|
+
from sequential_hooks._downstream import is_downstream_closure
|
|
28
|
+
from sequential_hooks._executable import ExecutableStatus, resolve_executable
|
|
29
|
+
from sequential_hooks._types import FailureKind, Step, StepFailure, StepResult
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from collections.abc import Callable, Generator
|
|
33
|
+
from contextlib import AbstractContextManager
|
|
34
|
+
from types import FrameType
|
|
35
|
+
from typing import BinaryIO, NoReturn
|
|
36
|
+
|
|
37
|
+
from sequential_hooks._containment import StderrReader
|
|
38
|
+
from sequential_hooks._diagnostics import Diagnostics, StderrRelay
|
|
39
|
+
|
|
40
|
+
type _WorkerStream = BinaryIO | StderrReader
|
|
41
|
+
_CANCELLATION_CLOSE_OBSERVE_SECONDS = 0.1
|
|
42
|
+
_OUTPUT_WORKER_JOIN_SECONDS = 1.0
|
|
43
|
+
_STDOUT_LIMIT_BYTES = 1024 * 1024
|
|
44
|
+
_TERMINATION_GRACE_SECONDS = 1.0
|
|
45
|
+
_WINDOWS_CRASH_CODES = frozenset({0x80000001, 0x80000002, 0x80000003, 0x80000004})
|
|
46
|
+
_WINDOWS_CRASH_RANGE_END = 0xCFFFFFFF
|
|
47
|
+
_WINDOWS_CRASH_RANGE_START = 0xC0000000
|
|
48
|
+
_WINDOWS_RUNTIME_CRASH_RANGE_END = 0xEFFFFFFF
|
|
49
|
+
_WINDOWS_RUNTIME_CRASH_RANGE_START = 0xE0000000
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ForwardedSignal(BaseException):
|
|
53
|
+
"""Report a wrapper signal after its active child has been cleaned up.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
signum: Native signal number received by the wrapper.
|
|
57
|
+
|
|
58
|
+
The executor raises this boundary exception at most once per signal-
|
|
59
|
+
forwarding context and only after active-child cleanup.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, signum: int) -> None:
|
|
63
|
+
"""Retain the native signal forwarded after child cleanup.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
signum: Native signal number received by the wrapper.
|
|
67
|
+
"""
|
|
68
|
+
super().__init__(f'signal {signum} forwarded')
|
|
69
|
+
self.signum = signum
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class _AttemptPipes:
|
|
74
|
+
"""Carry one attempt's parent pipe handles."""
|
|
75
|
+
|
|
76
|
+
_stderr: StderrReader
|
|
77
|
+
_stdin: BinaryIO
|
|
78
|
+
_stdout: BinaryIO
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _Attempt:
|
|
82
|
+
"""Own one managed step's output buffers, relay, and pipe workers."""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
relay: StderrRelay,
|
|
87
|
+
pipes: _AttemptPipes,
|
|
88
|
+
input_bytes: bytes,
|
|
89
|
+
thread_prefix: str,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Create pipe workers for one managed attempt."""
|
|
92
|
+
self._output = bytearray()
|
|
93
|
+
self._output_exceeded = threading.Event()
|
|
94
|
+
self._relay = relay
|
|
95
|
+
self._stderr_worker = _Worker(
|
|
96
|
+
f'{thread_prefix}-stderr',
|
|
97
|
+
pipes._stderr,
|
|
98
|
+
lambda: _read_stderr(pipes._stderr, relay),
|
|
99
|
+
)
|
|
100
|
+
self._stdin_worker = _Worker(
|
|
101
|
+
f'{thread_prefix}-stdin',
|
|
102
|
+
pipes._stdin,
|
|
103
|
+
lambda: _write_stdin(pipes._stdin, input_bytes),
|
|
104
|
+
close_in_worker=True,
|
|
105
|
+
)
|
|
106
|
+
self._stdout_worker = _Worker(
|
|
107
|
+
f'{thread_prefix}-stdout',
|
|
108
|
+
pipes._stdout,
|
|
109
|
+
lambda: _read_stdout(
|
|
110
|
+
pipes._stdout,
|
|
111
|
+
self._output,
|
|
112
|
+
self._output_exceeded,
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
self._workers = (
|
|
116
|
+
self._stdout_worker,
|
|
117
|
+
self._stderr_worker,
|
|
118
|
+
self._stdin_worker,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def _first_worker_error(self) -> Exception | None:
|
|
122
|
+
"""Return the first completed worker failure in lifecycle order."""
|
|
123
|
+
return next(
|
|
124
|
+
(error for worker in self._workers if (error := worker._failure()) is not None),
|
|
125
|
+
None,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _start(self) -> None:
|
|
129
|
+
"""Start readers first, then the stdin writer."""
|
|
130
|
+
self._stdout_worker._start()
|
|
131
|
+
self._stderr_worker._start()
|
|
132
|
+
self._stdin_worker._start()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class _CancellationClose:
|
|
137
|
+
"""Retain one asynchronous pipe-close failure without a traceback."""
|
|
138
|
+
|
|
139
|
+
_completed: threading.Event = field(default_factory=threading.Event)
|
|
140
|
+
_error: Exception | None = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class _Completion:
|
|
145
|
+
"""Carry saved process state through post-completion cleanup."""
|
|
146
|
+
|
|
147
|
+
_returncode: int | None
|
|
148
|
+
_blocking_stderr: str = ''
|
|
149
|
+
_failure_kind: FailureKind | None = None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class _Recovery:
|
|
154
|
+
"""Request another attempt with one fresh managed process."""
|
|
155
|
+
|
|
156
|
+
_managed: ManagedStep
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class _RunRequest:
|
|
161
|
+
"""Carry one immutable executor request through recovery."""
|
|
162
|
+
|
|
163
|
+
_allow_uncontained: bool
|
|
164
|
+
_input_bytes: bytes
|
|
165
|
+
_launch_step: Step
|
|
166
|
+
_step: Step
|
|
167
|
+
_timeout: float | None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class ProcessExecutor:
|
|
171
|
+
"""Execute literal argv with bounded platform containment and pipes.
|
|
172
|
+
|
|
173
|
+
Each `run()` call owns a fresh containment backend, process, pipe-worker
|
|
174
|
+
set, and timeout budget. Use `signal_forwarding()` around a command chain
|
|
175
|
+
when wrapper signals must be forwarded to its active child. The context
|
|
176
|
+
restores prior handlers and propagates the first forwarded signal only
|
|
177
|
+
after cleanup. Unexpected containment, pipe, and cleanup failures propagate
|
|
178
|
+
from `run()` unchanged.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(self, diagnostics: Diagnostics) -> None:
|
|
182
|
+
"""Retain the diagnostic sink for bounded child execution.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
diagnostics: Sink for bounded, labeled child diagnostics.
|
|
186
|
+
"""
|
|
187
|
+
self._diagnostics = diagnostics
|
|
188
|
+
self._forwarded_signal_raised = False
|
|
189
|
+
self._forwarded_signum: int | None = None
|
|
190
|
+
self._signal_lock = threading.RLock()
|
|
191
|
+
|
|
192
|
+
def _build_result(
|
|
193
|
+
self,
|
|
194
|
+
step: Step,
|
|
195
|
+
managed: ManagedStep,
|
|
196
|
+
attempt: _Attempt,
|
|
197
|
+
completion: _Completion,
|
|
198
|
+
) -> StepResult:
|
|
199
|
+
"""Build one bounded result after every cleanup attempt."""
|
|
200
|
+
returncode = completion._returncode
|
|
201
|
+
failure_kind = completion._failure_kind
|
|
202
|
+
failure: StepFailure | None = None
|
|
203
|
+
completion_failure = (
|
|
204
|
+
managed.completion_failure if isinstance(managed, CompletionFailureProvider) else None
|
|
205
|
+
)
|
|
206
|
+
if sys.platform == 'win32':
|
|
207
|
+
uses_windows_exit_codes = True
|
|
208
|
+
else:
|
|
209
|
+
uses_windows_exit_codes = (
|
|
210
|
+
isinstance(managed, WindowsExitCodeProvider) and managed.uses_windows_exit_codes
|
|
211
|
+
)
|
|
212
|
+
if failure_kind is None and completion_failure is not None:
|
|
213
|
+
failure = completion_failure
|
|
214
|
+
elif failure_kind is FailureKind.OUTPUT_LIMIT:
|
|
215
|
+
failure = StepFailure(
|
|
216
|
+
failure_kind,
|
|
217
|
+
f'step {step.index} ({step.name}) exceeded stdout limit',
|
|
218
|
+
returncode,
|
|
219
|
+
)
|
|
220
|
+
elif failure_kind is FailureKind.OUTPUT_WORKER_TIMEOUT:
|
|
221
|
+
failure = StepFailure(
|
|
222
|
+
failure_kind,
|
|
223
|
+
f'step {step.index} ({step.name}) output workers did not stop',
|
|
224
|
+
returncode,
|
|
225
|
+
)
|
|
226
|
+
elif failure_kind is FailureKind.TIMEOUT:
|
|
227
|
+
failure = StepFailure(
|
|
228
|
+
failure_kind,
|
|
229
|
+
f'step {step.index} ({step.name}) timed out',
|
|
230
|
+
returncode,
|
|
231
|
+
)
|
|
232
|
+
elif returncode is not None and uses_windows_exit_codes and _is_windows_crash(returncode):
|
|
233
|
+
failure = StepFailure(
|
|
234
|
+
FailureKind.CRASH,
|
|
235
|
+
f'step {step.index} ({step.name}) crashed with Windows status '
|
|
236
|
+
f'0x{returncode & 0xFFFFFFFF:08X}',
|
|
237
|
+
returncode,
|
|
238
|
+
)
|
|
239
|
+
elif returncode is not None and returncode < 0:
|
|
240
|
+
failure = StepFailure(
|
|
241
|
+
FailureKind.CRASH,
|
|
242
|
+
f'step {step.index} ({step.name}) crashed with signal {-returncode}',
|
|
243
|
+
returncode,
|
|
244
|
+
)
|
|
245
|
+
return StepResult(
|
|
246
|
+
returncode,
|
|
247
|
+
bytes(attempt._output),
|
|
248
|
+
completion._blocking_stderr,
|
|
249
|
+
failure,
|
|
250
|
+
managed.containment,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def _cleanup_attempt(
|
|
254
|
+
self,
|
|
255
|
+
attempt: _Attempt,
|
|
256
|
+
action: Callable[[], None],
|
|
257
|
+
first_error: Exception | None,
|
|
258
|
+
) -> tuple[str, bool, Exception | None]:
|
|
259
|
+
"""Dispose containment, bound joins, and close every parent pipe."""
|
|
260
|
+
cleanup = CleanupState(first_error)
|
|
261
|
+
cleanup.attempt(action)
|
|
262
|
+
|
|
263
|
+
deadline = time.monotonic() + _OUTPUT_WORKER_JOIN_SECONDS
|
|
264
|
+
for worker in attempt._workers:
|
|
265
|
+
worker._join(max(0.0, deadline - time.monotonic()))
|
|
266
|
+
|
|
267
|
+
worker_timed_out = any(worker._is_alive() for worker in attempt._workers)
|
|
268
|
+
for worker in attempt._workers:
|
|
269
|
+
if worker._is_alive():
|
|
270
|
+
cleanup.attempt(worker._cancel_pipe)
|
|
271
|
+
else:
|
|
272
|
+
cleanup.attempt(worker._close_pipe)
|
|
273
|
+
|
|
274
|
+
cancellation_deadline = time.monotonic() + _CANCELLATION_CLOSE_OBSERVE_SECONDS
|
|
275
|
+
for worker in attempt._workers:
|
|
276
|
+
cleanup.record(worker._failure())
|
|
277
|
+
|
|
278
|
+
for worker in attempt._workers:
|
|
279
|
+
worker._observe_cancellation(max(0.0, cancellation_deadline - time.monotonic()))
|
|
280
|
+
for worker in attempt._workers:
|
|
281
|
+
cleanup.record(worker._failure())
|
|
282
|
+
blocking_stderr = ''
|
|
283
|
+
with cleanup:
|
|
284
|
+
blocking_stderr = attempt._relay.close()
|
|
285
|
+
return blocking_stderr, worker_timed_out, cleanup.first_error
|
|
286
|
+
|
|
287
|
+
def _abort_release_exception(
|
|
288
|
+
self,
|
|
289
|
+
managed: ManagedStep,
|
|
290
|
+
attempt: _Attempt,
|
|
291
|
+
release_error: Exception,
|
|
292
|
+
) -> NoReturn:
|
|
293
|
+
"""Abort a failed release, clean every pipe, and re-raise."""
|
|
294
|
+
_, _, error = self._cleanup_attempt(
|
|
295
|
+
attempt,
|
|
296
|
+
managed.abort_before_release,
|
|
297
|
+
release_error,
|
|
298
|
+
)
|
|
299
|
+
if error is not None:
|
|
300
|
+
raise error
|
|
301
|
+
raise RuntimeError('release failed without an exception')
|
|
302
|
+
|
|
303
|
+
def _containment_failure(self, step: Step, error: ContainmentError) -> StepResult:
|
|
304
|
+
"""Map a containment refusal to one bounded step result."""
|
|
305
|
+
return StepResult(
|
|
306
|
+
returncode=None,
|
|
307
|
+
stdout=b'',
|
|
308
|
+
blocking_stderr='',
|
|
309
|
+
failure=StepFailure(
|
|
310
|
+
FailureKind.CONTAINMENT,
|
|
311
|
+
f'step {step.index} ({step.name}) containment failed: {error}',
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def _create_attempt(self, managed: ManagedStep, request: _RunRequest) -> _Attempt:
|
|
316
|
+
"""Create fresh output state and workers for one managed process."""
|
|
317
|
+
acquired_pipes: list[_WorkerStream] = []
|
|
318
|
+
relay: StderrRelay | None = None
|
|
319
|
+
attempt: _Attempt | None = None
|
|
320
|
+
setup = CleanupState()
|
|
321
|
+
with setup:
|
|
322
|
+
stdin = managed.stdin
|
|
323
|
+
acquired_pipes.append(stdin)
|
|
324
|
+
stdout = managed.stdout
|
|
325
|
+
acquired_pipes.append(stdout)
|
|
326
|
+
stderr = managed.stderr
|
|
327
|
+
acquired_pipes.append(stderr)
|
|
328
|
+
relay = self._diagnostics.relay(request._step)
|
|
329
|
+
attempt = _Attempt(
|
|
330
|
+
relay,
|
|
331
|
+
_AttemptPipes(stderr, stdin, stdout),
|
|
332
|
+
request._input_bytes,
|
|
333
|
+
f'sequential-hooks-step-{request._step.index}',
|
|
334
|
+
)
|
|
335
|
+
if attempt is not None:
|
|
336
|
+
return attempt
|
|
337
|
+
|
|
338
|
+
cleanup = CleanupState(setup.first_error)
|
|
339
|
+
cleanup.attempt(managed.abort_before_release)
|
|
340
|
+
for stream in acquired_pipes:
|
|
341
|
+
cleanup.attempt(stream.close)
|
|
342
|
+
if relay is not None:
|
|
343
|
+
cleanup.attempt(relay.close)
|
|
344
|
+
if cleanup.first_error is not None:
|
|
345
|
+
raise cleanup.first_error
|
|
346
|
+
raise RuntimeError('attempt construction failed without an exception')
|
|
347
|
+
|
|
348
|
+
def _pending_signal(self) -> int | None:
|
|
349
|
+
"""Return the first signal received in the forwarding context."""
|
|
350
|
+
with self._signal_lock:
|
|
351
|
+
return self._forwarded_signum
|
|
352
|
+
|
|
353
|
+
def _monitor_step(
|
|
354
|
+
self,
|
|
355
|
+
managed: ManagedStep,
|
|
356
|
+
attempt: _Attempt,
|
|
357
|
+
deadline: float | None,
|
|
358
|
+
) -> tuple[FailureKind | None, Exception | None]:
|
|
359
|
+
"""Observe immediate completion while enforcing every bounded stop."""
|
|
360
|
+
failure_kind: FailureKind | None = None
|
|
361
|
+
worker_error: Exception | None = None
|
|
362
|
+
while True:
|
|
363
|
+
forwarded_signum = self._pending_signal()
|
|
364
|
+
if forwarded_signum is not None:
|
|
365
|
+
managed.forward_signal(forwarded_signum)
|
|
366
|
+
managed.terminate_tree(_TERMINATION_GRACE_SECONDS)
|
|
367
|
+
break
|
|
368
|
+
|
|
369
|
+
if attempt._output_exceeded.is_set():
|
|
370
|
+
failure_kind = FailureKind.OUTPUT_LIMIT
|
|
371
|
+
managed.terminate_tree(_TERMINATION_GRACE_SECONDS)
|
|
372
|
+
break
|
|
373
|
+
|
|
374
|
+
worker_error = attempt._first_worker_error()
|
|
375
|
+
if worker_error is not None:
|
|
376
|
+
managed.terminate_tree(_TERMINATION_GRACE_SECONDS)
|
|
377
|
+
break
|
|
378
|
+
|
|
379
|
+
if managed.process.poll() is not None:
|
|
380
|
+
break
|
|
381
|
+
|
|
382
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
383
|
+
failure_kind = FailureKind.TIMEOUT
|
|
384
|
+
managed.terminate_tree(_TERMINATION_GRACE_SECONDS)
|
|
385
|
+
break
|
|
386
|
+
|
|
387
|
+
wait_time = 0.01
|
|
388
|
+
if deadline is not None:
|
|
389
|
+
wait_time = min(wait_time, max(0.0, deadline - time.monotonic()))
|
|
390
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
391
|
+
managed.process.wait(timeout=wait_time)
|
|
392
|
+
return failure_kind, worker_error
|
|
393
|
+
|
|
394
|
+
def _raise_pending_signal(self) -> None:
|
|
395
|
+
"""Raise an intercepted signal at most once per forwarding context."""
|
|
396
|
+
with self._signal_lock:
|
|
397
|
+
if self._forwarded_signum is None or self._forwarded_signal_raised:
|
|
398
|
+
return
|
|
399
|
+
self._forwarded_signal_raised = True
|
|
400
|
+
forwarded_signum = self._forwarded_signum
|
|
401
|
+
raise ForwardedSignal(forwarded_signum)
|
|
402
|
+
|
|
403
|
+
def _complete_attempt(
|
|
404
|
+
self,
|
|
405
|
+
managed: ManagedStep,
|
|
406
|
+
attempt: _Attempt,
|
|
407
|
+
request: _RunRequest,
|
|
408
|
+
deadline: float | None,
|
|
409
|
+
) -> StepResult:
|
|
410
|
+
"""Observe and clean one successfully released managed process."""
|
|
411
|
+
monitor_error: Exception | None = None
|
|
412
|
+
failure_kind: FailureKind | None = None
|
|
413
|
+
worker_error: Exception | None = None
|
|
414
|
+
monitor_cleanup = CleanupState()
|
|
415
|
+
with monitor_cleanup:
|
|
416
|
+
failure_kind, worker_error = self._monitor_step(
|
|
417
|
+
managed,
|
|
418
|
+
attempt,
|
|
419
|
+
deadline,
|
|
420
|
+
)
|
|
421
|
+
monitor_error = monitor_cleanup.first_error
|
|
422
|
+
|
|
423
|
+
completion = _Completion(
|
|
424
|
+
managed.process.returncode,
|
|
425
|
+
_failure_kind=failure_kind,
|
|
426
|
+
)
|
|
427
|
+
first_error = worker_error if worker_error is not None else monitor_error
|
|
428
|
+
blocking_stderr, worker_timed_out, error = self._cleanup_attempt(
|
|
429
|
+
attempt,
|
|
430
|
+
managed.finish,
|
|
431
|
+
first_error,
|
|
432
|
+
)
|
|
433
|
+
completion._blocking_stderr = blocking_stderr
|
|
434
|
+
if error is not None:
|
|
435
|
+
raise error
|
|
436
|
+
self._raise_pending_signal()
|
|
437
|
+
if worker_timed_out:
|
|
438
|
+
completion._failure_kind = FailureKind.OUTPUT_WORKER_TIMEOUT
|
|
439
|
+
elif completion._failure_kind is None and attempt._output_exceeded.is_set():
|
|
440
|
+
completion._failure_kind = FailureKind.OUTPUT_LIMIT
|
|
441
|
+
return self._build_result(request._step, managed, attempt, completion)
|
|
442
|
+
|
|
443
|
+
def _receive_signal(self, signum: int, _frame: FrameType | None) -> None:
|
|
444
|
+
"""Record the first wrapper signal for main-thread forwarding."""
|
|
445
|
+
with self._signal_lock:
|
|
446
|
+
if self._forwarded_signum is None:
|
|
447
|
+
self._forwarded_signum = signum
|
|
448
|
+
|
|
449
|
+
@contextlib.contextmanager
|
|
450
|
+
def _signal_forwarding(self) -> Generator[None]:
|
|
451
|
+
"""Install and restore signal handlers around one executor context."""
|
|
452
|
+
signal_names = ('SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK')
|
|
453
|
+
handled_signals = tuple(
|
|
454
|
+
signum for name in signal_names if (signum := getattr(signal, name, None)) is not None
|
|
455
|
+
)
|
|
456
|
+
previous_handlers = {signum: signal.getsignal(signum) for signum in handled_signals}
|
|
457
|
+
with self._signal_lock:
|
|
458
|
+
self._forwarded_signal_raised = False
|
|
459
|
+
self._forwarded_signum = None
|
|
460
|
+
try:
|
|
461
|
+
for signum in handled_signals:
|
|
462
|
+
signal.signal(signum, self._receive_signal)
|
|
463
|
+
yield
|
|
464
|
+
finally:
|
|
465
|
+
restoration = CleanupState()
|
|
466
|
+
for signum, handler in previous_handlers.items():
|
|
467
|
+
if handler is None:
|
|
468
|
+
continue
|
|
469
|
+
restoration.attempt(lambda s=signum, h=handler: signal.signal(s, h))
|
|
470
|
+
self._raise_pending_signal()
|
|
471
|
+
if restoration.first_error is not None:
|
|
472
|
+
raise restoration.first_error
|
|
473
|
+
|
|
474
|
+
def _spawn_failure(self, step: Step, error: OSError) -> StepResult:
|
|
475
|
+
"""Map an operating-system spawn error to a bounded result."""
|
|
476
|
+
kind = FailureKind.NOT_FOUND if isinstance(error, FileNotFoundError) else FailureKind.SPAWN
|
|
477
|
+
category = 'not found' if kind is FailureKind.NOT_FOUND else 'could not be spawned'
|
|
478
|
+
return StepResult(
|
|
479
|
+
returncode=None,
|
|
480
|
+
stdout=b'',
|
|
481
|
+
blocking_stderr='',
|
|
482
|
+
failure=StepFailure(kind, f'step {step.index} ({step.name}) {category}'),
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
def _recover_release_failure(
|
|
486
|
+
self,
|
|
487
|
+
backend: ContainmentBackend,
|
|
488
|
+
managed: ManagedStep,
|
|
489
|
+
attempt: _Attempt,
|
|
490
|
+
request: _RunRequest,
|
|
491
|
+
) -> _Recovery | StepResult:
|
|
492
|
+
"""Dispose a failed gate before selecting an allowed fallback."""
|
|
493
|
+
blocking_stderr, worker_timed_out, error = self._cleanup_attempt(
|
|
494
|
+
attempt,
|
|
495
|
+
managed.abort_before_release,
|
|
496
|
+
None,
|
|
497
|
+
)
|
|
498
|
+
if error is not None:
|
|
499
|
+
raise error
|
|
500
|
+
if worker_timed_out:
|
|
501
|
+
completion = _Completion(
|
|
502
|
+
managed.process.returncode,
|
|
503
|
+
blocking_stderr,
|
|
504
|
+
FailureKind.OUTPUT_WORKER_TIMEOUT,
|
|
505
|
+
)
|
|
506
|
+
return self._build_result(request._step, managed, attempt, completion)
|
|
507
|
+
|
|
508
|
+
self._raise_pending_signal()
|
|
509
|
+
try:
|
|
510
|
+
recovered = backend.recover_release_failure(
|
|
511
|
+
request._launch_step,
|
|
512
|
+
managed,
|
|
513
|
+
allow_uncontained=request._allow_uncontained,
|
|
514
|
+
)
|
|
515
|
+
except ContainmentError as error:
|
|
516
|
+
return self._containment_failure(request._step, error)
|
|
517
|
+
except OSError as error:
|
|
518
|
+
return self._spawn_failure(request._step, error)
|
|
519
|
+
if recovered is managed:
|
|
520
|
+
return self._containment_failure(
|
|
521
|
+
request._step,
|
|
522
|
+
ContainmentError('release recovery reused the failed managed process'),
|
|
523
|
+
)
|
|
524
|
+
return _Recovery(recovered)
|
|
525
|
+
|
|
526
|
+
def _run_attempt(
|
|
527
|
+
self,
|
|
528
|
+
backend: ContainmentBackend,
|
|
529
|
+
managed: ManagedStep,
|
|
530
|
+
request: _RunRequest,
|
|
531
|
+
deadline: float | None,
|
|
532
|
+
) -> _Recovery | StepResult:
|
|
533
|
+
"""Run one managed process through release or recovery."""
|
|
534
|
+
attempt = self._create_attempt(managed, request)
|
|
535
|
+
start_cleanup = CleanupState()
|
|
536
|
+
with start_cleanup:
|
|
537
|
+
attempt._start()
|
|
538
|
+
if start_cleanup.first_error is not None:
|
|
539
|
+
_, _, error = self._cleanup_attempt(
|
|
540
|
+
attempt,
|
|
541
|
+
managed.abort_before_release,
|
|
542
|
+
start_cleanup.first_error,
|
|
543
|
+
)
|
|
544
|
+
if error is not None:
|
|
545
|
+
raise error
|
|
546
|
+
raise RuntimeError('worker startup failed without an exception')
|
|
547
|
+
release_cleanup = CleanupState()
|
|
548
|
+
release_cleanup.attempt(managed.release)
|
|
549
|
+
release_error = release_cleanup.first_error
|
|
550
|
+
if isinstance(release_error, ContainmentReleaseError):
|
|
551
|
+
return self._recover_release_failure(
|
|
552
|
+
backend,
|
|
553
|
+
managed,
|
|
554
|
+
attempt,
|
|
555
|
+
request,
|
|
556
|
+
)
|
|
557
|
+
if release_error is not None:
|
|
558
|
+
return self._abort_release_exception(managed, attempt, release_error)
|
|
559
|
+
return self._complete_attempt(managed, attempt, request, deadline)
|
|
560
|
+
|
|
561
|
+
def _run_managed(
|
|
562
|
+
self,
|
|
563
|
+
backend: ContainmentBackend,
|
|
564
|
+
managed: ManagedStep,
|
|
565
|
+
request: _RunRequest,
|
|
566
|
+
) -> StepResult:
|
|
567
|
+
"""Run one managed attempt, including allowed release recovery."""
|
|
568
|
+
deadline = time.monotonic() + request._timeout if request._timeout is not None else None
|
|
569
|
+
while True:
|
|
570
|
+
outcome = self._run_attempt(
|
|
571
|
+
backend,
|
|
572
|
+
managed,
|
|
573
|
+
request,
|
|
574
|
+
deadline,
|
|
575
|
+
)
|
|
576
|
+
if isinstance(outcome, _Recovery):
|
|
577
|
+
managed = outcome._managed
|
|
578
|
+
continue
|
|
579
|
+
return outcome
|
|
580
|
+
|
|
581
|
+
def run(
|
|
582
|
+
self,
|
|
583
|
+
step: Step,
|
|
584
|
+
input_bytes: bytes,
|
|
585
|
+
timeout: float | None,
|
|
586
|
+
*,
|
|
587
|
+
allow_uncontained: bool,
|
|
588
|
+
) -> StepResult:
|
|
589
|
+
"""Execute one bounded step through the selected containment backend.
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
step: Literal child argv and one-based diagnostic index.
|
|
593
|
+
input_bytes: Native hook bytes to deliver through stdin.
|
|
594
|
+
timeout: Optional monotonic timeout in seconds.
|
|
595
|
+
allow_uncontained: Whether explicit uncontained fallback is
|
|
596
|
+
allowed.
|
|
597
|
+
|
|
598
|
+
Returns:
|
|
599
|
+
Raw status, bounded outputs, and any infrastructure failure.
|
|
600
|
+
|
|
601
|
+
Raises:
|
|
602
|
+
ForwardedSignal: If the wrapper received a handled signal.
|
|
603
|
+
Exception: If an unexpected lifecycle or pipe operation failed.
|
|
604
|
+
"""
|
|
605
|
+
self._raise_pending_signal()
|
|
606
|
+
resolution = resolve_executable(
|
|
607
|
+
step.argv[0],
|
|
608
|
+
path_value=os.environ.get('PATH'),
|
|
609
|
+
windows=sys.platform == 'win32',
|
|
610
|
+
pathext_value=os.environ.get('PATHEXT'),
|
|
611
|
+
)
|
|
612
|
+
if resolution.status is ExecutableStatus.NOT_FOUND:
|
|
613
|
+
return StepResult(
|
|
614
|
+
returncode=None,
|
|
615
|
+
stdout=b'',
|
|
616
|
+
blocking_stderr='',
|
|
617
|
+
failure=StepFailure(
|
|
618
|
+
FailureKind.NOT_FOUND,
|
|
619
|
+
f'step {step.index} ({step.name}) not found',
|
|
620
|
+
),
|
|
621
|
+
)
|
|
622
|
+
if resolution.status is ExecutableStatus.UNSUPPORTED_SHIM:
|
|
623
|
+
correction = resolution.correction or 'use a native executable entry point'
|
|
624
|
+
return StepResult(
|
|
625
|
+
returncode=None,
|
|
626
|
+
stdout=b'',
|
|
627
|
+
blocking_stderr='',
|
|
628
|
+
failure=StepFailure(
|
|
629
|
+
FailureKind.UNSUPPORTED_EXECUTABLE,
|
|
630
|
+
f'step {step.index} ({step.name}) is unsupported: {correction}',
|
|
631
|
+
),
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
resolved = resolution.resolved
|
|
635
|
+
if resolved is None:
|
|
636
|
+
raise RuntimeError('runnable executable resolution omitted its path')
|
|
637
|
+
launch_path = resolved if resolved.is_absolute() else resolved.absolute()
|
|
638
|
+
launch_step = Step(step.index, (str(launch_path), *step.argv[1:]))
|
|
639
|
+
|
|
640
|
+
try:
|
|
641
|
+
backend = current_backend()
|
|
642
|
+
self._raise_pending_signal()
|
|
643
|
+
managed = backend.start(launch_step, allow_uncontained=allow_uncontained)
|
|
644
|
+
except ContainmentError as error:
|
|
645
|
+
return self._containment_failure(step, error)
|
|
646
|
+
except OSError as error:
|
|
647
|
+
return self._spawn_failure(step, error)
|
|
648
|
+
request = _RunRequest(
|
|
649
|
+
_allow_uncontained=allow_uncontained,
|
|
650
|
+
_input_bytes=input_bytes,
|
|
651
|
+
_launch_step=launch_step,
|
|
652
|
+
_step=step,
|
|
653
|
+
_timeout=timeout,
|
|
654
|
+
)
|
|
655
|
+
return self._run_managed(
|
|
656
|
+
backend,
|
|
657
|
+
managed,
|
|
658
|
+
request,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
def signal_forwarding(self) -> AbstractContextManager[None]:
|
|
662
|
+
"""Return a context that forwards handled signals to the active child.
|
|
663
|
+
|
|
664
|
+
Returns:
|
|
665
|
+
Context manager that restores every previous signal handler.
|
|
666
|
+
"""
|
|
667
|
+
return self._signal_forwarding()
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
class _Worker:
|
|
671
|
+
"""Run one daemon pipe operation and retain its first exception."""
|
|
672
|
+
|
|
673
|
+
def __init__(
|
|
674
|
+
self,
|
|
675
|
+
name: str,
|
|
676
|
+
stream: _WorkerStream,
|
|
677
|
+
target: Callable[[], None],
|
|
678
|
+
*,
|
|
679
|
+
close_in_worker: bool = False,
|
|
680
|
+
) -> None:
|
|
681
|
+
"""Prepare one daemon pipe operation without starting its thread."""
|
|
682
|
+
self._close_claimed = False
|
|
683
|
+
self._close_in_worker = close_in_worker
|
|
684
|
+
self._close_lock = threading.Lock()
|
|
685
|
+
self._close_result = _CancellationClose()
|
|
686
|
+
self._cancellation_started = False
|
|
687
|
+
self._stream = stream
|
|
688
|
+
self._target_error: Exception | None = None
|
|
689
|
+
self._thread = threading.Thread(target=self._run, name=name, daemon=True)
|
|
690
|
+
self._target = target
|
|
691
|
+
|
|
692
|
+
def _cancel_pipe(self) -> None:
|
|
693
|
+
"""Transfer one potentially blocking close to a dedicated daemon."""
|
|
694
|
+
with self._close_lock:
|
|
695
|
+
if self._close_claimed:
|
|
696
|
+
return
|
|
697
|
+
closer = threading.Thread(
|
|
698
|
+
target=_close_cancelled_pipe,
|
|
699
|
+
args=(self._stream, self._close_in_worker, self._close_result),
|
|
700
|
+
name=f'{self._thread.name}-cancel',
|
|
701
|
+
daemon=True,
|
|
702
|
+
)
|
|
703
|
+
closer.start()
|
|
704
|
+
self._close_claimed = True
|
|
705
|
+
self._cancellation_started = True
|
|
706
|
+
|
|
707
|
+
def _claim_pipe(self) -> bool:
|
|
708
|
+
"""Claim the owned pipe for exactly one close attempt."""
|
|
709
|
+
with self._close_lock:
|
|
710
|
+
if self._close_claimed:
|
|
711
|
+
return False
|
|
712
|
+
self._close_claimed = True
|
|
713
|
+
return True
|
|
714
|
+
|
|
715
|
+
def _close_claimed_pipe(self) -> None:
|
|
716
|
+
"""Close a pipe after this worker transferred its ownership."""
|
|
717
|
+
if self._close_in_worker:
|
|
718
|
+
_close_stdin_pipe(self._stream)
|
|
719
|
+
else:
|
|
720
|
+
self._stream.close()
|
|
721
|
+
|
|
722
|
+
def _close_pipe(self) -> None:
|
|
723
|
+
"""Close a normally joined worker's pipe synchronously."""
|
|
724
|
+
if self._claim_pipe():
|
|
725
|
+
self._close_claimed_pipe()
|
|
726
|
+
|
|
727
|
+
def _failure(self) -> Exception | None:
|
|
728
|
+
"""Return the first worker-target or cancellation-close failure."""
|
|
729
|
+
return self._target_error or self._close_result._error
|
|
730
|
+
|
|
731
|
+
def _is_alive(self) -> bool:
|
|
732
|
+
"""Return whether this worker has not stopped."""
|
|
733
|
+
return self._thread.is_alive()
|
|
734
|
+
|
|
735
|
+
def _join(self, timeout: float) -> None:
|
|
736
|
+
"""Wait up to the caller's share of the cleanup deadline."""
|
|
737
|
+
if self._thread.ident is not None:
|
|
738
|
+
self._thread.join(timeout)
|
|
739
|
+
|
|
740
|
+
def _observe_cancellation(self, timeout: float) -> None:
|
|
741
|
+
"""Wait up to one bounded share for cancellation result delivery."""
|
|
742
|
+
if self._cancellation_started:
|
|
743
|
+
self._close_result._completed.wait(timeout)
|
|
744
|
+
|
|
745
|
+
def _run(self) -> None:
|
|
746
|
+
"""Run the pipe operation while retaining unexpected failure."""
|
|
747
|
+
cleanup = CleanupState()
|
|
748
|
+
cleanup.attempt(self._target)
|
|
749
|
+
if self._close_in_worker and self._claim_pipe():
|
|
750
|
+
cleanup.attempt(self._close_claimed_pipe)
|
|
751
|
+
self._target_error = cleanup.first_error
|
|
752
|
+
|
|
753
|
+
def _start(self) -> None:
|
|
754
|
+
"""Start this daemon worker."""
|
|
755
|
+
self._thread.start()
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _close_stdin_pipe(stream: _WorkerStream) -> None:
|
|
759
|
+
"""Close stdin without promoting a child's broken pipe to failure."""
|
|
760
|
+
try:
|
|
761
|
+
stream.close()
|
|
762
|
+
except OSError as error:
|
|
763
|
+
if not is_downstream_closure(error, windows=sys.platform == 'win32'):
|
|
764
|
+
raise
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _close_cancelled_pipe(
|
|
768
|
+
stream: _WorkerStream,
|
|
769
|
+
broken_pipe_is_benign: bool,
|
|
770
|
+
result: _CancellationClose,
|
|
771
|
+
) -> None:
|
|
772
|
+
"""Close one cancelled pipe while retaining non-benign failures."""
|
|
773
|
+
try:
|
|
774
|
+
cleanup = CleanupState()
|
|
775
|
+
if broken_pipe_is_benign:
|
|
776
|
+
with cleanup:
|
|
777
|
+
_close_stdin_pipe(stream)
|
|
778
|
+
else:
|
|
779
|
+
cleanup.attempt(stream.close)
|
|
780
|
+
result._error = cleanup.first_error
|
|
781
|
+
finally:
|
|
782
|
+
result._completed.set()
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _is_windows_crash(value: int) -> bool:
|
|
786
|
+
"""Classify one exact Windows exit value as a likely crash."""
|
|
787
|
+
unsigned = value & 0xFFFFFFFF
|
|
788
|
+
return (
|
|
789
|
+
_WINDOWS_CRASH_RANGE_START <= unsigned <= _WINDOWS_CRASH_RANGE_END
|
|
790
|
+
or _WINDOWS_RUNTIME_CRASH_RANGE_START <= unsigned <= _WINDOWS_RUNTIME_CRASH_RANGE_END
|
|
791
|
+
or unsigned in _WINDOWS_CRASH_CODES
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _read_stderr(stream: StderrReader, relay: StderrRelay) -> None:
|
|
796
|
+
"""Drain child stderr through its bounded diagnostic relay."""
|
|
797
|
+
while data := stream.read1(64 * 1024):
|
|
798
|
+
relay.feed(data)
|
|
799
|
+
relay.finish()
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _read_stdout(
|
|
803
|
+
stream: BinaryIO,
|
|
804
|
+
output: bytearray,
|
|
805
|
+
exceeded: threading.Event,
|
|
806
|
+
) -> None:
|
|
807
|
+
"""Drain child stdout while retaining no more than its byte budget."""
|
|
808
|
+
while True:
|
|
809
|
+
remaining = max(0, _STDOUT_LIMIT_BYTES - len(output))
|
|
810
|
+
data = stream.read(min(64 * 1024, remaining + 1))
|
|
811
|
+
if not data:
|
|
812
|
+
return
|
|
813
|
+
output.extend(data[:remaining])
|
|
814
|
+
if len(data) > remaining:
|
|
815
|
+
exceeded.set()
|
|
816
|
+
return
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _write_stdin(stream: BinaryIO, input_bytes: bytes) -> None:
|
|
820
|
+
"""Deliver input without promoting a child's closed pipe to failure."""
|
|
821
|
+
try:
|
|
822
|
+
stream.write(input_bytes)
|
|
823
|
+
except OSError as error:
|
|
824
|
+
if not is_downstream_closure(error, windows=sys.platform == 'win32'):
|
|
825
|
+
raise
|