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,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)