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,853 @@
|
|
|
1
|
+
# pyright: reportPrivateUsage=false
|
|
2
|
+
|
|
3
|
+
"""Contain child process trees in Windows Job Objects."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, BinaryIO, cast
|
|
18
|
+
|
|
19
|
+
from sequential_hooks._cleanup import CleanupState
|
|
20
|
+
from sequential_hooks._containment import (
|
|
21
|
+
CompletionProcess,
|
|
22
|
+
ContainmentBackend,
|
|
23
|
+
ContainmentError,
|
|
24
|
+
ContainmentReleaseError,
|
|
25
|
+
)
|
|
26
|
+
from sequential_hooks._containment._uncontained import UncontainedBackend
|
|
27
|
+
from sequential_hooks._containment._windows_api import NativeWindowsApi, WindowsApi
|
|
28
|
+
from sequential_hooks._types import ContainmentState, FailureKind, StepFailure
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from sequential_hooks._containment import ManagedStep, StderrReader
|
|
32
|
+
from sequential_hooks._types import Step
|
|
33
|
+
|
|
34
|
+
_ABORT_GRACE_SECONDS = 1.0
|
|
35
|
+
_ARGV_LIMIT_BYTES = 1024 * 1024
|
|
36
|
+
_CREATE_NEW_PROCESS_GROUP = cast(
|
|
37
|
+
'int',
|
|
38
|
+
getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0x00000200),
|
|
39
|
+
)
|
|
40
|
+
_FORCE_KILL_WAIT_SECONDS = 1.0
|
|
41
|
+
_LAUNCHER_INVARIANT = 3
|
|
42
|
+
_LAUNCHER_NOT_FOUND = 1
|
|
43
|
+
_LAUNCHER_PATH = Path(__file__).with_name('_windows_launcher.py').resolve()
|
|
44
|
+
_LAUNCHER_SPAWN = 2
|
|
45
|
+
_LAUNCHER_STATUS_HEADER_BYTES = 5
|
|
46
|
+
_LAUNCHER_STATUS_LIMIT_BYTES = 4096
|
|
47
|
+
_LAUNCHER_SUCCESS = 0
|
|
48
|
+
_RELEASE_ABORT_RESERVE_SECONDS = 1.0
|
|
49
|
+
_RELEASE_DEADLINE_SECONDS = 2.0
|
|
50
|
+
_PopenFactory = Callable[..., subprocess.Popen[bytes]]
|
|
51
|
+
_StartupInfoFactory = Callable[[tuple[int, int]], object]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class _ControlWriteResult:
|
|
56
|
+
"""Carry one detached control writer's bounded result."""
|
|
57
|
+
|
|
58
|
+
_bytes_written: int = 0
|
|
59
|
+
_error: OSError | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _LauncherProcess:
|
|
63
|
+
"""Decode launcher status while presenting its child result to the executor."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
process: subprocess.Popen[bytes],
|
|
68
|
+
status_reader: int,
|
|
69
|
+
step: Step,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Bind launcher status to the child process result."""
|
|
72
|
+
self._decoded_failure: StepFailure | None = None
|
|
73
|
+
self._ignore_status = False
|
|
74
|
+
self._process = process
|
|
75
|
+
self._result_returncode: int | None = None
|
|
76
|
+
self._status_reader: int | None = status_reader
|
|
77
|
+
self._status_resolved = False
|
|
78
|
+
self._step = step
|
|
79
|
+
|
|
80
|
+
def _close_status_reader(self) -> None:
|
|
81
|
+
"""Close the parent status descriptor exactly once."""
|
|
82
|
+
if self._status_reader is not None:
|
|
83
|
+
descriptor = self._status_reader
|
|
84
|
+
self._status_reader = None
|
|
85
|
+
os.close(descriptor)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def _completion_failure(self) -> StepFailure | None:
|
|
89
|
+
"""Return the decoded real-step spawn failure, if any."""
|
|
90
|
+
return self._decoded_failure
|
|
91
|
+
|
|
92
|
+
def _discard_status(self) -> None:
|
|
93
|
+
"""Stop interpreting status after wrapper-owned termination."""
|
|
94
|
+
self._ignore_status = True
|
|
95
|
+
self._close_status_reader()
|
|
96
|
+
|
|
97
|
+
def _read_status_frame(self) -> bytes:
|
|
98
|
+
"""Read one bounded status frame through end of file."""
|
|
99
|
+
if self._status_reader is None:
|
|
100
|
+
raise _LauncherProtocolError('launcher status descriptor is unavailable')
|
|
101
|
+
frame = bytearray()
|
|
102
|
+
while chunk := os.read(
|
|
103
|
+
self._status_reader,
|
|
104
|
+
_LAUNCHER_STATUS_LIMIT_BYTES + 1 - len(frame),
|
|
105
|
+
):
|
|
106
|
+
frame.extend(chunk)
|
|
107
|
+
if len(frame) > _LAUNCHER_STATUS_LIMIT_BYTES:
|
|
108
|
+
break
|
|
109
|
+
return bytes(frame)
|
|
110
|
+
|
|
111
|
+
def _resolve_status(self) -> None:
|
|
112
|
+
"""Decode one complete launcher status exactly once."""
|
|
113
|
+
if self._status_resolved:
|
|
114
|
+
return
|
|
115
|
+
self._status_resolved = True
|
|
116
|
+
if self._ignore_status:
|
|
117
|
+
raw_returncode = self._process.returncode
|
|
118
|
+
self._result_returncode = (
|
|
119
|
+
None if raw_returncode is None else raw_returncode & 0xFFFFFFFF
|
|
120
|
+
)
|
|
121
|
+
self._close_status_reader()
|
|
122
|
+
return
|
|
123
|
+
try:
|
|
124
|
+
frame = self._read_status_frame()
|
|
125
|
+
finally:
|
|
126
|
+
self._close_status_reader()
|
|
127
|
+
status, message = _decode_status_frame(frame)
|
|
128
|
+
if status == _LAUNCHER_SUCCESS:
|
|
129
|
+
if message:
|
|
130
|
+
raise _LauncherProtocolError('successful launcher status contains diagnostic text')
|
|
131
|
+
if self._process.returncode is None:
|
|
132
|
+
raise _LauncherProtocolError('launcher completed without an exit value')
|
|
133
|
+
self._result_returncode = self._process.returncode & 0xFFFFFFFF
|
|
134
|
+
return
|
|
135
|
+
if not message:
|
|
136
|
+
raise _LauncherProtocolError('failed launcher status omits diagnostic text')
|
|
137
|
+
if status == _LAUNCHER_NOT_FOUND:
|
|
138
|
+
self._decoded_failure = StepFailure(
|
|
139
|
+
FailureKind.NOT_FOUND,
|
|
140
|
+
f'step {self._step.index} ({self._step.name}) not found',
|
|
141
|
+
)
|
|
142
|
+
return
|
|
143
|
+
if status == _LAUNCHER_SPAWN:
|
|
144
|
+
self._decoded_failure = StepFailure(
|
|
145
|
+
FailureKind.SPAWN,
|
|
146
|
+
f'step {self._step.index} ({self._step.name}) could not be spawned',
|
|
147
|
+
)
|
|
148
|
+
return
|
|
149
|
+
if status == _LAUNCHER_INVARIANT:
|
|
150
|
+
raise _LauncherProtocolError(f'launcher invariant failure: {message}')
|
|
151
|
+
raise _LauncherProtocolError(f'unknown launcher status {status}')
|
|
152
|
+
|
|
153
|
+
def _raise_release_invariant_if_available(self, deadline: float) -> None:
|
|
154
|
+
"""Raise an available status invariant after a partial write."""
|
|
155
|
+
try:
|
|
156
|
+
self._process.wait(timeout=max(0.0, deadline - time.monotonic()))
|
|
157
|
+
except subprocess.TimeoutExpired:
|
|
158
|
+
return
|
|
159
|
+
self._resolve_status()
|
|
160
|
+
raise _LauncherProtocolError('launcher completed after a failed partial control frame')
|
|
161
|
+
|
|
162
|
+
def poll(self) -> int | None:
|
|
163
|
+
"""Poll the launcher and decode status after it exits.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Exact real-step status, `0` for a decoded spawn failure, or `None`
|
|
167
|
+
while the launcher is running.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
OSError: If status-channel I/O fails.
|
|
171
|
+
_LauncherProtocolError: If status is malformed or violates a
|
|
172
|
+
protocol invariant.
|
|
173
|
+
"""
|
|
174
|
+
if self._process.poll() is None:
|
|
175
|
+
return None
|
|
176
|
+
self._resolve_status()
|
|
177
|
+
return 0 if self._decoded_failure is not None else self._result_returncode
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def returncode(self) -> int | None:
|
|
181
|
+
"""Return the exact real-step status after status resolution.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Exact real-step status, or `None` before status resolution or when
|
|
185
|
+
launcher status reports a launch failure.
|
|
186
|
+
"""
|
|
187
|
+
if self._status_resolved:
|
|
188
|
+
return self._result_returncode
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
def wait(self, timeout: float | None = None) -> int:
|
|
192
|
+
"""Wait for the launcher and return the exact real-step status.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
timeout: Maximum seconds to wait, or `None` to wait indefinitely.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Exact real-step status, or `0` when `_completion_failure` carries a
|
|
199
|
+
decoded real-step launch failure.
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
OSError: If status-channel I/O fails.
|
|
203
|
+
subprocess.TimeoutExpired: If the launcher remains running after
|
|
204
|
+
`timeout`.
|
|
205
|
+
_LauncherProtocolError: If status is malformed or violates a
|
|
206
|
+
protocol invariant.
|
|
207
|
+
"""
|
|
208
|
+
self._process.wait(timeout=timeout)
|
|
209
|
+
self._resolve_status()
|
|
210
|
+
return 0 if self._result_returncode is None else self._result_returncode
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class _LauncherProtocolError(RuntimeError):
|
|
214
|
+
"""Report a corrupt or invariant-failure launcher status frame."""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True)
|
|
218
|
+
class _ManagedResources:
|
|
219
|
+
"""Carry one launcher's parent-owned descriptors and Job Object."""
|
|
220
|
+
|
|
221
|
+
_control_writer: int
|
|
222
|
+
_job_handle: int
|
|
223
|
+
_status_reader: int
|
|
224
|
+
_write_fd: Callable[[int, bytes], int]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class _WindowsManagedStep:
|
|
228
|
+
"""Own one launcher gate, optional Job Object, and parent status handle."""
|
|
229
|
+
|
|
230
|
+
def __init__(
|
|
231
|
+
self,
|
|
232
|
+
api: WindowsApi,
|
|
233
|
+
process: subprocess.Popen[bytes],
|
|
234
|
+
step: Step,
|
|
235
|
+
resources: _ManagedResources,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Take ownership of one gated Windows child and its resources."""
|
|
238
|
+
self._api = api
|
|
239
|
+
self._assigned = False
|
|
240
|
+
self._containment = ContainmentState.CONTAINED
|
|
241
|
+
self._control_writer: int | None = resources._control_writer
|
|
242
|
+
self._job_cleanup_deadline: float | None = None
|
|
243
|
+
self._job_handle: int | None = resources._job_handle
|
|
244
|
+
self._launcher_process = _LauncherProcess(process, resources._status_reader, step)
|
|
245
|
+
self._process = process
|
|
246
|
+
self._released = False
|
|
247
|
+
self._step = step
|
|
248
|
+
self._write_fd = resources._write_fd
|
|
249
|
+
|
|
250
|
+
def _close_control_writer(self) -> None:
|
|
251
|
+
"""Close the parent control writer exactly once."""
|
|
252
|
+
if self._control_writer is not None:
|
|
253
|
+
descriptor = self._control_writer
|
|
254
|
+
self._control_writer = None
|
|
255
|
+
os.close(descriptor)
|
|
256
|
+
|
|
257
|
+
def _close_job(self) -> None:
|
|
258
|
+
"""Close the wrapper-owned Job Object handle exactly once."""
|
|
259
|
+
if self._job_handle is not None:
|
|
260
|
+
handle = self._job_handle
|
|
261
|
+
self._job_handle = None
|
|
262
|
+
self._api.close_handle(handle)
|
|
263
|
+
|
|
264
|
+
def _close_process_pipes(self, cleanup: CleanupState) -> None:
|
|
265
|
+
"""Attempt each wrapper-owned standard-pipe close."""
|
|
266
|
+
for stream in (self._process.stdin, self._process.stdout, self._process.stderr):
|
|
267
|
+
if stream is not None and not stream.closed:
|
|
268
|
+
cleanup.attempt(stream.close)
|
|
269
|
+
|
|
270
|
+
def _mark_assigned(self) -> None:
|
|
271
|
+
"""Record successful assignment before the launcher can be released."""
|
|
272
|
+
self._assigned = True
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def completion_failure(self) -> StepFailure | None:
|
|
276
|
+
"""Return a decoded launcher-reported real-step spawn failure.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Decoded spawn failure, or `None` when none is available.
|
|
280
|
+
"""
|
|
281
|
+
return self._launcher_process._completion_failure
|
|
282
|
+
|
|
283
|
+
def _release_job(self) -> None:
|
|
284
|
+
"""Drop a refused Job Object under the explicit uncontained override."""
|
|
285
|
+
self._containment = ContainmentState.UNCONTAINED
|
|
286
|
+
self._close_job()
|
|
287
|
+
|
|
288
|
+
def _stop_unassigned_launcher(self, grace_seconds: float) -> None:
|
|
289
|
+
"""Bound cleanup of a launcher that is not protected by the job."""
|
|
290
|
+
if self._process.poll() is not None:
|
|
291
|
+
return
|
|
292
|
+
self._process.terminate()
|
|
293
|
+
try:
|
|
294
|
+
self._process.wait(timeout=max(0.0, grace_seconds))
|
|
295
|
+
except subprocess.TimeoutExpired:
|
|
296
|
+
self._process.kill()
|
|
297
|
+
self._process.wait(timeout=_FORCE_KILL_WAIT_SECONDS)
|
|
298
|
+
|
|
299
|
+
def _terminate_job_tree(self) -> None:
|
|
300
|
+
"""Terminate and confirm all job exits within one retained deadline."""
|
|
301
|
+
job_handle = self._job_handle
|
|
302
|
+
if job_handle is None:
|
|
303
|
+
return
|
|
304
|
+
if self._job_cleanup_deadline is None:
|
|
305
|
+
self._job_cleanup_deadline = time.monotonic() + _FORCE_KILL_WAIT_SECONDS
|
|
306
|
+
self._api.terminate_job(job_handle)
|
|
307
|
+
remaining = max(0.0, self._job_cleanup_deadline - time.monotonic())
|
|
308
|
+
if not self._api.wait_job(job_handle, remaining):
|
|
309
|
+
raise ContainmentError(
|
|
310
|
+
'Windows job completion could not be confirmed after termination'
|
|
311
|
+
)
|
|
312
|
+
self._process.wait(timeout=max(0.0, self._job_cleanup_deadline - time.monotonic()))
|
|
313
|
+
|
|
314
|
+
def _wait_for_cleanup(
|
|
315
|
+
self,
|
|
316
|
+
cleanup: CleanupState,
|
|
317
|
+
deadline: float,
|
|
318
|
+
limit: float,
|
|
319
|
+
) -> bool:
|
|
320
|
+
"""Wait for cleanup completion while retaining ordinary failures."""
|
|
321
|
+
try:
|
|
322
|
+
self._process.wait(timeout=min(limit, max(0.0, deadline - time.monotonic())))
|
|
323
|
+
except subprocess.TimeoutExpired:
|
|
324
|
+
return False
|
|
325
|
+
except (OSError, RuntimeError) as error:
|
|
326
|
+
cleanup.record(error)
|
|
327
|
+
return False
|
|
328
|
+
return True
|
|
329
|
+
|
|
330
|
+
def _terminate_before_release(self, cleanup: CleanupState, deadline: float) -> None:
|
|
331
|
+
"""Terminate and reap the blocked launcher within one deadline."""
|
|
332
|
+
terminated = False
|
|
333
|
+
job_handle = self._job_handle
|
|
334
|
+
if self._assigned and job_handle is not None:
|
|
335
|
+
terminated = cleanup.attempt(lambda: self._api.terminate_job(job_handle))
|
|
336
|
+
if not terminated:
|
|
337
|
+
cleanup.attempt(self._process.terminate)
|
|
338
|
+
if self._wait_for_cleanup(cleanup, deadline, _FORCE_KILL_WAIT_SECONDS):
|
|
339
|
+
return
|
|
340
|
+
cleanup.attempt(self._process.kill)
|
|
341
|
+
if not self._wait_for_cleanup(cleanup, deadline, _FORCE_KILL_WAIT_SECONDS):
|
|
342
|
+
cleanup.record(
|
|
343
|
+
_LauncherProtocolError('launcher could not be reaped after termination')
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def containment(self) -> ContainmentState:
|
|
348
|
+
"""Return whether this launcher remains assigned to its Job Object.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Current Job Object containment state.
|
|
352
|
+
"""
|
|
353
|
+
return self._containment
|
|
354
|
+
|
|
355
|
+
def _dispose_before_release(
|
|
356
|
+
self,
|
|
357
|
+
first_error: Exception | None = None,
|
|
358
|
+
*,
|
|
359
|
+
deadline: float | None = None,
|
|
360
|
+
force_terminate: bool = False,
|
|
361
|
+
) -> Exception | None:
|
|
362
|
+
"""Dispose containment owners without touching handed-off pipes."""
|
|
363
|
+
cleanup = CleanupState(first_error)
|
|
364
|
+
end = (
|
|
365
|
+
time.monotonic() + _ABORT_GRACE_SECONDS + _FORCE_KILL_WAIT_SECONDS
|
|
366
|
+
if deadline is None
|
|
367
|
+
else deadline
|
|
368
|
+
)
|
|
369
|
+
cleanup.attempt(self._close_control_writer)
|
|
370
|
+
|
|
371
|
+
exited = not force_terminate and self._wait_for_cleanup(
|
|
372
|
+
cleanup,
|
|
373
|
+
end,
|
|
374
|
+
_ABORT_GRACE_SECONDS,
|
|
375
|
+
)
|
|
376
|
+
if not exited:
|
|
377
|
+
self._terminate_before_release(cleanup, end)
|
|
378
|
+
|
|
379
|
+
cleanup.attempt(self._launcher_process._discard_status)
|
|
380
|
+
cleanup.attempt(self._close_job)
|
|
381
|
+
return cleanup.first_error
|
|
382
|
+
|
|
383
|
+
def abort_before_release(self) -> None:
|
|
384
|
+
"""Dispose of the blocked launcher without starting the real step.
|
|
385
|
+
|
|
386
|
+
Raises:
|
|
387
|
+
Exception: If launcher or Job Object cleanup fails.
|
|
388
|
+
"""
|
|
389
|
+
if error := self._dispose_before_release():
|
|
390
|
+
raise error
|
|
391
|
+
|
|
392
|
+
def _dispose_setup_failure(
|
|
393
|
+
self,
|
|
394
|
+
first_error: Exception | None = None,
|
|
395
|
+
*,
|
|
396
|
+
force_terminate: bool = False,
|
|
397
|
+
) -> Exception | None:
|
|
398
|
+
"""Dispose every owner before standard pipes reach the executor."""
|
|
399
|
+
cleanup = CleanupState(
|
|
400
|
+
self._dispose_before_release(
|
|
401
|
+
first_error,
|
|
402
|
+
force_terminate=force_terminate,
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
self._close_process_pipes(cleanup)
|
|
406
|
+
return cleanup.first_error
|
|
407
|
+
|
|
408
|
+
@property
|
|
409
|
+
def process(self) -> CompletionProcess:
|
|
410
|
+
"""Return a completion facade exposing the exact real-step result.
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
Launcher facade used to observe real-step completion.
|
|
414
|
+
"""
|
|
415
|
+
return self._launcher_process
|
|
416
|
+
|
|
417
|
+
def finish(self) -> None:
|
|
418
|
+
"""Confirm remaining job members have exited, then close the handle.
|
|
419
|
+
|
|
420
|
+
Raises:
|
|
421
|
+
Exception: If termination, completion, or handle cleanup fails.
|
|
422
|
+
The first failure is preserved while handle closure is tried.
|
|
423
|
+
"""
|
|
424
|
+
if self._job_handle is not None:
|
|
425
|
+
cleanup = CleanupState()
|
|
426
|
+
try:
|
|
427
|
+
with cleanup:
|
|
428
|
+
self._terminate_job_tree()
|
|
429
|
+
finally:
|
|
430
|
+
cleanup.attempt(self._close_job)
|
|
431
|
+
if cleanup.first_error is not None:
|
|
432
|
+
raise cleanup.first_error
|
|
433
|
+
elif self._process.poll() is None:
|
|
434
|
+
self._stop_unassigned_launcher(_ABORT_GRACE_SECONDS)
|
|
435
|
+
|
|
436
|
+
def forward_signal(self, signum: int) -> None:
|
|
437
|
+
"""Try Ctrl+Break for the launcher's console process group.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
signum: Wrapper signal, whose Windows console form is Ctrl+Break.
|
|
441
|
+
"""
|
|
442
|
+
del signum
|
|
443
|
+
if self._process.poll() is None:
|
|
444
|
+
self._api.generate_ctrl_break(self._process.pid)
|
|
445
|
+
|
|
446
|
+
@property
|
|
447
|
+
def _raw_process(self) -> subprocess.Popen[bytes]:
|
|
448
|
+
"""Return the blocked launcher for pre-release assignment."""
|
|
449
|
+
return self._process
|
|
450
|
+
|
|
451
|
+
def release(self) -> None:
|
|
452
|
+
"""Release after successful assignment or explicitly authorized uncontained fallback.
|
|
453
|
+
|
|
454
|
+
Raises:
|
|
455
|
+
_LauncherProtocolError: If the gate state, control frame, launcher
|
|
456
|
+
status, writer startup, or cleanup violates the protocol.
|
|
457
|
+
ContainmentReleaseError: If control-frame delivery times out or
|
|
458
|
+
fails before the launcher accepts it.
|
|
459
|
+
"""
|
|
460
|
+
if self._released or self._control_writer is None:
|
|
461
|
+
raise _LauncherProtocolError('launcher control gate was already released')
|
|
462
|
+
argv_bytes = json.dumps(
|
|
463
|
+
self._step.argv,
|
|
464
|
+
ensure_ascii=False,
|
|
465
|
+
separators=(',', ':'),
|
|
466
|
+
).encode()
|
|
467
|
+
if len(argv_bytes) > _ARGV_LIMIT_BYTES:
|
|
468
|
+
raise _LauncherProtocolError('launcher argv frame exceeds its bound')
|
|
469
|
+
frame = b'R' + len(argv_bytes).to_bytes(8, 'little') + argv_bytes
|
|
470
|
+
descriptor = self._control_writer
|
|
471
|
+
ownership_transferred = threading.Event()
|
|
472
|
+
result = _ControlWriteResult()
|
|
473
|
+
deadline = time.monotonic() + _RELEASE_DEADLINE_SECONDS
|
|
474
|
+
try:
|
|
475
|
+
writer = threading.Thread(
|
|
476
|
+
target=_deliver_control_frame,
|
|
477
|
+
args=(
|
|
478
|
+
ownership_transferred,
|
|
479
|
+
self._write_fd,
|
|
480
|
+
descriptor,
|
|
481
|
+
frame,
|
|
482
|
+
result,
|
|
483
|
+
),
|
|
484
|
+
name=f'sequential-hooks-control-writer-{self._step.index}',
|
|
485
|
+
daemon=True,
|
|
486
|
+
)
|
|
487
|
+
writer.start()
|
|
488
|
+
except RuntimeError as error:
|
|
489
|
+
raise _LauncherProtocolError('launcher control writer could not be started') from error
|
|
490
|
+
self._control_writer = None
|
|
491
|
+
ownership_transferred.set()
|
|
492
|
+
writer.join(
|
|
493
|
+
max(
|
|
494
|
+
0.0,
|
|
495
|
+
deadline - _RELEASE_ABORT_RESERVE_SECONDS - time.monotonic(),
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
if writer.is_alive():
|
|
499
|
+
release_error = ContainmentReleaseError('launcher control frame delivery timed out')
|
|
500
|
+
cleanup_error = self._dispose_before_release(
|
|
501
|
+
deadline=deadline,
|
|
502
|
+
force_terminate=True,
|
|
503
|
+
)
|
|
504
|
+
writer.join(max(0.0, deadline - time.monotonic()))
|
|
505
|
+
if cleanup_error is not None:
|
|
506
|
+
if isinstance(cleanup_error, _LauncherProtocolError):
|
|
507
|
+
raise cleanup_error
|
|
508
|
+
raise _LauncherProtocolError(
|
|
509
|
+
'launcher cleanup failed after control delivery timeout'
|
|
510
|
+
) from cleanup_error
|
|
511
|
+
raise release_error
|
|
512
|
+
if result._error is not None:
|
|
513
|
+
if result._bytes_written:
|
|
514
|
+
self._launcher_process._raise_release_invariant_if_available(deadline)
|
|
515
|
+
raise ContainmentReleaseError(
|
|
516
|
+
'launcher control frame could not be written'
|
|
517
|
+
) from result._error
|
|
518
|
+
self._released = True
|
|
519
|
+
|
|
520
|
+
@property
|
|
521
|
+
def stderr(self) -> StderrReader:
|
|
522
|
+
"""Return the launcher's inherited real-step stderr pipe.
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
Binary stderr reader inherited through the launcher.
|
|
526
|
+
"""
|
|
527
|
+
return cast('StderrReader', self._process.stderr)
|
|
528
|
+
|
|
529
|
+
@property
|
|
530
|
+
def stdin(self) -> BinaryIO:
|
|
531
|
+
"""Return the launcher's inherited real-step stdin pipe.
|
|
532
|
+
|
|
533
|
+
Returns:
|
|
534
|
+
Binary stdin writer inherited through the launcher.
|
|
535
|
+
"""
|
|
536
|
+
return cast('BinaryIO', self._process.stdin)
|
|
537
|
+
|
|
538
|
+
@property
|
|
539
|
+
def stdout(self) -> BinaryIO:
|
|
540
|
+
"""Return the launcher's inherited real-step stdout pipe.
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
Binary stdout reader inherited through the launcher.
|
|
544
|
+
"""
|
|
545
|
+
return cast('BinaryIO', self._process.stdout)
|
|
546
|
+
|
|
547
|
+
def terminate_tree(self, grace_seconds: float) -> None:
|
|
548
|
+
"""Wait for the launcher, then terminate and confirm all job exits.
|
|
549
|
+
|
|
550
|
+
Call `forward_signal()` first when a graceful console signal is
|
|
551
|
+
required. Without a retained Job Object, forced termination covers only
|
|
552
|
+
the launcher.
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
grace_seconds: Initial wait in seconds before forced termination.
|
|
556
|
+
|
|
557
|
+
Raises:
|
|
558
|
+
ContainmentError: If job completion cannot be confirmed in time.
|
|
559
|
+
OSError: If native job termination or accounting fails.
|
|
560
|
+
"""
|
|
561
|
+
self._launcher_process._discard_status()
|
|
562
|
+
if self._process.poll() is None:
|
|
563
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
564
|
+
self._process.wait(timeout=max(0.0, grace_seconds))
|
|
565
|
+
if self._assigned and self._job_handle is not None:
|
|
566
|
+
self._terminate_job_tree()
|
|
567
|
+
elif self._process.poll() is None:
|
|
568
|
+
self._stop_unassigned_launcher(grace_seconds)
|
|
569
|
+
|
|
570
|
+
@property
|
|
571
|
+
def uses_windows_exit_codes(self) -> bool:
|
|
572
|
+
"""Return whether the completion facade carries unsigned Windows status.
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
`True` because the facade exposes unsigned Windows exit codes.
|
|
576
|
+
"""
|
|
577
|
+
return True
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _create_gate_pipes() -> tuple[int, int, int, int]:
|
|
581
|
+
"""Create both anonymous pipes without leaking a partial first pair."""
|
|
582
|
+
control_reader, control_writer = os.pipe()
|
|
583
|
+
try:
|
|
584
|
+
status_reader, status_writer = os.pipe()
|
|
585
|
+
except OSError:
|
|
586
|
+
os.close(control_reader)
|
|
587
|
+
os.close(control_writer)
|
|
588
|
+
raise
|
|
589
|
+
return control_reader, control_writer, status_reader, status_writer
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _decode_status_frame(frame: bytes) -> tuple[int, str]:
|
|
593
|
+
"""Validate and decode one complete bounded launcher status frame."""
|
|
594
|
+
if len(frame) < _LAUNCHER_STATUS_HEADER_BYTES:
|
|
595
|
+
raise _LauncherProtocolError('launcher status frame is missing or truncated')
|
|
596
|
+
status = frame[0]
|
|
597
|
+
message_length = int.from_bytes(frame[1:_LAUNCHER_STATUS_HEADER_BYTES], 'little')
|
|
598
|
+
if message_length > _LAUNCHER_STATUS_LIMIT_BYTES - _LAUNCHER_STATUS_HEADER_BYTES:
|
|
599
|
+
raise _LauncherProtocolError('launcher status frame exceeds its bound')
|
|
600
|
+
if len(frame) != message_length + _LAUNCHER_STATUS_HEADER_BYTES:
|
|
601
|
+
raise _LauncherProtocolError('launcher status frame is truncated or has trailing bytes')
|
|
602
|
+
try:
|
|
603
|
+
message = frame[_LAUNCHER_STATUS_HEADER_BYTES:].decode('utf-8')
|
|
604
|
+
except UnicodeDecodeError as error:
|
|
605
|
+
raise _LauncherProtocolError('launcher status text is not UTF-8') from error
|
|
606
|
+
return status, message
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _deliver_control_frame(
|
|
610
|
+
ownership_transferred: threading.Event,
|
|
611
|
+
write_fd: Callable[[int, bytes], int],
|
|
612
|
+
descriptor: int,
|
|
613
|
+
frame: bytes,
|
|
614
|
+
result: _ControlWriteResult,
|
|
615
|
+
) -> None:
|
|
616
|
+
"""Own and deliver one control frame from a detached daemon."""
|
|
617
|
+
ownership_transferred.wait()
|
|
618
|
+
try:
|
|
619
|
+
while result._bytes_written < len(frame):
|
|
620
|
+
try:
|
|
621
|
+
written = write_fd(descriptor, frame[result._bytes_written :])
|
|
622
|
+
except OSError as error:
|
|
623
|
+
result._error = error
|
|
624
|
+
break
|
|
625
|
+
if written <= 0:
|
|
626
|
+
result._error = OSError('launcher control write made no progress')
|
|
627
|
+
break
|
|
628
|
+
result._bytes_written += written
|
|
629
|
+
finally:
|
|
630
|
+
try:
|
|
631
|
+
os.close(descriptor)
|
|
632
|
+
except OSError as error:
|
|
633
|
+
if result._error is None:
|
|
634
|
+
result._error = error
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _native_handle(fd: int) -> int:
|
|
638
|
+
"""Convert one C-runtime descriptor into its Windows kernel handle."""
|
|
639
|
+
msvcrt = __import__('msvcrt')
|
|
640
|
+
return int(msvcrt.get_osfhandle(fd))
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _set_handle_inheritable(handle: int, *, inheritable: bool) -> None:
|
|
644
|
+
"""Set one Windows kernel handle's temporary inheritance state."""
|
|
645
|
+
setter = cast('Callable[[int, bool], None]', vars(os)['set_handle_inheritable'])
|
|
646
|
+
setter(handle, inheritable)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _startup_info(handles: tuple[int, int]) -> object:
|
|
650
|
+
"""Build one restricted Windows inherited-handle list."""
|
|
651
|
+
startup_info_type = vars(subprocess)['STARTUPINFO']
|
|
652
|
+
startup_info = startup_info_type()
|
|
653
|
+
startup_info.lpAttributeList = {'handle_list': list(handles)}
|
|
654
|
+
return cast('object', startup_info)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@dataclass(frozen=True)
|
|
658
|
+
class _WindowsRuntime:
|
|
659
|
+
"""Inject process and descriptor operations for platform-neutral tests."""
|
|
660
|
+
|
|
661
|
+
_fd_to_handle: Callable[[int], int] = _native_handle
|
|
662
|
+
_popen_factory: _PopenFactory = subprocess.Popen
|
|
663
|
+
_set_handle_inheritable: Callable[..., None] = _set_handle_inheritable
|
|
664
|
+
_startup_info_factory: _StartupInfoFactory = _startup_info
|
|
665
|
+
_write_fd: Callable[[int, bytes], int] = os.write
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
class WindowsBackend:
|
|
669
|
+
"""Create launcher-gated native-Windows managed steps."""
|
|
670
|
+
|
|
671
|
+
def __init__(
|
|
672
|
+
self,
|
|
673
|
+
api: WindowsApi | None = None,
|
|
674
|
+
*,
|
|
675
|
+
runtime: _WindowsRuntime | None = None,
|
|
676
|
+
uncontained_backend: ContainmentBackend | None = None,
|
|
677
|
+
) -> None:
|
|
678
|
+
"""Select native Windows operations and explicit fallback behavior.
|
|
679
|
+
|
|
680
|
+
Args:
|
|
681
|
+
api: Windows API implementation, or the native implementation.
|
|
682
|
+
runtime: Process and descriptor operations, or their native
|
|
683
|
+
defaults.
|
|
684
|
+
uncontained_backend: Backend used for explicitly allowed fallback.
|
|
685
|
+
|
|
686
|
+
Raises:
|
|
687
|
+
OSError: If `api` is omitted and native Windows APIs are
|
|
688
|
+
unavailable.
|
|
689
|
+
"""
|
|
690
|
+
self._api = api or NativeWindowsApi()
|
|
691
|
+
self._runtime = runtime or _WindowsRuntime()
|
|
692
|
+
self._uncontained_backend = uncontained_backend or UncontainedBackend()
|
|
693
|
+
|
|
694
|
+
def _fallback_or_raise(
|
|
695
|
+
self,
|
|
696
|
+
step: Step,
|
|
697
|
+
*,
|
|
698
|
+
allow_uncontained: bool,
|
|
699
|
+
message: str,
|
|
700
|
+
) -> ManagedStep:
|
|
701
|
+
"""Return an explicit fallback or raise one containment refusal."""
|
|
702
|
+
if not allow_uncontained:
|
|
703
|
+
raise ContainmentError(message)
|
|
704
|
+
return self._uncontained_backend.start(step, allow_uncontained=True)
|
|
705
|
+
|
|
706
|
+
def _start_launcher(self, step: Step, job_handle: int) -> _WindowsManagedStep:
|
|
707
|
+
"""Start one isolated launcher with only its two gate handles inherited."""
|
|
708
|
+
control_reader, control_writer, status_reader, status_writer = _create_gate_pipes()
|
|
709
|
+
inheritable_handles: list[int] = []
|
|
710
|
+
process: subprocess.Popen[bytes]
|
|
711
|
+
try:
|
|
712
|
+
try:
|
|
713
|
+
control_handle = self._runtime._fd_to_handle(control_reader)
|
|
714
|
+
status_handle = self._runtime._fd_to_handle(status_writer)
|
|
715
|
+
for handle in (control_handle, status_handle):
|
|
716
|
+
self._runtime._set_handle_inheritable(handle, inheritable=True)
|
|
717
|
+
inheritable_handles.append(handle)
|
|
718
|
+
command = (
|
|
719
|
+
sys.executable,
|
|
720
|
+
'-I',
|
|
721
|
+
'-S',
|
|
722
|
+
str(_LAUNCHER_PATH),
|
|
723
|
+
str(control_handle),
|
|
724
|
+
str(status_handle),
|
|
725
|
+
)
|
|
726
|
+
process = self._runtime._popen_factory(
|
|
727
|
+
command,
|
|
728
|
+
stdin=subprocess.PIPE,
|
|
729
|
+
stdout=subprocess.PIPE,
|
|
730
|
+
stderr=subprocess.PIPE,
|
|
731
|
+
shell=False,
|
|
732
|
+
close_fds=True,
|
|
733
|
+
creationflags=_CREATE_NEW_PROCESS_GROUP,
|
|
734
|
+
startupinfo=self._runtime._startup_info_factory(
|
|
735
|
+
(control_handle, status_handle)
|
|
736
|
+
),
|
|
737
|
+
)
|
|
738
|
+
finally:
|
|
739
|
+
for handle in inheritable_handles:
|
|
740
|
+
with contextlib.suppress(OSError):
|
|
741
|
+
self._runtime._set_handle_inheritable(handle, inheritable=False)
|
|
742
|
+
for descriptor in (control_reader, status_writer):
|
|
743
|
+
with contextlib.suppress(OSError):
|
|
744
|
+
os.close(descriptor)
|
|
745
|
+
except Exception:
|
|
746
|
+
for descriptor in (control_writer, status_reader):
|
|
747
|
+
with contextlib.suppress(OSError):
|
|
748
|
+
os.close(descriptor)
|
|
749
|
+
raise
|
|
750
|
+
managed = _WindowsManagedStep(
|
|
751
|
+
self._api,
|
|
752
|
+
process,
|
|
753
|
+
step,
|
|
754
|
+
_ManagedResources(
|
|
755
|
+
control_writer,
|
|
756
|
+
job_handle,
|
|
757
|
+
status_reader,
|
|
758
|
+
self._runtime._write_fd,
|
|
759
|
+
),
|
|
760
|
+
)
|
|
761
|
+
if process.stdin is None or process.stdout is None or process.stderr is None:
|
|
762
|
+
error = _LauncherProtocolError('launcher standard pipes were not created')
|
|
763
|
+
managed._dispose_setup_failure(error, force_terminate=True)
|
|
764
|
+
raise error
|
|
765
|
+
return managed
|
|
766
|
+
|
|
767
|
+
def recover_release_failure(
|
|
768
|
+
self,
|
|
769
|
+
step: Step,
|
|
770
|
+
failed: ManagedStep,
|
|
771
|
+
*,
|
|
772
|
+
allow_uncontained: bool,
|
|
773
|
+
) -> ManagedStep:
|
|
774
|
+
"""Return a direct uncontained fallback after a disposed gate.
|
|
775
|
+
|
|
776
|
+
Args:
|
|
777
|
+
step: Step whose control frame could not be delivered.
|
|
778
|
+
failed: Failed launcher already disposed by the executor.
|
|
779
|
+
allow_uncontained: Whether direct fallback is explicitly allowed.
|
|
780
|
+
|
|
781
|
+
Returns:
|
|
782
|
+
Fresh direct uncontained process.
|
|
783
|
+
|
|
784
|
+
Raises:
|
|
785
|
+
ContainmentError: If direct fallback was not explicitly allowed.
|
|
786
|
+
"""
|
|
787
|
+
del failed
|
|
788
|
+
return self._fallback_or_raise(
|
|
789
|
+
step,
|
|
790
|
+
allow_uncontained=allow_uncontained,
|
|
791
|
+
message='containment release failed without explicit override',
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
def start(self, step: Step, *, allow_uncontained: bool) -> ManagedStep:
|
|
795
|
+
"""Create, assign, and retain one blocked Windows launcher.
|
|
796
|
+
|
|
797
|
+
Args:
|
|
798
|
+
step: Literal real-step argv.
|
|
799
|
+
allow_uncontained: Whether exact setup failures may use the
|
|
800
|
+
approved reduced-guarantee row.
|
|
801
|
+
|
|
802
|
+
Returns:
|
|
803
|
+
Assigned blocked launcher, or an explicitly allowed fallback.
|
|
804
|
+
|
|
805
|
+
Raises:
|
|
806
|
+
ContainmentError: If setup failed without an applicable override.
|
|
807
|
+
_LauncherProtocolError: If launcher setup violates its protocol or
|
|
808
|
+
invariants.
|
|
809
|
+
"""
|
|
810
|
+
try:
|
|
811
|
+
job_handle = self._api.create_kill_on_close_job()
|
|
812
|
+
except OSError as error:
|
|
813
|
+
return self._fallback_or_raise(
|
|
814
|
+
step,
|
|
815
|
+
allow_uncontained=allow_uncontained,
|
|
816
|
+
message=f'Job Object creation failed: {error}',
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
try:
|
|
820
|
+
managed = self._start_launcher(step, job_handle)
|
|
821
|
+
except _LauncherProtocolError:
|
|
822
|
+
raise
|
|
823
|
+
except OSError as error:
|
|
824
|
+
with contextlib.suppress(OSError):
|
|
825
|
+
self._api.close_handle(job_handle)
|
|
826
|
+
return self._fallback_or_raise(
|
|
827
|
+
step,
|
|
828
|
+
allow_uncontained=allow_uncontained,
|
|
829
|
+
message=f'launcher creation failed: {error}',
|
|
830
|
+
)
|
|
831
|
+
except Exception:
|
|
832
|
+
with contextlib.suppress(OSError):
|
|
833
|
+
self._api.close_handle(job_handle)
|
|
834
|
+
raise
|
|
835
|
+
|
|
836
|
+
try:
|
|
837
|
+
self._api.assign_pid(job_handle, managed._raw_process.pid)
|
|
838
|
+
except OSError as error:
|
|
839
|
+
if allow_uncontained:
|
|
840
|
+
try:
|
|
841
|
+
managed._release_job()
|
|
842
|
+
except OSError:
|
|
843
|
+
managed._dispose_setup_failure(error)
|
|
844
|
+
raise ContainmentError(f'Job Object assignment failed: {error}') from error
|
|
845
|
+
else:
|
|
846
|
+
return managed
|
|
847
|
+
managed._dispose_setup_failure(error)
|
|
848
|
+
raise ContainmentError(f'Job Object assignment failed: {error}') from error
|
|
849
|
+
except Exception as error:
|
|
850
|
+
managed._dispose_setup_failure(error)
|
|
851
|
+
raise
|
|
852
|
+
managed._mark_assigned()
|
|
853
|
+
return managed
|