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,410 @@
|
|
|
1
|
+
"""Select and define native process-tree containment boundaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import TYPE_CHECKING, BinaryIO, Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from sequential_hooks._types import ContainmentState, Step, StepFailure
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class CompletionFailureProvider(Protocol):
|
|
17
|
+
"""Expose a platform failure decoded after process completion.
|
|
18
|
+
|
|
19
|
+
After monitoring, the executor reads `completion_failure` to distinguish a
|
|
20
|
+
decoded platform failure from the process return code.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def completion_failure(self) -> StepFailure | None:
|
|
25
|
+
"""Return a platform completion failure decoded after process exit.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Decoded completion failure, or `None` when no such failure was
|
|
29
|
+
decoded. `None` does not establish that the child exited
|
|
30
|
+
successfully.
|
|
31
|
+
"""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@runtime_checkable
|
|
36
|
+
class CompletionProcess(Protocol):
|
|
37
|
+
"""Observe the immediate process used as a managed completion handle.
|
|
38
|
+
|
|
39
|
+
`poll()` and `wait()` may resolve platform status before returning a
|
|
40
|
+
result. `returncode` remains `None` until completion has been observed.
|
|
41
|
+
Implementations surface their documented operating-system and protocol
|
|
42
|
+
failures to the caller.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def poll(self) -> int | None:
|
|
46
|
+
"""Return the child status without blocking.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Child status, or `None` while the process is running. The Windows
|
|
50
|
+
launcher returns `0` for a decoded real-step launch failure;
|
|
51
|
+
inspect the managed step's `completion_failure` to distinguish that
|
|
52
|
+
result from a successful child exit.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
OSError: If process or status-channel observation fails.
|
|
56
|
+
Implementation-specific exception: If malformed completion data
|
|
57
|
+
violates the concrete platform's protocol or invariants.
|
|
58
|
+
"""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def returncode(self) -> int | None:
|
|
63
|
+
"""Return the observed child status when available.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Child status, or `None` before completion is observed or when the
|
|
67
|
+
Windows launcher reports a real-step launch failure.
|
|
68
|
+
"""
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
def wait(self, timeout: float | None = None) -> int:
|
|
72
|
+
"""Wait for completion and return the child status.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
timeout: Maximum seconds to wait, or `None` to wait indefinitely.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Child status after completion. The Windows launcher returns `0` for
|
|
79
|
+
a decoded real-step launch failure; inspect the managed step's
|
|
80
|
+
`completion_failure` to distinguish that result from a successful
|
|
81
|
+
child exit.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
OSError: If process or status-channel observation fails.
|
|
85
|
+
subprocess.TimeoutExpired: If the process remains running after
|
|
86
|
+
`timeout`.
|
|
87
|
+
Implementation-specific exception: If malformed completion data
|
|
88
|
+
violates the concrete platform's protocol or invariants.
|
|
89
|
+
"""
|
|
90
|
+
...
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ContainmentError(RuntimeError):
|
|
94
|
+
"""Report that a managed process could not be established."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ContainmentPlatform(StrEnum):
|
|
98
|
+
"""Name one exact containment implementation classification.
|
|
99
|
+
|
|
100
|
+
Attributes:
|
|
101
|
+
POSIX: POSIX process-group containment.
|
|
102
|
+
UNAVAILABLE: Unsupported runtime classification with value
|
|
103
|
+
`uncontained`.
|
|
104
|
+
WINDOWS: Windows Job Object containment.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
POSIX = 'posix'
|
|
108
|
+
UNAVAILABLE = 'uncontained'
|
|
109
|
+
WINDOWS = 'windows'
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ContainmentReleaseError(RuntimeError):
|
|
113
|
+
"""Report failure while releasing a prepared process."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class StderrReader(Protocol):
|
|
117
|
+
"""Read and close one buffered native stderr pipe."""
|
|
118
|
+
|
|
119
|
+
def close(self) -> None:
|
|
120
|
+
"""Close the pipe."""
|
|
121
|
+
...
|
|
122
|
+
|
|
123
|
+
def read1(self, size: int = -1, /) -> bytes:
|
|
124
|
+
"""Read at most one buffered chunk.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
size: Maximum bytes to read, or `-1` for the stream default.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The bytes read, or an empty value at EOF.
|
|
131
|
+
"""
|
|
132
|
+
...
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ManagedStep(Protocol):
|
|
136
|
+
"""Own one prepared step through release, monitoring, and cleanup.
|
|
137
|
+
|
|
138
|
+
The executor acquires the standard-stream pipes before calling `release()`.
|
|
139
|
+
It calls `abort_before_release()` if preparation or release fails, and
|
|
140
|
+
calls `finish()` after monitoring ends. Signal and timeout paths may call
|
|
141
|
+
`forward_signal()` and `terminate_tree()` first.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def abort_before_release(self) -> None:
|
|
145
|
+
"""Dispose of a managed step after preparation or release fails.
|
|
146
|
+
|
|
147
|
+
Backends without a launch gate may already have started the real child.
|
|
148
|
+
"""
|
|
149
|
+
...
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def containment(self) -> ContainmentState:
|
|
153
|
+
"""Return whether whole-tree containment is active.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Current containment state for the managed step.
|
|
157
|
+
"""
|
|
158
|
+
...
|
|
159
|
+
|
|
160
|
+
def finish(self) -> None:
|
|
161
|
+
"""Terminate remaining managed processes and release containment.
|
|
162
|
+
|
|
163
|
+
Uncontained backends can clean up only the immediate process.
|
|
164
|
+
"""
|
|
165
|
+
...
|
|
166
|
+
|
|
167
|
+
def forward_signal(self, signum: int) -> None:
|
|
168
|
+
"""Forward a graceful signal when the platform can do so.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
signum: Signal received by the wrapper.
|
|
172
|
+
"""
|
|
173
|
+
...
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def process(self) -> CompletionProcess:
|
|
177
|
+
"""Return the immediate process used as the completion handle.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Process facade used to observe completion.
|
|
181
|
+
"""
|
|
182
|
+
...
|
|
183
|
+
|
|
184
|
+
def release(self) -> None:
|
|
185
|
+
"""Release a prepared process after output workers are ready."""
|
|
186
|
+
...
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def stderr(self) -> StderrReader:
|
|
190
|
+
"""Return the pipe carrying native step stderr.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Binary reader for native step stderr.
|
|
194
|
+
"""
|
|
195
|
+
...
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def stdin(self) -> BinaryIO:
|
|
199
|
+
"""Return the pipe that carries native step input.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Binary writer for native step stdin.
|
|
203
|
+
"""
|
|
204
|
+
...
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def stdout(self) -> BinaryIO:
|
|
208
|
+
"""Return the pipe carrying native step stdout.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Binary reader for native step stdout.
|
|
212
|
+
"""
|
|
213
|
+
...
|
|
214
|
+
|
|
215
|
+
def terminate_tree(self, grace_seconds: float) -> None:
|
|
216
|
+
"""Terminate the managed tree and wait for bounded cleanup.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
grace_seconds: Maximum graceful wait before forced termination.
|
|
220
|
+
"""
|
|
221
|
+
...
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class ContainmentBackend(Protocol):
|
|
225
|
+
"""Create managed steps and recover an allowed release failure.
|
|
226
|
+
|
|
227
|
+
`start()` returns a fresh process under the selected containment policy.
|
|
228
|
+
After the executor disposes a step whose release gate failed,
|
|
229
|
+
`recover_release_failure()` returns a distinct allowed fallback or refuses
|
|
230
|
+
it with `ContainmentError`.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
def recover_release_failure(
|
|
234
|
+
self,
|
|
235
|
+
step: Step,
|
|
236
|
+
failed: ManagedStep,
|
|
237
|
+
*,
|
|
238
|
+
allow_uncontained: bool,
|
|
239
|
+
) -> ManagedStep:
|
|
240
|
+
"""Return an allowed fallback after the failed step has been disposed.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
step: Step whose prepared process could not be released.
|
|
244
|
+
failed: Failed managed process already disposed by the executor.
|
|
245
|
+
allow_uncontained: Whether the user explicitly allowed fallback.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Fresh fallback process allowed by the selected backend.
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
ContainmentError: If fallback is disallowed.
|
|
252
|
+
OSError: If fallback startup fails.
|
|
253
|
+
"""
|
|
254
|
+
...
|
|
255
|
+
|
|
256
|
+
def start(self, step: Step, *, allow_uncontained: bool) -> ManagedStep:
|
|
257
|
+
"""Create one managed or explicitly uncontained process.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
step: Literal argument vector to start.
|
|
261
|
+
allow_uncontained: Whether the user explicitly allowed fallback.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Fresh process under the selected containment policy.
|
|
265
|
+
|
|
266
|
+
Raises:
|
|
267
|
+
ContainmentError: If required containment cannot be established.
|
|
268
|
+
OSError: If platform process or setup operations fail without
|
|
269
|
+
conversion by the concrete backend.
|
|
270
|
+
"""
|
|
271
|
+
...
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@runtime_checkable
|
|
275
|
+
class WindowsExitCodeProvider(Protocol):
|
|
276
|
+
"""Expose whether a managed result uses unsigned Windows exit codes."""
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def uses_windows_exit_codes(self) -> bool:
|
|
280
|
+
"""Return whether the immediate result uses unsigned Windows status.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Whether the process exposes unsigned Windows exit codes.
|
|
284
|
+
"""
|
|
285
|
+
...
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _classify_platform(
|
|
289
|
+
implementation: str,
|
|
290
|
+
platform_name: str,
|
|
291
|
+
) -> ContainmentPlatform:
|
|
292
|
+
"""Classify exact native interpreter facts for containment."""
|
|
293
|
+
if implementation != 'CPython':
|
|
294
|
+
return ContainmentPlatform.UNAVAILABLE
|
|
295
|
+
if platform_name in {'darwin', 'linux'}:
|
|
296
|
+
return ContainmentPlatform.POSIX
|
|
297
|
+
if platform_name == 'win32':
|
|
298
|
+
return ContainmentPlatform.WINDOWS
|
|
299
|
+
return ContainmentPlatform.UNAVAILABLE
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _resolve_runtime(
|
|
303
|
+
*,
|
|
304
|
+
implementation: str | None = None,
|
|
305
|
+
platform_name: str | None = None,
|
|
306
|
+
) -> ContainmentPlatform:
|
|
307
|
+
"""Resolve the current containment platform classification."""
|
|
308
|
+
selected_implementation = (
|
|
309
|
+
platform.python_implementation() if implementation is None else implementation
|
|
310
|
+
)
|
|
311
|
+
selected_platform = sys.platform if platform_name is None else platform_name
|
|
312
|
+
return _classify_platform(selected_implementation, selected_platform)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def current_backend(
|
|
316
|
+
*,
|
|
317
|
+
implementation: str | None = None,
|
|
318
|
+
platform_name: str | None = None,
|
|
319
|
+
) -> ContainmentBackend:
|
|
320
|
+
"""Return the backend for the running native interpreter.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
implementation: Python implementation name, or the current value.
|
|
324
|
+
platform_name: Runtime platform name, or the current value.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
Fresh backend for the effective interpreter and platform.
|
|
328
|
+
"""
|
|
329
|
+
runtime = _resolve_runtime(
|
|
330
|
+
implementation=implementation,
|
|
331
|
+
platform_name=platform_name,
|
|
332
|
+
)
|
|
333
|
+
if runtime is ContainmentPlatform.POSIX:
|
|
334
|
+
from sequential_hooks._containment._posix import PosixBackend # noqa: PLC0415
|
|
335
|
+
|
|
336
|
+
return PosixBackend()
|
|
337
|
+
if runtime is ContainmentPlatform.WINDOWS:
|
|
338
|
+
from sequential_hooks._containment._windows import WindowsBackend # noqa: PLC0415
|
|
339
|
+
|
|
340
|
+
return WindowsBackend()
|
|
341
|
+
from sequential_hooks._containment._uncontained import ( # noqa: PLC0415
|
|
342
|
+
UncontainedBackend,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
return UncontainedBackend()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def probe_current_containment(
|
|
349
|
+
*,
|
|
350
|
+
implementation: str | None = None,
|
|
351
|
+
platform_name: str | None = None,
|
|
352
|
+
) -> tuple[ContainmentPlatform, bool | None, tuple[str, ...]]:
|
|
353
|
+
"""Report static containment availability without starting a process.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
implementation: Python implementation name, or the current value.
|
|
357
|
+
platform_name: Runtime platform name, or the current value.
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Platform classification, Job Object configuration result, and bounded
|
|
361
|
+
limitations. The configuration result is `True` or `False` when the
|
|
362
|
+
classification is `ContainmentPlatform.WINDOWS`, and `None` for other
|
|
363
|
+
classifications.
|
|
364
|
+
"""
|
|
365
|
+
containment = _resolve_runtime(
|
|
366
|
+
implementation=implementation,
|
|
367
|
+
platform_name=platform_name,
|
|
368
|
+
)
|
|
369
|
+
if containment is ContainmentPlatform.POSIX:
|
|
370
|
+
return containment, None, ()
|
|
371
|
+
if containment is ContainmentPlatform.WINDOWS:
|
|
372
|
+
from sequential_hooks._containment._windows_api import ( # noqa: PLC0415
|
|
373
|
+
probe_job_object,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
configurable = probe_job_object()
|
|
377
|
+
limitations = () if configurable else ('kill-on-close Job Object is unavailable',)
|
|
378
|
+
return containment, configurable, limitations
|
|
379
|
+
return (
|
|
380
|
+
containment,
|
|
381
|
+
None,
|
|
382
|
+
('whole-process-tree containment is unavailable for this interpreter',),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def start_process(
|
|
387
|
+
step: Step,
|
|
388
|
+
*,
|
|
389
|
+
contained: bool,
|
|
390
|
+
creationflags: int = 0,
|
|
391
|
+
) -> subprocess.Popen[bytes]:
|
|
392
|
+
"""Start one literal argv with binary parent pipes.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
step: Literal argv to execute.
|
|
396
|
+
contained: Whether POSIX must create a new process session.
|
|
397
|
+
creationflags: Native process-creation flags.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Immediate process with binary standard-stream pipes.
|
|
401
|
+
"""
|
|
402
|
+
return subprocess.Popen( # noqa: S603
|
|
403
|
+
step.argv,
|
|
404
|
+
stdin=subprocess.PIPE,
|
|
405
|
+
stdout=subprocess.PIPE,
|
|
406
|
+
stderr=subprocess.PIPE,
|
|
407
|
+
creationflags=creationflags,
|
|
408
|
+
shell=False,
|
|
409
|
+
start_new_session=contained,
|
|
410
|
+
)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Contain child process trees in POSIX process groups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from typing import TYPE_CHECKING, BinaryIO, cast
|
|
12
|
+
|
|
13
|
+
from sequential_hooks._containment import ContainmentError, start_process
|
|
14
|
+
from sequential_hooks._containment._uncontained import UncontainedBackend
|
|
15
|
+
from sequential_hooks._types import ContainmentState
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from sequential_hooks._containment import ManagedStep, StderrReader
|
|
19
|
+
from sequential_hooks._types import Step
|
|
20
|
+
|
|
21
|
+
_ABORT_GRACE_SECONDS = 1.0
|
|
22
|
+
_FORCE_KILL_WAIT_SECONDS = 1.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PosixBackend:
|
|
26
|
+
"""Create process-group-contained POSIX steps."""
|
|
27
|
+
|
|
28
|
+
def recover_release_failure(
|
|
29
|
+
self,
|
|
30
|
+
step: Step,
|
|
31
|
+
failed: ManagedStep,
|
|
32
|
+
*,
|
|
33
|
+
allow_uncontained: bool,
|
|
34
|
+
) -> ManagedStep:
|
|
35
|
+
"""Return an explicit uncontained fallback after release failure.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
step: Step whose prepared process could not be released.
|
|
39
|
+
failed: Failed managed process already disposed by the executor.
|
|
40
|
+
allow_uncontained: Whether the user explicitly allowed fallback.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Fresh explicitly uncontained process.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ContainmentError: If fallback was not explicitly allowed.
|
|
47
|
+
"""
|
|
48
|
+
del failed
|
|
49
|
+
if not allow_uncontained:
|
|
50
|
+
raise ContainmentError('containment release failed without explicit override')
|
|
51
|
+
return UncontainedBackend().start(step, allow_uncontained=True)
|
|
52
|
+
|
|
53
|
+
def start(self, step: Step, *, allow_uncontained: bool) -> ManagedStep:
|
|
54
|
+
"""Create one process-group-contained POSIX process.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
step: Literal argv to start.
|
|
58
|
+
allow_uncontained: Accepted for the backend interface; this method
|
|
59
|
+
does not use it because fallback is handled separately.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Managed POSIX process group.
|
|
63
|
+
"""
|
|
64
|
+
del allow_uncontained
|
|
65
|
+
return _PosixManagedStep(step)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _PosixManagedStep:
|
|
69
|
+
"""Own one POSIX process group and its parent pipe handles."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, step: Step) -> None:
|
|
72
|
+
"""Start one contained POSIX child and validate its pipes."""
|
|
73
|
+
self._graceful_signum: int | None = None
|
|
74
|
+
self._process = start_process(step, contained=True)
|
|
75
|
+
if (
|
|
76
|
+
self._process.stdin is None
|
|
77
|
+
or self._process.stdout is None
|
|
78
|
+
or self._process.stderr is None
|
|
79
|
+
):
|
|
80
|
+
self.terminate_tree(_ABORT_GRACE_SECONDS)
|
|
81
|
+
raise ContainmentError('managed POSIX child pipes were not created')
|
|
82
|
+
|
|
83
|
+
def _group_exists(self) -> bool:
|
|
84
|
+
"""Return whether the exact managed process group still exists."""
|
|
85
|
+
if sys.platform == 'win32':
|
|
86
|
+
raise ContainmentError('POSIX process groups are unavailable on Windows')
|
|
87
|
+
try:
|
|
88
|
+
os.killpg(self._process.pid, 0)
|
|
89
|
+
except ProcessLookupError:
|
|
90
|
+
return False
|
|
91
|
+
except PermissionError:
|
|
92
|
+
return True
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
def _wait_for_group_exit(self, deadline: float) -> bool:
|
|
96
|
+
"""Wait only until one absolute deadline for the exact group to vanish."""
|
|
97
|
+
while self._group_exists():
|
|
98
|
+
remaining = deadline - time.monotonic()
|
|
99
|
+
if remaining <= 0:
|
|
100
|
+
return False
|
|
101
|
+
if self._process.poll() is None:
|
|
102
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
103
|
+
self._process.wait(timeout=min(0.01, remaining))
|
|
104
|
+
else:
|
|
105
|
+
time.sleep(min(0.01, remaining))
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
def _wait_for_process_reap(self, deadline: float) -> bool:
|
|
109
|
+
"""Wait only until one absolute deadline for the leader to be reaped."""
|
|
110
|
+
if self._process.poll() is not None:
|
|
111
|
+
return True
|
|
112
|
+
try:
|
|
113
|
+
self._process.wait(timeout=max(0.0, deadline - time.monotonic()))
|
|
114
|
+
except subprocess.TimeoutExpired:
|
|
115
|
+
return False
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
def _signal_group(self, signum: int, deadline: float) -> None:
|
|
119
|
+
"""Send one signal only to the exact managed process group."""
|
|
120
|
+
if sys.platform == 'win32':
|
|
121
|
+
raise ContainmentError('POSIX process groups are unavailable on Windows')
|
|
122
|
+
try:
|
|
123
|
+
os.killpg(self._process.pid, signum)
|
|
124
|
+
except ProcessLookupError:
|
|
125
|
+
return
|
|
126
|
+
except PermissionError:
|
|
127
|
+
# Darwin can report EPERM while an exiting leader is not yet waitable.
|
|
128
|
+
if not self._wait_for_process_reap(deadline):
|
|
129
|
+
raise
|
|
130
|
+
with contextlib.suppress(ProcessLookupError):
|
|
131
|
+
os.killpg(self._process.pid, signum)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def containment(self) -> ContainmentState:
|
|
135
|
+
"""Return the active whole-tree containment state.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Contained state for this POSIX process group.
|
|
139
|
+
"""
|
|
140
|
+
return ContainmentState.CONTAINED
|
|
141
|
+
|
|
142
|
+
def forward_signal(self, signum: int) -> None:
|
|
143
|
+
"""Forward and record the wrapper's actual graceful signal.
|
|
144
|
+
|
|
145
|
+
Exceptional Darwin exit-race recovery may wait up to the force-kill
|
|
146
|
+
bound before returning.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
signum: Actual signal received by the wrapper.
|
|
150
|
+
"""
|
|
151
|
+
if self._graceful_signum is None:
|
|
152
|
+
self._graceful_signum = signum
|
|
153
|
+
deadline = time.monotonic() + _FORCE_KILL_WAIT_SECONDS
|
|
154
|
+
self._signal_group(signum, deadline)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def process(self) -> subprocess.Popen[bytes]:
|
|
158
|
+
"""Return the immediate process completion handle.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Immediate POSIX child process.
|
|
162
|
+
"""
|
|
163
|
+
return self._process
|
|
164
|
+
|
|
165
|
+
def release(self) -> None:
|
|
166
|
+
"""Do nothing because the POSIX child starts during construction."""
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def stderr(self) -> StderrReader:
|
|
170
|
+
"""Return the parent stderr pipe.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Binary stderr reader.
|
|
174
|
+
"""
|
|
175
|
+
return cast('StderrReader', self._process.stderr)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def stdin(self) -> BinaryIO:
|
|
179
|
+
"""Return the parent stdin pipe.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Binary stdin writer.
|
|
183
|
+
"""
|
|
184
|
+
return cast('BinaryIO', self._process.stdin)
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def stdout(self) -> BinaryIO:
|
|
188
|
+
"""Return the parent stdout pipe.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Binary stdout reader.
|
|
192
|
+
"""
|
|
193
|
+
return cast('BinaryIO', self._process.stdout)
|
|
194
|
+
|
|
195
|
+
def terminate_tree(self, grace_seconds: float) -> None:
|
|
196
|
+
"""Terminate the exact managed group after one grace period.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
grace_seconds: Maximum graceful wait before `SIGKILL`.
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
ContainmentError: If the process group does not disappear or its
|
|
203
|
+
leader cannot be reaped within the bounded cleanup waits.
|
|
204
|
+
"""
|
|
205
|
+
if sys.platform == 'win32':
|
|
206
|
+
raise ContainmentError('POSIX process groups are unavailable on Windows')
|
|
207
|
+
graceful_deadline = time.monotonic() + max(0.0, grace_seconds)
|
|
208
|
+
if self._graceful_signum is None:
|
|
209
|
+
self._graceful_signum = signal.SIGTERM
|
|
210
|
+
self._signal_group(signal.SIGTERM, graceful_deadline)
|
|
211
|
+
|
|
212
|
+
if self._wait_for_group_exit(graceful_deadline):
|
|
213
|
+
reap_deadline = time.monotonic() + _FORCE_KILL_WAIT_SECONDS
|
|
214
|
+
if not self._wait_for_process_reap(reap_deadline):
|
|
215
|
+
raise ContainmentError('managed POSIX leader could not be reaped')
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
force_deadline = time.monotonic() + _FORCE_KILL_WAIT_SECONDS
|
|
219
|
+
self._signal_group(signal.SIGKILL, force_deadline)
|
|
220
|
+
group_disappeared = self._wait_for_group_exit(force_deadline)
|
|
221
|
+
process_reaped = self._wait_for_process_reap(force_deadline)
|
|
222
|
+
if not group_disappeared:
|
|
223
|
+
raise ContainmentError('managed POSIX process group did not disappear after SIGKILL')
|
|
224
|
+
if not process_reaped:
|
|
225
|
+
raise ContainmentError('managed POSIX leader could not be reaped after SIGKILL')
|
|
226
|
+
|
|
227
|
+
def abort_before_release(self) -> None:
|
|
228
|
+
"""Dispose of the POSIX child process.
|
|
229
|
+
|
|
230
|
+
POSIX has no release gate, so aborting is ordinary tree termination.
|
|
231
|
+
"""
|
|
232
|
+
self.terminate_tree(_ABORT_GRACE_SECONDS)
|
|
233
|
+
|
|
234
|
+
def finish(self) -> None:
|
|
235
|
+
"""Terminate remaining group members after immediate completion."""
|
|
236
|
+
self.terminate_tree(_ABORT_GRACE_SECONDS)
|