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