sequential-hooks 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plugins/agy/_sequential_hooks/__init__.py +1 -0
- plugins/agy/_sequential_hooks/_adapter.py +566 -0
- plugins/agy/_sequential_hooks/_doctor.py +413 -0
- plugins/claude/_sequential_hooks/__init__.py +1 -0
- plugins/claude/_sequential_hooks/_adapter.py +560 -0
- plugins/claude/_sequential_hooks/_doctor.py +455 -0
- plugins/codex/_sequential_hooks/__init__.py +1 -0
- plugins/codex/_sequential_hooks/_adapter.py +466 -0
- plugins/codex/_sequential_hooks/_doctor.py +563 -0
- sequential_hooks/__init__.py +3 -0
- sequential_hooks/__main__.py +5 -0
- sequential_hooks/_arguments.py +222 -0
- sequential_hooks/_cleanup.py +88 -0
- sequential_hooks/_cli.py +440 -0
- sequential_hooks/_containment/__init__.py +410 -0
- sequential_hooks/_containment/_posix.py +236 -0
- sequential_hooks/_containment/_uncontained.py +239 -0
- sequential_hooks/_containment/_windows.py +853 -0
- sequential_hooks/_containment/_windows_api.py +570 -0
- sequential_hooks/_containment/_windows_launcher.py +189 -0
- sequential_hooks/_diagnostics.py +265 -0
- sequential_hooks/_doctor/__init__.py +342 -0
- sequential_hooks/_doctor/_command.py +469 -0
- sequential_hooks/_doctor/_common.py +448 -0
- sequential_hooks/_doctor/_types.py +94 -0
- sequential_hooks/_downstream.py +181 -0
- sequential_hooks/_executable.py +187 -0
- sequential_hooks/_executor.py +825 -0
- sequential_hooks/_registry.py +103 -0
- sequential_hooks/_runner.py +48 -0
- sequential_hooks/_types.py +125 -0
- sequential_hooks/hosts/__init__.py +130 -0
- sequential_hooks/hosts/_contract.py +434 -0
- sequential_hooks/hosts/_inspection.py +78 -0
- sequential_hooks/hosts/_json.py +150 -0
- sequential_hooks/hosts/_records.py +273 -0
- sequential_hooks/hosts/_skeleton.py +1326 -0
- sequential_hooks/py.typed +0 -0
- sequential_hooks-0.1.0.dist-info/METADATA +90 -0
- sequential_hooks-0.1.0.dist-info/RECORD +43 -0
- sequential_hooks-0.1.0.dist-info/WHEEL +4 -0
- sequential_hooks-0.1.0.dist-info/entry_points.txt +7 -0
- sequential_hooks-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""Wrap the Windows APIs required for process containment and completion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
from ctypes import wintypes
|
|
9
|
+
from typing import TYPE_CHECKING, Any, NoReturn, Protocol, TypedDict
|
|
10
|
+
|
|
11
|
+
from sequential_hooks._cleanup import CleanupState
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
import subprocess
|
|
15
|
+
|
|
16
|
+
_ASSIGN_PROCESS_RIGHTS = 0x0001 | 0x0100
|
|
17
|
+
_CTRL_BREAK_EVENT = 1
|
|
18
|
+
_DO_NOT_INHERIT_HANDLE = False
|
|
19
|
+
_ERROR_INVALID_PARAMETER = 87
|
|
20
|
+
_INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
|
|
21
|
+
_JOB_OBJECT_ASSOCIATE_COMPLETION_PORT_CLASS = 7
|
|
22
|
+
_JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS = 1
|
|
23
|
+
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS = 9
|
|
24
|
+
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
|
|
25
|
+
_JOB_OBJECT_MSG_NEW_PROCESS = 6
|
|
26
|
+
_JOB_POLL_SECONDS = 0.01
|
|
27
|
+
_QUERY_AND_SYNCHRONIZE_PROCESS_RIGHTS = 0x1000 | 0x00100000
|
|
28
|
+
_WAIT_OBJECT_0 = 0
|
|
29
|
+
_WAIT_TIMEOUT = 258
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _JobCompletion(TypedDict):
|
|
33
|
+
"""Retain creation notifications and native completion owners for one job."""
|
|
34
|
+
|
|
35
|
+
_completed: bool
|
|
36
|
+
_observed_processes: int
|
|
37
|
+
_port_handle: int
|
|
38
|
+
_process_handles: list[int]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NativeWindowsApi:
|
|
42
|
+
"""Call the kernel32 surface required for containment and process completion.
|
|
43
|
+
|
|
44
|
+
Callers own handles returned by `create_kill_on_close_job()` until passing
|
|
45
|
+
each handle to `close_handle()`. `assign_pid()` owns and closes only its
|
|
46
|
+
temporary process handle.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
"""Bind the required `kernel32` functions and their ctypes signatures.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
OSError: If native Windows APIs cannot be loaded.
|
|
54
|
+
"""
|
|
55
|
+
win_dll = getattr(ctypes, 'WinDLL', None)
|
|
56
|
+
if win_dll is None:
|
|
57
|
+
raise OSError('native Windows APIs are unavailable')
|
|
58
|
+
self._jobs: dict[int, _JobCompletion] = {}
|
|
59
|
+
self._kernel32: Any = win_dll('kernel32', use_last_error=True)
|
|
60
|
+
self._kernel32.AssignProcessToJobObject.argtypes = [
|
|
61
|
+
wintypes.HANDLE,
|
|
62
|
+
wintypes.HANDLE,
|
|
63
|
+
]
|
|
64
|
+
self._kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
|
|
65
|
+
self._kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
66
|
+
self._kernel32.CloseHandle.restype = wintypes.BOOL
|
|
67
|
+
self._kernel32.CreateIoCompletionPort.argtypes = [
|
|
68
|
+
wintypes.HANDLE,
|
|
69
|
+
wintypes.HANDLE,
|
|
70
|
+
ctypes.c_size_t,
|
|
71
|
+
wintypes.DWORD,
|
|
72
|
+
]
|
|
73
|
+
self._kernel32.CreateIoCompletionPort.restype = wintypes.HANDLE
|
|
74
|
+
self._kernel32.CreateJobObjectW.argtypes = [
|
|
75
|
+
wintypes.LPVOID,
|
|
76
|
+
wintypes.LPCWSTR,
|
|
77
|
+
]
|
|
78
|
+
self._kernel32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
79
|
+
self._kernel32.GenerateConsoleCtrlEvent.argtypes = [
|
|
80
|
+
wintypes.DWORD,
|
|
81
|
+
wintypes.DWORD,
|
|
82
|
+
]
|
|
83
|
+
self._kernel32.GenerateConsoleCtrlEvent.restype = wintypes.BOOL
|
|
84
|
+
self._kernel32.GetQueuedCompletionStatus.argtypes = [
|
|
85
|
+
wintypes.HANDLE,
|
|
86
|
+
wintypes.LPVOID,
|
|
87
|
+
wintypes.LPVOID,
|
|
88
|
+
wintypes.LPVOID,
|
|
89
|
+
wintypes.DWORD,
|
|
90
|
+
]
|
|
91
|
+
self._kernel32.GetQueuedCompletionStatus.restype = wintypes.BOOL
|
|
92
|
+
self._kernel32.IsProcessInJob.argtypes = [
|
|
93
|
+
wintypes.HANDLE,
|
|
94
|
+
wintypes.HANDLE,
|
|
95
|
+
wintypes.LPVOID,
|
|
96
|
+
]
|
|
97
|
+
self._kernel32.IsProcessInJob.restype = wintypes.BOOL
|
|
98
|
+
self._kernel32.OpenProcess.argtypes = [
|
|
99
|
+
wintypes.DWORD,
|
|
100
|
+
wintypes.BOOL,
|
|
101
|
+
wintypes.DWORD,
|
|
102
|
+
]
|
|
103
|
+
self._kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
104
|
+
self._kernel32.QueryInformationJobObject.argtypes = [
|
|
105
|
+
wintypes.HANDLE,
|
|
106
|
+
wintypes.INT,
|
|
107
|
+
wintypes.LPVOID,
|
|
108
|
+
wintypes.DWORD,
|
|
109
|
+
wintypes.LPVOID,
|
|
110
|
+
]
|
|
111
|
+
self._kernel32.QueryInformationJobObject.restype = wintypes.BOOL
|
|
112
|
+
self._kernel32.SetInformationJobObject.argtypes = [
|
|
113
|
+
wintypes.HANDLE,
|
|
114
|
+
wintypes.INT,
|
|
115
|
+
wintypes.LPVOID,
|
|
116
|
+
wintypes.DWORD,
|
|
117
|
+
]
|
|
118
|
+
self._kernel32.SetInformationJobObject.restype = wintypes.BOOL
|
|
119
|
+
self._kernel32.TerminateJobObject.argtypes = [
|
|
120
|
+
wintypes.HANDLE,
|
|
121
|
+
wintypes.UINT,
|
|
122
|
+
]
|
|
123
|
+
self._kernel32.TerminateJobObject.restype = wintypes.BOOL
|
|
124
|
+
self._kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
|
125
|
+
self._kernel32.WaitForSingleObject.restype = wintypes.DWORD
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _raise_last_error(operation: str) -> NoReturn:
|
|
129
|
+
"""Raise one bounded operating-system error for a failed Win32 call."""
|
|
130
|
+
get_last_error = getattr(ctypes, 'get_last_error', lambda: 0)
|
|
131
|
+
error_code = int(get_last_error())
|
|
132
|
+
raise OSError(error_code, f'{operation} failed with Windows error {error_code}')
|
|
133
|
+
|
|
134
|
+
def _close_native_handle(self, handle: int) -> None:
|
|
135
|
+
"""Close one kernel handle without dispatching job ownership cleanup."""
|
|
136
|
+
if not self._kernel32.CloseHandle(handle):
|
|
137
|
+
self._raise_last_error('CloseHandle')
|
|
138
|
+
|
|
139
|
+
def _capture_job_process(self, job_handle: int, job: _JobCompletion, pid: int) -> None:
|
|
140
|
+
"""Retain each notified process still belonging to its original job."""
|
|
141
|
+
process_handle = self._kernel32.OpenProcess(
|
|
142
|
+
_QUERY_AND_SYNCHRONIZE_PROCESS_RIGHTS,
|
|
143
|
+
_DO_NOT_INHERIT_HANDLE,
|
|
144
|
+
pid,
|
|
145
|
+
)
|
|
146
|
+
if not process_handle:
|
|
147
|
+
if getattr(ctypes, 'get_last_error', lambda: 0)() == _ERROR_INVALID_PARAMETER:
|
|
148
|
+
# A destroyed process cannot still have pending native teardown.
|
|
149
|
+
job['_observed_processes'] += 1
|
|
150
|
+
return
|
|
151
|
+
self._raise_last_error('OpenProcess')
|
|
152
|
+
handle = int(process_handle)
|
|
153
|
+
retained = False
|
|
154
|
+
cleanup = CleanupState()
|
|
155
|
+
try:
|
|
156
|
+
with cleanup:
|
|
157
|
+
in_job = wintypes.BOOL()
|
|
158
|
+
if not self._kernel32.IsProcessInJob(handle, job_handle, ctypes.byref(in_job)):
|
|
159
|
+
self._raise_last_error('IsProcessInJob')
|
|
160
|
+
if in_job.value:
|
|
161
|
+
job['_process_handles'].append(handle)
|
|
162
|
+
retained = True
|
|
163
|
+
# A reused PID in another job means the original is already gone.
|
|
164
|
+
job['_observed_processes'] += 1
|
|
165
|
+
finally:
|
|
166
|
+
if not retained:
|
|
167
|
+
cleanup.attempt(lambda: self._close_native_handle(handle))
|
|
168
|
+
if cleanup.first_error is not None:
|
|
169
|
+
raise cleanup.first_error
|
|
170
|
+
|
|
171
|
+
def _receive_job_message(
|
|
172
|
+
self,
|
|
173
|
+
job_handle: int,
|
|
174
|
+
job: _JobCompletion,
|
|
175
|
+
timeout_seconds: float,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Consume one queued creation event or a bounded notification wait."""
|
|
178
|
+
message = wintypes.DWORD()
|
|
179
|
+
key = ctypes.c_size_t()
|
|
180
|
+
pid = ctypes.c_void_p()
|
|
181
|
+
if not self._kernel32.GetQueuedCompletionStatus(
|
|
182
|
+
job['_port_handle'],
|
|
183
|
+
ctypes.byref(message),
|
|
184
|
+
ctypes.byref(key),
|
|
185
|
+
ctypes.byref(pid),
|
|
186
|
+
max(0, math.ceil(timeout_seconds * 1000)),
|
|
187
|
+
):
|
|
188
|
+
if getattr(ctypes, 'get_last_error', lambda: 0)() == _WAIT_TIMEOUT:
|
|
189
|
+
return
|
|
190
|
+
self._raise_last_error('GetQueuedCompletionStatus')
|
|
191
|
+
if key.value != job_handle:
|
|
192
|
+
raise OSError('unexpected Windows job completion key')
|
|
193
|
+
if message.value == _JOB_OBJECT_MSG_NEW_PROCESS:
|
|
194
|
+
if pid.value is None:
|
|
195
|
+
raise OSError('Windows job creation notification omits process identity')
|
|
196
|
+
self._capture_job_process(job_handle, job, pid.value)
|
|
197
|
+
|
|
198
|
+
def close_handle(self, handle: int) -> None:
|
|
199
|
+
"""Close one owned kernel handle and any associated job resources.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
handle: Owned nonzero kernel handle.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
OSError: If a native close fails; all owners are still attempted.
|
|
206
|
+
"""
|
|
207
|
+
cleanup = CleanupState()
|
|
208
|
+
if job := self._jobs.pop(handle, None):
|
|
209
|
+
for process_handle in job['_process_handles']:
|
|
210
|
+
cleanup.attempt(lambda handle=process_handle: self._close_native_handle(handle))
|
|
211
|
+
cleanup.attempt(lambda: self._close_native_handle(job['_port_handle']))
|
|
212
|
+
cleanup.attempt(lambda: self._close_native_handle(handle))
|
|
213
|
+
if cleanup.first_error is not None:
|
|
214
|
+
raise cleanup.first_error
|
|
215
|
+
|
|
216
|
+
def assign_pid(self, job_handle: int, pid: int) -> None:
|
|
217
|
+
"""Assign one live process and close the temporary process handle.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
job_handle: Owned Job Object handle.
|
|
221
|
+
pid: Live launcher process identifier.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
OSError: If opening, assigning, or closing the process handle
|
|
225
|
+
fails.
|
|
226
|
+
"""
|
|
227
|
+
process_handle = self._kernel32.OpenProcess(
|
|
228
|
+
_ASSIGN_PROCESS_RIGHTS,
|
|
229
|
+
_DO_NOT_INHERIT_HANDLE,
|
|
230
|
+
pid,
|
|
231
|
+
)
|
|
232
|
+
if not process_handle:
|
|
233
|
+
self._raise_last_error('OpenProcess')
|
|
234
|
+
try:
|
|
235
|
+
if not self._kernel32.AssignProcessToJobObject(job_handle, process_handle):
|
|
236
|
+
self._raise_last_error('AssignProcessToJobObject')
|
|
237
|
+
finally:
|
|
238
|
+
self.close_handle(int(process_handle))
|
|
239
|
+
|
|
240
|
+
def create_kill_on_close_job(self) -> int:
|
|
241
|
+
"""Create and configure a Job Object with kill-on-close.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Owned Job Object handle with no breakaway permission.
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
OSError: If job creation, configuration, or cleanup fails.
|
|
248
|
+
"""
|
|
249
|
+
job_handle = self._kernel32.CreateJobObjectW(None, None)
|
|
250
|
+
if not job_handle:
|
|
251
|
+
self._raise_last_error('CreateJobObjectW')
|
|
252
|
+
handle = int(job_handle)
|
|
253
|
+
information = _JobObjectExtendedLimitInformation()
|
|
254
|
+
information.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
255
|
+
cleanup = CleanupState()
|
|
256
|
+
port_handle = 0
|
|
257
|
+
with cleanup:
|
|
258
|
+
if not self._kernel32.SetInformationJobObject(
|
|
259
|
+
handle,
|
|
260
|
+
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS,
|
|
261
|
+
ctypes.byref(information),
|
|
262
|
+
ctypes.sizeof(information),
|
|
263
|
+
):
|
|
264
|
+
self._raise_last_error('SetInformationJobObject')
|
|
265
|
+
port = self._kernel32.CreateIoCompletionPort(_INVALID_HANDLE_VALUE, None, 0, 1)
|
|
266
|
+
if not port:
|
|
267
|
+
self._raise_last_error('CreateIoCompletionPort')
|
|
268
|
+
port_handle = int(port)
|
|
269
|
+
association = _JobObjectAssociateCompletionPort(handle, port_handle)
|
|
270
|
+
if not self._kernel32.SetInformationJobObject(
|
|
271
|
+
handle,
|
|
272
|
+
_JOB_OBJECT_ASSOCIATE_COMPLETION_PORT_CLASS,
|
|
273
|
+
ctypes.byref(association),
|
|
274
|
+
ctypes.sizeof(association),
|
|
275
|
+
):
|
|
276
|
+
self._raise_last_error('SetInformationJobObject')
|
|
277
|
+
if cleanup.first_error is not None:
|
|
278
|
+
if port_handle:
|
|
279
|
+
cleanup.attempt(lambda: self._close_native_handle(port_handle))
|
|
280
|
+
cleanup.attempt(lambda: self._close_native_handle(handle))
|
|
281
|
+
raise cleanup.first_error
|
|
282
|
+
self._jobs[handle] = {
|
|
283
|
+
'_completed': False,
|
|
284
|
+
'_observed_processes': 0,
|
|
285
|
+
'_port_handle': port_handle,
|
|
286
|
+
'_process_handles': [],
|
|
287
|
+
}
|
|
288
|
+
return handle
|
|
289
|
+
|
|
290
|
+
def generate_ctrl_break(self, process_group_id: int) -> bool:
|
|
291
|
+
"""Try to send Ctrl+Break to one console process group.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
process_group_id: Launcher process-group identifier.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Whether the console accepted the event.
|
|
298
|
+
"""
|
|
299
|
+
return bool(self._kernel32.GenerateConsoleCtrlEvent(_CTRL_BREAK_EVENT, process_group_id))
|
|
300
|
+
|
|
301
|
+
def terminate_job(self, job_handle: int) -> None:
|
|
302
|
+
"""Terminate every process currently assigned to one job.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
job_handle: Owned Job Object handle.
|
|
306
|
+
|
|
307
|
+
Raises:
|
|
308
|
+
OSError: If `TerminateJobObject` fails.
|
|
309
|
+
"""
|
|
310
|
+
if not self._kernel32.TerminateJobObject(job_handle, 1):
|
|
311
|
+
self._raise_last_error('TerminateJobObject')
|
|
312
|
+
|
|
313
|
+
def wait_job(self, job_handle: int, timeout_seconds: float) -> bool:
|
|
314
|
+
"""Confirm complete creation tracking and native teardown of every member.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
job_handle: Owned Job Object handle retained throughout the wait.
|
|
318
|
+
timeout_seconds: Nonnegative maximum wait in seconds.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Whether every job member has completed native teardown. Missing
|
|
322
|
+
creation notifications or an exhausted deadline return `False`.
|
|
323
|
+
|
|
324
|
+
Raises:
|
|
325
|
+
OSError: If querying, tracking, or waiting for job members fails.
|
|
326
|
+
"""
|
|
327
|
+
job = self._jobs[job_handle]
|
|
328
|
+
if job['_completed']:
|
|
329
|
+
return True
|
|
330
|
+
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
|
331
|
+
information = _JobObjectBasicAccountingInformation()
|
|
332
|
+
while True:
|
|
333
|
+
if not self._kernel32.QueryInformationJobObject(
|
|
334
|
+
job_handle,
|
|
335
|
+
_JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS,
|
|
336
|
+
ctypes.byref(information),
|
|
337
|
+
ctypes.sizeof(information),
|
|
338
|
+
None,
|
|
339
|
+
):
|
|
340
|
+
self._raise_last_error('QueryInformationJobObject')
|
|
341
|
+
# Native probes observe zero accounting and exit codes before process
|
|
342
|
+
# handles signal. Count events to detect missing notifications, then wait
|
|
343
|
+
# on retained identities even after they disappear from job lists.
|
|
344
|
+
if (
|
|
345
|
+
information.ActiveProcesses == 0
|
|
346
|
+
and job['_observed_processes'] == information.TotalProcesses
|
|
347
|
+
):
|
|
348
|
+
break
|
|
349
|
+
remaining = deadline - time.monotonic()
|
|
350
|
+
if remaining <= 0:
|
|
351
|
+
return False
|
|
352
|
+
self._receive_job_message(job_handle, job, min(_JOB_POLL_SECONDS, remaining))
|
|
353
|
+
for process_handle in job['_process_handles']:
|
|
354
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
355
|
+
status = int(self._kernel32.WaitForSingleObject(process_handle, int(remaining * 1000)))
|
|
356
|
+
if status == _WAIT_TIMEOUT:
|
|
357
|
+
return False
|
|
358
|
+
if status != _WAIT_OBJECT_0:
|
|
359
|
+
self._raise_last_error('WaitForSingleObject')
|
|
360
|
+
job['_completed'] = True
|
|
361
|
+
return True
|
|
362
|
+
|
|
363
|
+
def wait_process(self, process: subprocess.Popen[bytes], timeout_seconds: float) -> bool:
|
|
364
|
+
"""Wait for native completion independently of Popen's cached status.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
process: Live owning CPython Popen object; its handle is borrowed,
|
|
368
|
+
never reopened or closed here.
|
|
369
|
+
timeout_seconds: Nonnegative maximum wait in seconds.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Whether the retained process handle became signaled.
|
|
373
|
+
|
|
374
|
+
Raises:
|
|
375
|
+
OSError: If the retained handle is unavailable or the native wait
|
|
376
|
+
fails.
|
|
377
|
+
"""
|
|
378
|
+
# CPython exposes no public Windows handle. Keep this implementation
|
|
379
|
+
# dependency at one validated platform boundary; Popen owns its lifetime.
|
|
380
|
+
handle = vars(process).get('_handle')
|
|
381
|
+
if not isinstance(handle, int) or handle <= 0:
|
|
382
|
+
raise OSError('owned Windows process handle is unavailable')
|
|
383
|
+
milliseconds = max(0, int(timeout_seconds * 1000))
|
|
384
|
+
status = int(self._kernel32.WaitForSingleObject(handle, milliseconds))
|
|
385
|
+
if status not in (_WAIT_OBJECT_0, _WAIT_TIMEOUT):
|
|
386
|
+
self._raise_last_error('WaitForSingleObject')
|
|
387
|
+
return status == _WAIT_OBJECT_0
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class WindowsApi(Protocol):
|
|
391
|
+
"""Provide the Job Object, console, and process waits used by containment.
|
|
392
|
+
|
|
393
|
+
Callers own each handle returned by `create_kill_on_close_job()` until
|
|
394
|
+
passing it to `close_handle()`. Operations raise `OSError` on native
|
|
395
|
+
failure except `generate_ctrl_break()`, which reports acceptance as a
|
|
396
|
+
Boolean result.
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
def assign_pid(self, job_handle: int, pid: int) -> None:
|
|
400
|
+
"""Open the live process, assign it, and close that process handle.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
job_handle: Job Object handle that receives the process.
|
|
404
|
+
pid: Live process identifier to assign.
|
|
405
|
+
|
|
406
|
+
Raises:
|
|
407
|
+
OSError: If opening, assigning, or closing the process handle
|
|
408
|
+
fails.
|
|
409
|
+
"""
|
|
410
|
+
...
|
|
411
|
+
|
|
412
|
+
def close_handle(self, handle: int) -> None:
|
|
413
|
+
"""Close one owned kernel handle exactly once.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
handle: Owned kernel handle to close.
|
|
417
|
+
|
|
418
|
+
Raises:
|
|
419
|
+
OSError: If `CloseHandle` fails.
|
|
420
|
+
"""
|
|
421
|
+
...
|
|
422
|
+
|
|
423
|
+
def create_kill_on_close_job(self) -> int:
|
|
424
|
+
"""Return a Job Object handle with no breakaway permission.
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
Owned kill-on-close Job Object handle.
|
|
428
|
+
|
|
429
|
+
Raises:
|
|
430
|
+
OSError: If job creation, configuration, or cleanup fails.
|
|
431
|
+
"""
|
|
432
|
+
...
|
|
433
|
+
|
|
434
|
+
def generate_ctrl_break(self, process_group_id: int) -> bool:
|
|
435
|
+
"""Try to deliver Ctrl+Break to one console process group.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
process_group_id: Console process-group identifier.
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
Whether the console accepted the event.
|
|
442
|
+
"""
|
|
443
|
+
...
|
|
444
|
+
|
|
445
|
+
def terminate_job(self, job_handle: int) -> None:
|
|
446
|
+
"""Terminate every process in the job.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
job_handle: Job Object handle whose processes must terminate.
|
|
450
|
+
|
|
451
|
+
Raises:
|
|
452
|
+
OSError: If `TerminateJobObject` fails.
|
|
453
|
+
"""
|
|
454
|
+
...
|
|
455
|
+
|
|
456
|
+
def wait_job(self, job_handle: int, timeout_seconds: float) -> bool:
|
|
457
|
+
"""Wait for all members of a retained job to exit.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
job_handle: Owned Job Object handle retained throughout the wait.
|
|
461
|
+
timeout_seconds: Nonnegative maximum wait in seconds.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
Whether all job members, including nested-job members, have exited.
|
|
465
|
+
|
|
466
|
+
Raises:
|
|
467
|
+
OSError: If querying job accounting fails.
|
|
468
|
+
"""
|
|
469
|
+
...
|
|
470
|
+
|
|
471
|
+
def wait_process(self, process: subprocess.Popen[bytes], timeout_seconds: float) -> bool:
|
|
472
|
+
"""Wait on the retained handle without trusting cached exit status.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
process: Popen that retains ownership of the immediate process.
|
|
476
|
+
timeout_seconds: Nonnegative maximum wait in seconds.
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
Whether the process has completed native teardown.
|
|
480
|
+
|
|
481
|
+
Raises:
|
|
482
|
+
OSError: If the owned handle is unavailable or the native wait
|
|
483
|
+
fails.
|
|
484
|
+
"""
|
|
485
|
+
...
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class _IoCounters(ctypes.Structure):
|
|
489
|
+
"""Mirror the Win32 `IO_COUNTERS` layout."""
|
|
490
|
+
|
|
491
|
+
_fields_ = [
|
|
492
|
+
('ReadOperationCount', ctypes.c_ulonglong),
|
|
493
|
+
('WriteOperationCount', ctypes.c_ulonglong),
|
|
494
|
+
('OtherOperationCount', ctypes.c_ulonglong),
|
|
495
|
+
('ReadTransferCount', ctypes.c_ulonglong),
|
|
496
|
+
('WriteTransferCount', ctypes.c_ulonglong),
|
|
497
|
+
('OtherTransferCount', ctypes.c_ulonglong),
|
|
498
|
+
]
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
class _JobObjectAssociateCompletionPort(ctypes.Structure):
|
|
502
|
+
"""Mirror the Win32 `JOBOBJECT_ASSOCIATE_COMPLETION_PORT` layout."""
|
|
503
|
+
|
|
504
|
+
_fields_ = [
|
|
505
|
+
('CompletionKey', ctypes.c_void_p),
|
|
506
|
+
('CompletionPort', wintypes.HANDLE),
|
|
507
|
+
]
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
class _JobObjectBasicAccountingInformation(ctypes.Structure):
|
|
511
|
+
"""Mirror the Win32 `JOBOBJECT_BASIC_ACCOUNTING_INFORMATION` layout."""
|
|
512
|
+
|
|
513
|
+
_fields_ = [
|
|
514
|
+
('TotalUserTime', ctypes.c_longlong),
|
|
515
|
+
('TotalKernelTime', ctypes.c_longlong),
|
|
516
|
+
('ThisPeriodTotalUserTime', ctypes.c_longlong),
|
|
517
|
+
('ThisPeriodTotalKernelTime', ctypes.c_longlong),
|
|
518
|
+
('TotalPageFaultCount', wintypes.DWORD),
|
|
519
|
+
('TotalProcesses', wintypes.DWORD),
|
|
520
|
+
('ActiveProcesses', wintypes.DWORD),
|
|
521
|
+
('TotalTerminatedProcesses', wintypes.DWORD),
|
|
522
|
+
]
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
class _JobObjectBasicLimitInformation(ctypes.Structure):
|
|
526
|
+
"""Mirror the Win32 `JOBOBJECT_BASIC_LIMIT_INFORMATION` layout."""
|
|
527
|
+
|
|
528
|
+
_fields_ = [
|
|
529
|
+
('PerProcessUserTimeLimit', ctypes.c_longlong),
|
|
530
|
+
('PerJobUserTimeLimit', ctypes.c_longlong),
|
|
531
|
+
('LimitFlags', wintypes.DWORD),
|
|
532
|
+
('MinimumWorkingSetSize', ctypes.c_size_t),
|
|
533
|
+
('MaximumWorkingSetSize', ctypes.c_size_t),
|
|
534
|
+
('ActiveProcessLimit', wintypes.DWORD),
|
|
535
|
+
('Affinity', ctypes.c_size_t),
|
|
536
|
+
('PriorityClass', wintypes.DWORD),
|
|
537
|
+
('SchedulingClass', wintypes.DWORD),
|
|
538
|
+
]
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
class _JobObjectExtendedLimitInformation(ctypes.Structure):
|
|
542
|
+
"""Mirror the Win32 `JOBOBJECT_EXTENDED_LIMIT_INFORMATION` layout."""
|
|
543
|
+
|
|
544
|
+
_fields_ = [
|
|
545
|
+
('BasicLimitInformation', _JobObjectBasicLimitInformation),
|
|
546
|
+
('IoInfo', _IoCounters),
|
|
547
|
+
('ProcessMemoryLimit', ctypes.c_size_t),
|
|
548
|
+
('JobMemoryLimit', ctypes.c_size_t),
|
|
549
|
+
('PeakProcessMemoryUsed', ctypes.c_size_t),
|
|
550
|
+
('PeakJobMemoryUsed', ctypes.c_size_t),
|
|
551
|
+
]
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def probe_job_object(api: WindowsApi | None = None) -> bool:
|
|
555
|
+
"""Test whether a kill-on-close Job Object can be configured.
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
api: Windows API boundary to probe, or the native implementation.
|
|
559
|
+
|
|
560
|
+
Returns:
|
|
561
|
+
`True` when creation, configuration, and handle cleanup succeed;
|
|
562
|
+
`False` when an operation raises `OSError`.
|
|
563
|
+
"""
|
|
564
|
+
try:
|
|
565
|
+
selected_api = api or NativeWindowsApi()
|
|
566
|
+
handle = selected_api.create_kill_on_close_job()
|
|
567
|
+
selected_api.close_handle(handle)
|
|
568
|
+
except OSError:
|
|
569
|
+
return False
|
|
570
|
+
return True
|