h2h-dev-tools 0.2.1__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.
@@ -0,0 +1,873 @@
1
+ """Own commands through POSIX sessions or start-gated Windows Job Objects.
2
+
3
+ A successful command requires an empty owned tree, not merely an exited leader.
4
+ Callers own their command selection, absolute deadline and cleanup reserve.
5
+ Detached POSIX sessions and daemon-owned resources are outside this contract.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ctypes
11
+ import math
12
+ import ntpath
13
+ import os
14
+ import shlex
15
+ import signal
16
+ import subprocess
17
+ import sys
18
+ import time
19
+ from collections.abc import Callable, Iterator, Sequence
20
+ from contextlib import contextmanager, nullcontext
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from types import FrameType
24
+ from typing import Any, BinaryIO, Final, cast
25
+
26
+ TERMINATION_GRACE_SECONDS = 2.0
27
+ TERMINATION_POLL_SECONDS = 0.05
28
+ TIMEOUT_EXIT_CODE = 124
29
+ TERMINATION_FAILED_EXIT_CODE = 125
30
+ INTERRUPTED_EXIT_CODE = 130
31
+ _WINDOWS_SUPERVISOR_MODE: Final = "--internal-windows-supervisor"
32
+ _WINDOWS_START_TOKEN: Final = b"\x01"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ProcessCommand:
37
+ """Explicit command and execution context; no repository discovery."""
38
+
39
+ label: str
40
+ argv: tuple[str, ...]
41
+ cwd: Path
42
+ environment: dict[str, str] | None = None
43
+ stdout: BinaryIO | int | None = None
44
+ stderr: BinaryIO | int | None = None
45
+
46
+
47
+ class RunnerSignalInterrupt(BaseException):
48
+ """A POSIX termination signal converted into controlled runner shutdown."""
49
+
50
+ def __init__(self, signal_number: int) -> None:
51
+ super().__init__(signal_number)
52
+ self.signal_number = signal_number
53
+
54
+
55
+ class ProcessOwnershipError(RuntimeError):
56
+ """The runner could not verify that its managed command process tree exited."""
57
+
58
+
59
+ @dataclass
60
+ class SignalController:
61
+ """Defer asynchronous interruption only across ownership-critical regions."""
62
+
63
+ pending_signal: int | None = None
64
+ defer_depth: int = 0
65
+ interruption_started: bool = False
66
+
67
+ def receive(self, signal_number: int) -> None:
68
+ if self.interruption_started:
69
+ return
70
+ if self.defer_depth:
71
+ if self.pending_signal is None:
72
+ self.pending_signal = signal_number
73
+ return
74
+ self._raise(signal_number)
75
+
76
+ @contextmanager
77
+ def defer(self) -> Iterator[None]:
78
+ self.defer_depth += 1
79
+ body_failed = True
80
+ try:
81
+ yield
82
+ body_failed = False
83
+ finally:
84
+ self.defer_depth -= 1
85
+ if self.defer_depth == 0 and not body_failed:
86
+ self.raise_pending()
87
+
88
+ def raise_pending(self) -> None:
89
+ if self.pending_signal is None:
90
+ return
91
+ pending = self.pending_signal
92
+ self.pending_signal = None
93
+ self._raise(pending)
94
+
95
+ def _raise(self, signal_number: int) -> None:
96
+ self.interruption_started = True
97
+ if signal_number == signal.SIGINT:
98
+ raise KeyboardInterrupt
99
+ raise RunnerSignalInterrupt(signal_number)
100
+
101
+
102
+ @contextmanager
103
+ def controlled_termination_signals(
104
+ *, posix_signals: tuple[int, ...] | None = None
105
+ ) -> Iterator[SignalController]:
106
+ """Control platform termination signals and restore prior handlers."""
107
+
108
+ signal_numbers: tuple[int, ...]
109
+ if os.name == "nt":
110
+ break_signal = cast(int | None, getattr(signal, "SIGBREAK", None))
111
+ if break_signal is None:
112
+ raise RuntimeError("Windows requires SIGBREAK support")
113
+ signal_numbers = (signal.SIGINT, break_signal)
114
+ else:
115
+ signal_numbers = (
116
+ (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
117
+ if posix_signals is None
118
+ else posix_signals
119
+ )
120
+ previous_handlers = [
121
+ (signal_number, signal.getsignal(signal_number))
122
+ for signal_number in signal_numbers
123
+ ]
124
+ controller = SignalController()
125
+
126
+ def interrupt(signal_number: int, frame: FrameType | None) -> None:
127
+ del frame
128
+ controller.receive(signal_number)
129
+
130
+ installed: list[int] = []
131
+ try:
132
+ for signal_number, _previous_handler in previous_handlers:
133
+ signal.signal(signal_number, interrupt)
134
+ installed.append(signal_number)
135
+ yield controller
136
+ finally:
137
+ previous_by_signal = dict(previous_handlers)
138
+ for signal_number in reversed(installed):
139
+ signal.signal(signal_number, previous_by_signal[signal_number])
140
+
141
+
142
+ @contextmanager
143
+ def defer_signal_interrupts(
144
+ controller: SignalController | None,
145
+ ) -> Iterator[None]:
146
+ if controller is None:
147
+ yield
148
+ return
149
+ with controller.defer():
150
+ yield
151
+
152
+
153
+ class WindowsJob:
154
+ """A non-inheritable Windows Job with kill-on-owner-close semantics."""
155
+
156
+ _KILL_ON_JOB_CLOSE: Final = 0x00002000
157
+ _EXTENDED_LIMIT_INFORMATION: Final = 9
158
+ _BASIC_ACCOUNTING_INFORMATION: Final = 1
159
+
160
+ def __init__(
161
+ self,
162
+ kernel32: Any,
163
+ handle: int,
164
+ accounting_information_type: type[ctypes.Structure],
165
+ ) -> None:
166
+ self._kernel32 = kernel32
167
+ self._handle: int | None = handle
168
+ self._accounting_information_type = accounting_information_type
169
+
170
+ @classmethod
171
+ def create(cls) -> WindowsJob:
172
+ if os.name != "nt":
173
+ raise RuntimeError("Windows Job Objects are unavailable")
174
+ from ctypes import wintypes
175
+
176
+ class _IoCounters(ctypes.Structure):
177
+ _fields_ = [
178
+ ("ReadOperationCount", ctypes.c_ulonglong),
179
+ ("WriteOperationCount", ctypes.c_ulonglong),
180
+ ("OtherOperationCount", ctypes.c_ulonglong),
181
+ ("ReadTransferCount", ctypes.c_ulonglong),
182
+ ("WriteTransferCount", ctypes.c_ulonglong),
183
+ ("OtherTransferCount", ctypes.c_ulonglong),
184
+ ]
185
+
186
+ class _BasicLimitInformation(ctypes.Structure):
187
+ _fields_ = [
188
+ ("PerProcessUserTimeLimit", ctypes.c_longlong),
189
+ ("PerJobUserTimeLimit", ctypes.c_longlong),
190
+ ("LimitFlags", wintypes.DWORD),
191
+ ("MinimumWorkingSetSize", ctypes.c_size_t),
192
+ ("MaximumWorkingSetSize", ctypes.c_size_t),
193
+ ("ActiveProcessLimit", wintypes.DWORD),
194
+ ("Affinity", ctypes.c_size_t),
195
+ ("PriorityClass", wintypes.DWORD),
196
+ ("SchedulingClass", wintypes.DWORD),
197
+ ]
198
+
199
+ class _ExtendedLimitInformation(ctypes.Structure):
200
+ _fields_ = [
201
+ ("BasicLimitInformation", _BasicLimitInformation),
202
+ ("IoInfo", _IoCounters),
203
+ ("ProcessMemoryLimit", ctypes.c_size_t),
204
+ ("JobMemoryLimit", ctypes.c_size_t),
205
+ ("PeakProcessMemoryUsed", ctypes.c_size_t),
206
+ ("PeakJobMemoryUsed", ctypes.c_size_t),
207
+ ]
208
+
209
+ class _BasicAccountingInformation(ctypes.Structure):
210
+ _fields_ = [
211
+ ("TotalUserTime", ctypes.c_longlong),
212
+ ("TotalKernelTime", ctypes.c_longlong),
213
+ ("ThisPeriodTotalUserTime", ctypes.c_longlong),
214
+ ("ThisPeriodTotalKernelTime", ctypes.c_longlong),
215
+ ("TotalPageFaultCount", wintypes.DWORD),
216
+ ("TotalProcesses", wintypes.DWORD),
217
+ ("ActiveProcesses", wintypes.DWORD),
218
+ ("TotalTerminatedProcesses", wintypes.DWORD),
219
+ ]
220
+
221
+ win_dll = getattr(ctypes, "WinDLL", None)
222
+ if not callable(win_dll):
223
+ raise RuntimeError("Windows Job Object APIs are unavailable")
224
+ kernel32 = win_dll("kernel32", use_last_error=True)
225
+ kernel32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
226
+ kernel32.CreateJobObjectW.restype = wintypes.HANDLE
227
+ kernel32.SetInformationJobObject.argtypes = [
228
+ wintypes.HANDLE,
229
+ ctypes.c_int,
230
+ ctypes.c_void_p,
231
+ wintypes.DWORD,
232
+ ]
233
+ kernel32.SetInformationJobObject.restype = wintypes.BOOL
234
+ kernel32.AssignProcessToJobObject.argtypes = [
235
+ wintypes.HANDLE,
236
+ wintypes.HANDLE,
237
+ ]
238
+ kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
239
+ kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT]
240
+ kernel32.TerminateJobObject.restype = wintypes.BOOL
241
+ kernel32.QueryInformationJobObject.argtypes = [
242
+ wintypes.HANDLE,
243
+ ctypes.c_int,
244
+ ctypes.c_void_p,
245
+ wintypes.DWORD,
246
+ ctypes.c_void_p,
247
+ ]
248
+ kernel32.QueryInformationJobObject.restype = wintypes.BOOL
249
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
250
+ kernel32.CloseHandle.restype = wintypes.BOOL
251
+
252
+ raw_handle = kernel32.CreateJobObjectW(None, None)
253
+ if not raw_handle:
254
+ raise cls._last_error("CreateJobObjectW")
255
+ handle = int(raw_handle)
256
+ information = _ExtendedLimitInformation()
257
+ information.BasicLimitInformation.LimitFlags = cls._KILL_ON_JOB_CLOSE
258
+ if not kernel32.SetInformationJobObject(
259
+ handle,
260
+ cls._EXTENDED_LIMIT_INFORMATION,
261
+ ctypes.byref(information),
262
+ ctypes.sizeof(information),
263
+ ):
264
+ error = cls._last_error("SetInformationJobObject")
265
+ kernel32.CloseHandle(handle)
266
+ raise error
267
+ return cls(kernel32, handle, _BasicAccountingInformation)
268
+
269
+ @staticmethod
270
+ def _last_error(operation: str) -> OSError:
271
+ get_last_error = cast(Any, getattr(ctypes, "get_last_error", lambda: 0))
272
+ code = int(get_last_error())
273
+ return OSError(code, f"{operation} failed with Windows error {code}")
274
+
275
+ def assign(self, process: subprocess.Popen[bytes]) -> None:
276
+ process_handle = getattr(process, "_handle", None)
277
+ if process_handle is None:
278
+ raise RuntimeError("Python did not expose the child process handle")
279
+ if self._handle is None:
280
+ raise RuntimeError("Windows Job Object is already closed")
281
+ if not self._kernel32.AssignProcessToJobObject(
282
+ self._handle,
283
+ int(process_handle),
284
+ ):
285
+ raise self._last_error("AssignProcessToJobObject")
286
+
287
+ def terminate(self) -> None:
288
+ if self._handle is None:
289
+ return
290
+ if not self._kernel32.TerminateJobObject(self._handle, 1):
291
+ raise self._last_error("TerminateJobObject")
292
+
293
+ def active_processes(self) -> int:
294
+ if self._handle is None:
295
+ return 0
296
+ information = self._accounting_information_type()
297
+ if not self._kernel32.QueryInformationJobObject(
298
+ self._handle,
299
+ self._BASIC_ACCOUNTING_INFORMATION,
300
+ ctypes.byref(information),
301
+ ctypes.sizeof(information),
302
+ None,
303
+ ):
304
+ raise self._last_error("QueryInformationJobObject")
305
+ return int(information.ActiveProcesses)
306
+
307
+ def wait_empty(self, *, deadline: float) -> bool:
308
+ while self.active_processes() != 0:
309
+ remaining_seconds = seconds_remaining(deadline)
310
+ if remaining_seconds == 0:
311
+ return False
312
+ time.sleep(min(TERMINATION_POLL_SECONDS, remaining_seconds))
313
+ return True
314
+
315
+ def close(self) -> None:
316
+ handle = self._handle
317
+ if handle is None:
318
+ return
319
+ if not self._kernel32.CloseHandle(handle):
320
+ raise self._last_error("CloseHandle")
321
+ self._handle = None
322
+
323
+
324
+ @dataclass(frozen=True)
325
+ class OwnedProcess:
326
+ process: subprocess.Popen[bytes]
327
+ windows_job: WindowsJob | None = None
328
+
329
+
330
+ def _start_posix_process(phase: ProcessCommand) -> subprocess.Popen[bytes]:
331
+ command = phase.argv
332
+ return subprocess.Popen(
333
+ command,
334
+ cwd=phase.cwd,
335
+ env=phase.environment,
336
+ stdout=phase.stdout,
337
+ stderr=phase.stderr,
338
+ start_new_session=True,
339
+ )
340
+
341
+
342
+ def _windows_supervisor_launch(phase: ProcessCommand) -> tuple[str, dict[str, str]]:
343
+ executable = sys.executable
344
+ base_executable = getattr(sys, "_base_executable", None)
345
+ if not isinstance(base_executable, str) or not base_executable:
346
+ raise RuntimeError("Windows Python did not expose its base executable")
347
+ environment = dict(os.environ if phase.environment is None else phase.environment)
348
+ environment.pop("__PYVENV_LAUNCHER__", None)
349
+ if ntpath.normcase(ntpath.normpath(base_executable)) != ntpath.normcase(
350
+ ntpath.normpath(executable)
351
+ ):
352
+ if not Path(base_executable).is_file():
353
+ raise RuntimeError("Windows Python base executable is unavailable")
354
+ executable = base_executable
355
+ return executable, environment
356
+
357
+
358
+ def _cleanup_windows_supervisor(
359
+ process: subprocess.Popen[bytes],
360
+ job: WindowsJob,
361
+ *,
362
+ assigned: bool,
363
+ deadline: float,
364
+ ) -> bool:
365
+ cleanup_succeeded = True
366
+ try:
367
+ if process.stdin is not None:
368
+ try:
369
+ process.stdin.close()
370
+ except OSError:
371
+ cleanup_succeeded = False
372
+ if assigned:
373
+ try:
374
+ job.terminate()
375
+ except OSError:
376
+ cleanup_succeeded = False
377
+ try:
378
+ cleanup_succeeded = (
379
+ job.wait_empty(deadline=deadline) and cleanup_succeeded
380
+ )
381
+ except OSError:
382
+ cleanup_succeeded = False
383
+ elif process.poll() is None:
384
+ try:
385
+ process.kill()
386
+ except OSError:
387
+ cleanup_succeeded = False
388
+ try:
389
+ process.wait(timeout=seconds_remaining(deadline))
390
+ except OSError, subprocess.TimeoutExpired:
391
+ cleanup_succeeded = False
392
+ finally:
393
+ # Startup can fail before an OwnedProcess is returned. Its local Job
394
+ # still needs the kill-on-close backstop if waiting or probing raises.
395
+ try:
396
+ job.close()
397
+ except OSError:
398
+ cleanup_succeeded = False
399
+ return cleanup_succeeded and process.poll() is not None
400
+
401
+
402
+ def _start_windows_process(
403
+ phase: ProcessCommand,
404
+ *,
405
+ deadline: float,
406
+ signal_controller: SignalController | None,
407
+ ) -> OwnedProcess:
408
+ command = phase.argv
409
+ creation_flag = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
410
+ if not isinstance(creation_flag, int) or creation_flag == 0:
411
+ raise ProcessOwnershipError(phase.label) from None
412
+ job: WindowsJob | None = None
413
+ supervisor: subprocess.Popen[bytes] | None = None
414
+ assigned = False
415
+ cleanup_performed = False
416
+ try:
417
+ with defer_signal_interrupts(signal_controller):
418
+ try:
419
+ job = WindowsJob.create()
420
+ executable, environment = _windows_supervisor_launch(phase)
421
+ supervisor = subprocess.Popen(
422
+ (
423
+ executable,
424
+ str(Path(__file__).resolve()),
425
+ _WINDOWS_SUPERVISOR_MODE,
426
+ *command,
427
+ ),
428
+ cwd=phase.cwd,
429
+ env=environment,
430
+ stdout=phase.stdout,
431
+ stderr=phase.stderr,
432
+ stdin=subprocess.PIPE,
433
+ creationflags=creation_flag,
434
+ )
435
+ job.assign(supervisor)
436
+ assigned = True
437
+ if signal_controller is not None:
438
+ signal_controller.raise_pending()
439
+ if supervisor.stdin is None:
440
+ raise RuntimeError(
441
+ "Windows command supervisor start gate is unavailable"
442
+ )
443
+ written = supervisor.stdin.write(_WINDOWS_START_TOKEN)
444
+ if written != len(_WINDOWS_START_TOKEN):
445
+ raise BlockingIOError(
446
+ "Windows command supervisor start gate was partial"
447
+ )
448
+ supervisor.stdin.close()
449
+ except BaseException as startup_error:
450
+ cleaned = True
451
+ if job is None:
452
+ cleaned = supervisor is None
453
+ elif supervisor is None:
454
+ try:
455
+ job.close()
456
+ except OSError:
457
+ cleaned = False
458
+ else:
459
+ cleanup_performed = True
460
+ cleaned = _cleanup_windows_supervisor(
461
+ supervisor,
462
+ job,
463
+ assigned=assigned,
464
+ deadline=deadline,
465
+ )
466
+ if not cleaned:
467
+ raise ProcessOwnershipError(phase.label) from startup_error
468
+ if signal_controller is not None:
469
+ signal_controller.raise_pending()
470
+ if isinstance(
471
+ startup_error,
472
+ (KeyboardInterrupt, RunnerSignalInterrupt),
473
+ ):
474
+ raise
475
+ raise ProcessOwnershipError(phase.label) from startup_error
476
+ return OwnedProcess(supervisor, job)
477
+ except (KeyboardInterrupt, RunnerSignalInterrupt) as startup_error:
478
+ if job is not None and supervisor is not None and not cleanup_performed:
479
+ cleaned = _cleanup_windows_supervisor(
480
+ supervisor,
481
+ job,
482
+ assigned=assigned,
483
+ deadline=deadline,
484
+ )
485
+ if not cleaned:
486
+ raise ProcessOwnershipError(phase.label) from startup_error
487
+ raise
488
+
489
+
490
+ def start_owned_process(
491
+ phase: ProcessCommand,
492
+ *,
493
+ deadline: float,
494
+ signal_controller: SignalController | None,
495
+ ) -> OwnedProcess:
496
+ if os.name == "nt":
497
+ return _start_windows_process(
498
+ phase,
499
+ deadline=deadline,
500
+ signal_controller=signal_controller,
501
+ )
502
+ try:
503
+ return OwnedProcess(_start_posix_process(phase))
504
+ except OSError as error:
505
+ raise ProcessOwnershipError(phase.label) from error
506
+
507
+
508
+ def seconds_remaining(deadline: float) -> float:
509
+ return max(0.0, deadline - time.monotonic())
510
+
511
+
512
+ def _wait_for_process_until(
513
+ process: subprocess.Popen[bytes], *, deadline: float
514
+ ) -> bool:
515
+ remaining_seconds = seconds_remaining(deadline)
516
+ if remaining_seconds == 0:
517
+ return process.poll() is not None
518
+ try:
519
+ process.wait(timeout=remaining_seconds)
520
+ except subprocess.TimeoutExpired:
521
+ return False
522
+ return True
523
+
524
+
525
+ def _wait_for_posix_process_group_exit(
526
+ process: subprocess.Popen[bytes],
527
+ *,
528
+ kill_process_group: Callable[[int, int], None],
529
+ deadline: float,
530
+ ) -> bool:
531
+ while True:
532
+ # poll() reaps an exited leader; group membership, rather than leader
533
+ # state, remains the authority for whether descendants survived.
534
+ process.poll()
535
+ try:
536
+ kill_process_group(process.pid, 0)
537
+ except ProcessLookupError:
538
+ return _wait_for_process_until(process, deadline=deadline)
539
+ except PermissionError:
540
+ pass
541
+ remaining_seconds = seconds_remaining(deadline)
542
+ if remaining_seconds <= 0:
543
+ return False
544
+ time.sleep(min(TERMINATION_POLL_SECONDS, remaining_seconds))
545
+
546
+
547
+ def _terminate_posix_process_group(
548
+ process: subprocess.Popen[bytes], *, deadline: float
549
+ ) -> bool:
550
+ kill_process_group = cast(
551
+ Callable[[int, int], None] | None,
552
+ getattr(os, "killpg", None),
553
+ )
554
+ if kill_process_group is None:
555
+ return False
556
+
557
+ try:
558
+ kill_process_group(process.pid, signal.SIGTERM)
559
+ except ProcessLookupError:
560
+ return _wait_for_process_until(process, deadline=deadline)
561
+ except OSError:
562
+ return False
563
+ now = time.monotonic()
564
+ graceful_deadline = min(
565
+ deadline,
566
+ now + min(TERMINATION_GRACE_SECONDS, max(0.0, deadline - now) / 2),
567
+ )
568
+ if _wait_for_posix_process_group_exit(
569
+ process,
570
+ kill_process_group=kill_process_group,
571
+ deadline=graceful_deadline,
572
+ ):
573
+ return True
574
+ try:
575
+ kill_process_group(process.pid, signal.SIGKILL)
576
+ except ProcessLookupError:
577
+ return _wait_for_process_until(process, deadline=deadline)
578
+ except OSError:
579
+ return False
580
+ return _wait_for_posix_process_group_exit(
581
+ process,
582
+ kill_process_group=kill_process_group,
583
+ deadline=deadline,
584
+ )
585
+
586
+
587
+ def wait_for_owned_tree_exit(
588
+ owner: OwnedProcess,
589
+ *,
590
+ deadline: float,
591
+ ) -> bool:
592
+ if owner.windows_job is not None:
593
+ try:
594
+ return owner.windows_job.wait_empty(
595
+ deadline=deadline
596
+ ) and _wait_for_process_until(
597
+ owner.process,
598
+ deadline=deadline,
599
+ )
600
+ except OSError:
601
+ return False
602
+ kill_process_group = cast(
603
+ Callable[[int, int], None] | None,
604
+ getattr(os, "killpg", None),
605
+ )
606
+ if kill_process_group is None:
607
+ return False
608
+ return _wait_for_posix_process_group_exit(
609
+ owner.process,
610
+ kill_process_group=kill_process_group,
611
+ deadline=deadline,
612
+ )
613
+
614
+
615
+ def _report_diagnostic(message: str) -> None:
616
+ """Keep a failed diagnostic stream from interrupting ownership cleanup."""
617
+
618
+ try:
619
+ print(message, file=sys.stderr, flush=True)
620
+ except Exception:
621
+ # Diagnostics are secondary to process ownership. Even an unusual stream
622
+ # implementation must not prevent termination, fallback or Job closing.
623
+ pass
624
+
625
+
626
+ def _taskkill_windows_tree(
627
+ process: subprocess.Popen[bytes], *, deadline: float
628
+ ) -> bool:
629
+ remaining_seconds = seconds_remaining(deadline)
630
+ if remaining_seconds == 0:
631
+ return False
632
+ try:
633
+ completed = subprocess.run(
634
+ ("taskkill", "/PID", str(process.pid), "/T", "/F"),
635
+ check=False,
636
+ capture_output=True,
637
+ text=True,
638
+ timeout=min(TERMINATION_GRACE_SECONDS, remaining_seconds),
639
+ )
640
+ except OSError, subprocess.TimeoutExpired:
641
+ return False
642
+ if completed.returncode != 0:
643
+ detail = completed.stderr.strip() or completed.stdout.strip()
644
+ _report_diagnostic(
645
+ f"taskkill failed with exit {completed.returncode}"
646
+ + (f": {detail}" if detail else "")
647
+ )
648
+ return False
649
+ return True
650
+
651
+
652
+ def _terminate_windows_tree(
653
+ owner: OwnedProcess,
654
+ *,
655
+ deadline: float,
656
+ ) -> bool:
657
+ job = owner.windows_job
658
+ if job is None or seconds_remaining(deadline) == 0:
659
+ return False
660
+ try:
661
+ job.terminate()
662
+ except OSError as error:
663
+ _report_diagnostic(str(error))
664
+ if not _taskkill_windows_tree(owner.process, deadline=deadline):
665
+ return False
666
+ return wait_for_owned_tree_exit(owner, deadline=deadline)
667
+
668
+
669
+ def terminate_owned_tree(
670
+ owner: OwnedProcess,
671
+ *,
672
+ deadline: float,
673
+ ) -> bool:
674
+ """Terminate and verify the managed command process tree before deadline."""
675
+
676
+ if owner.windows_job is not None:
677
+ return _terminate_windows_tree(owner, deadline=deadline)
678
+ return _terminate_posix_process_group(owner.process, deadline=deadline)
679
+
680
+
681
+ def close_owner(owner: OwnedProcess) -> bool:
682
+ job = owner.windows_job
683
+ if job is None:
684
+ return True
685
+ try:
686
+ job.close()
687
+ except OSError as error:
688
+ _report_diagnostic(str(error))
689
+ return False
690
+ return True
691
+
692
+
693
+ def run_command(
694
+ phase: ProcessCommand,
695
+ *,
696
+ hard_deadline: float | None,
697
+ termination_reserve_seconds: float = 6.0,
698
+ signal_controller: SignalController | None = None,
699
+ ) -> int:
700
+ """Run and close an owned command, controlling signals by default.
701
+
702
+ Automatic signal handling requires the main thread. Callers that already
703
+ manage process-wide handlers may pass their existing signal controller.
704
+ """
705
+
706
+ controller_context = (
707
+ controlled_termination_signals()
708
+ if signal_controller is None
709
+ else nullcontext(signal_controller)
710
+ )
711
+ with controller_context as controller:
712
+ return _run_command(
713
+ phase,
714
+ hard_deadline=hard_deadline,
715
+ termination_reserve_seconds=termination_reserve_seconds,
716
+ signal_controller=controller,
717
+ )
718
+
719
+
720
+ def _run_command(
721
+ phase: ProcessCommand,
722
+ *,
723
+ hard_deadline: float | None,
724
+ termination_reserve_seconds: float,
725
+ signal_controller: SignalController,
726
+ ) -> int:
727
+ if (
728
+ not math.isfinite(termination_reserve_seconds)
729
+ or termination_reserve_seconds <= 0
730
+ or (hard_deadline is not None and not math.isfinite(hard_deadline))
731
+ ):
732
+ raise ValueError(
733
+ "Deadline must be finite and cleanup reserve finite and positive"
734
+ )
735
+ started_at = time.monotonic()
736
+ budget_detail = (
737
+ "unbounded"
738
+ if hard_deadline is None
739
+ else f"{max(0.0, hard_deadline - started_at):.1f}s total remaining"
740
+ )
741
+ print(f"\n==> {phase.label} ({budget_detail})", flush=True)
742
+ print("+", shlex.join(phase.argv), flush=True)
743
+ execution_deadline = (
744
+ None if hard_deadline is None else hard_deadline - termination_reserve_seconds
745
+ )
746
+ if execution_deadline is not None and execution_deadline <= started_at:
747
+ _report_diagnostic(
748
+ f"command budget lacks the {termination_reserve_seconds:.1f}s "
749
+ f"termination reserve before {phase.label}"
750
+ )
751
+ return TIMEOUT_EXIT_CODE
752
+ ownership_deadline = (
753
+ hard_deadline
754
+ if hard_deadline is not None
755
+ else started_at + termination_reserve_seconds
756
+ )
757
+ owner: OwnedProcess | None = None
758
+ tree_empty_proven = False
759
+ termination_deadline: float | None = None
760
+ return_code = TERMINATION_FAILED_EXIT_CODE
761
+ try:
762
+ # Do not deliver a controlled platform signal after Popen creates the
763
+ # child but before this frame owns its process tree. The handler records
764
+ # a pending signal and delivers it after ownership is established.
765
+ with signal_controller.defer():
766
+ owner = start_owned_process(
767
+ phase,
768
+ deadline=ownership_deadline,
769
+ signal_controller=signal_controller,
770
+ )
771
+ timeout_seconds = (
772
+ None
773
+ if execution_deadline is None
774
+ else max(0.0, execution_deadline - time.monotonic())
775
+ )
776
+ try:
777
+ return_code = owner.process.wait(timeout=timeout_seconds)
778
+ except subprocess.TimeoutExpired:
779
+ termination_deadline = hard_deadline
780
+ return_code = TIMEOUT_EXIT_CODE
781
+ _report_diagnostic(
782
+ f"command budget expired during {phase.label}; "
783
+ "terminating its process tree"
784
+ )
785
+ else:
786
+ cleanup_started = time.monotonic()
787
+ termination_deadline = (
788
+ cleanup_started + termination_reserve_seconds
789
+ if hard_deadline is None
790
+ else hard_deadline
791
+ )
792
+ clean_exit_deadline = min(
793
+ termination_deadline,
794
+ cleanup_started + TERMINATION_POLL_SECONDS * 5,
795
+ )
796
+ with signal_controller.defer():
797
+ tree_empty_proven = wait_for_owned_tree_exit(
798
+ owner, deadline=clean_exit_deadline
799
+ )
800
+ if not tree_empty_proven:
801
+ _report_diagnostic(
802
+ "command leader exited with surviving process-tree members "
803
+ f"during {phase.label}"
804
+ )
805
+ raise ProcessOwnershipError(phase.label)
806
+ except KeyboardInterrupt, RunnerSignalInterrupt:
807
+ _report_diagnostic(
808
+ f"command interrupted during {phase.label}; terminating its process tree"
809
+ )
810
+ raise
811
+ finally:
812
+ if owner is not None:
813
+ # Every exit, including wait or diagnostic failure, passes through
814
+ # bounded teardown. Defer signals through Job closure as well, and
815
+ # never let failed termination skip the kill-on-close backstop.
816
+ with signal_controller.defer():
817
+ try:
818
+ if not tree_empty_proven:
819
+ if termination_deadline is None:
820
+ termination_deadline = (
821
+ time.monotonic() + termination_reserve_seconds
822
+ )
823
+ if hard_deadline is not None:
824
+ termination_deadline = min(
825
+ hard_deadline, termination_deadline
826
+ )
827
+ try:
828
+ tree_empty_proven = terminate_owned_tree(
829
+ owner, deadline=termination_deadline
830
+ )
831
+ except Exception as error:
832
+ raise ProcessOwnershipError(phase.label) from error
833
+ if not tree_empty_proven:
834
+ _report_diagnostic(
835
+ "could not verify process-tree termination for "
836
+ f"{phase.label}"
837
+ )
838
+ raise ProcessOwnershipError(phase.label)
839
+ finally:
840
+ if not close_owner(owner):
841
+ raise ProcessOwnershipError(phase.label)
842
+ print(f"owned_tree_empty={tree_empty_proven}", flush=True)
843
+ finished_at = time.monotonic()
844
+ elapsed = finished_at - started_at
845
+ if hard_deadline is not None and finished_at > hard_deadline and return_code == 0:
846
+ return_code = TIMEOUT_EXIT_CODE
847
+ print(f"<== {phase.label}: {elapsed:.1f}s (exit {return_code})", flush=True)
848
+ return return_code
849
+
850
+
851
+ def _windows_supervisor_main(command: Sequence[str]) -> int:
852
+ """Wait for Job ownership before starting the requested command phase."""
853
+
854
+ if os.name != "nt" or not command:
855
+ return TERMINATION_FAILED_EXIT_CODE
856
+ try:
857
+ token = sys.stdin.buffer.read(len(_WINDOWS_START_TOKEN))
858
+ except OSError:
859
+ return TERMINATION_FAILED_EXIT_CODE
860
+ if token != _WINDOWS_START_TOKEN:
861
+ return TERMINATION_FAILED_EXIT_CODE
862
+ try:
863
+ process = subprocess.Popen(tuple(command))
864
+ except OSError as error:
865
+ print(f"could not start command: {error}", file=sys.stderr, flush=True)
866
+ return TERMINATION_FAILED_EXIT_CODE
867
+ return process.wait()
868
+
869
+
870
+ if __name__ == "__main__":
871
+ if len(sys.argv) < 2 or sys.argv[1] != _WINDOWS_SUPERVISOR_MODE:
872
+ raise SystemExit(TERMINATION_FAILED_EXIT_CODE)
873
+ raise SystemExit(_windows_supervisor_main(sys.argv[2:]))