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