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.
Files changed (43) hide show
  1. plugins/agy/_sequential_hooks/__init__.py +1 -0
  2. plugins/agy/_sequential_hooks/_adapter.py +566 -0
  3. plugins/agy/_sequential_hooks/_doctor.py +413 -0
  4. plugins/claude/_sequential_hooks/__init__.py +1 -0
  5. plugins/claude/_sequential_hooks/_adapter.py +560 -0
  6. plugins/claude/_sequential_hooks/_doctor.py +455 -0
  7. plugins/codex/_sequential_hooks/__init__.py +1 -0
  8. plugins/codex/_sequential_hooks/_adapter.py +466 -0
  9. plugins/codex/_sequential_hooks/_doctor.py +563 -0
  10. sequential_hooks/__init__.py +3 -0
  11. sequential_hooks/__main__.py +5 -0
  12. sequential_hooks/_arguments.py +222 -0
  13. sequential_hooks/_cleanup.py +88 -0
  14. sequential_hooks/_cli.py +440 -0
  15. sequential_hooks/_containment/__init__.py +410 -0
  16. sequential_hooks/_containment/_posix.py +236 -0
  17. sequential_hooks/_containment/_uncontained.py +239 -0
  18. sequential_hooks/_containment/_windows.py +853 -0
  19. sequential_hooks/_containment/_windows_api.py +570 -0
  20. sequential_hooks/_containment/_windows_launcher.py +189 -0
  21. sequential_hooks/_diagnostics.py +265 -0
  22. sequential_hooks/_doctor/__init__.py +342 -0
  23. sequential_hooks/_doctor/_command.py +469 -0
  24. sequential_hooks/_doctor/_common.py +448 -0
  25. sequential_hooks/_doctor/_types.py +94 -0
  26. sequential_hooks/_downstream.py +181 -0
  27. sequential_hooks/_executable.py +187 -0
  28. sequential_hooks/_executor.py +825 -0
  29. sequential_hooks/_registry.py +103 -0
  30. sequential_hooks/_runner.py +48 -0
  31. sequential_hooks/_types.py +125 -0
  32. sequential_hooks/hosts/__init__.py +130 -0
  33. sequential_hooks/hosts/_contract.py +434 -0
  34. sequential_hooks/hosts/_inspection.py +78 -0
  35. sequential_hooks/hosts/_json.py +150 -0
  36. sequential_hooks/hosts/_records.py +273 -0
  37. sequential_hooks/hosts/_skeleton.py +1326 -0
  38. sequential_hooks/py.typed +0 -0
  39. sequential_hooks-0.1.0.dist-info/METADATA +90 -0
  40. sequential_hooks-0.1.0.dist-info/RECORD +43 -0
  41. sequential_hooks-0.1.0.dist-info/WHEEL +4 -0
  42. sequential_hooks-0.1.0.dist-info/entry_points.txt +7 -0
  43. 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