qplotter 1.6.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.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +67 -0
- qplot/_build_info.json +1 -0
- qplot/_metadata.py +75 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +578 -0
- qplot/configuration/config_schema.json +357 -0
- qplot/configuration/fonts.py +3 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +592 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2340 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2566 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +148 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +876 -0
- qplot/datahandling/trusted_heatmap.py +246 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1919 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1804 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +123 -0
- qplot/tools/general.py +139 -0
- qplot/tools/heatmap_geometry.py +391 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +650 -0
- qplot/tools/sample_statistics.py +111 -0
- qplot/tools/worker.py +2838 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4933 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_display_precision.py +48 -0
- qplot/windows/_dragdrop.py +70 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +210 -0
- qplot/windows/_mixed_appearance.py +303 -0
- qplot/windows/_mixed_plots.py +461 -0
- qplot/windows/_native_averaging.py +323 -0
- qplot/windows/_native_transforms.py +551 -0
- qplot/windows/_plot1d_snap.py +612 -0
- qplot/windows/_plot1d_traces.py +2236 -0
- qplot/windows/_plot2d_colorbar.py +1127 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2291 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +2082 -0
- qplot/windows/_plot_actions.py +2703 -0
- qplot/windows/_plot_annotation.py +186 -0
- qplot/windows/_plot_appearance.py +283 -0
- qplot/windows/_plot_axis_scaling.py +1521 -0
- qplot/windows/_plot_data_viewbox.py +94 -0
- qplot/windows/_plot_drop_axes.py +224 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +112 -0
- qplot/windows/_plot_marquee.py +659 -0
- qplot/windows/_plot_refresh.py +950 -0
- qplot/windows/_plot_source_promotion.py +178 -0
- qplot/windows/_plot_state.py +237 -0
- qplot/windows/_plot_status.py +115 -0
- qplot/windows/_plot_tiling.py +35 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +724 -0
- qplot/windows/_refresh_interval.py +172 -0
- qplot/windows/_run_controls.py +789 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +532 -0
- qplot/windows/_subplots/subplot2d.py +919 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +414 -0
- qplot/windows/main.py +1632 -0
- qplot/windows/plot1d.py +313 -0
- qplot/windows/plot2d.py +1935 -0
- qplotter-1.6.0.dist-info/METADATA +322 -0
- qplotter-1.6.0.dist-info/RECORD +130 -0
- qplotter-1.6.0.dist-info/WHEEL +5 -0
- qplotter-1.6.0.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1166 @@
|
|
|
1
|
+
"""Retained Windows Job Object containment for the qPlot GUI process tree.
|
|
2
|
+
|
|
3
|
+
The module is importable on every platform and loads ``kernel32`` only when a
|
|
4
|
+
real Windows adapter is constructed. Production process creation is atomic
|
|
5
|
+
with respect to Job Object membership: ``PROC_THREAD_ATTRIBUTE_JOB_LIST``
|
|
6
|
+
places the suspended GUI in the retained job as part of ``CreateProcessW``.
|
|
7
|
+
The initial thread is resumed only after membership has been verified.
|
|
8
|
+
|
|
9
|
+
No production operation opens or terminates a process by PID. The PID is
|
|
10
|
+
retained only for authenticated protocol comparison and diagnostics; all
|
|
11
|
+
liveness, status, and termination operations use the original process and job
|
|
12
|
+
handles.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ctypes
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import Mapping, Sequence
|
|
23
|
+
from ctypes import wintypes
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any, Protocol
|
|
26
|
+
|
|
27
|
+
_FORCED_SHUTDOWN_EXIT_CODE = 70
|
|
28
|
+
_RETRY_INTERVAL_SECONDS = 0.01
|
|
29
|
+
_WAIT_SLICE_MILLISECONDS = 50
|
|
30
|
+
_MAX_WAIT_MILLISECONDS = 0xFFFFFFFE
|
|
31
|
+
_FAILURE_DIAGNOSTIC_LIMIT = 64
|
|
32
|
+
|
|
33
|
+
_CREATE_SUSPENDED = 0x00000004
|
|
34
|
+
_CREATE_UNICODE_ENVIRONMENT = 0x00000400
|
|
35
|
+
_EXTENDED_STARTUPINFO_PRESENT = 0x00080000
|
|
36
|
+
_WINDOWS_CONTAINED_CREATION_FLAGS = (
|
|
37
|
+
_CREATE_SUSPENDED | _CREATE_UNICODE_ENVIRONMENT | _EXTENDED_STARTUPINFO_PRESENT
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_STARTF_USESTDHANDLES = 0x00000100
|
|
41
|
+
_DUPLICATE_SAME_ACCESS = 0x00000002
|
|
42
|
+
_FILE_TYPE_CHAR = 0x0002
|
|
43
|
+
_INVALID_HANDLE_VALUE = -1
|
|
44
|
+
|
|
45
|
+
_PROC_THREAD_ATTRIBUTE_HANDLE_LIST = 0x00020002
|
|
46
|
+
_PROC_THREAD_ATTRIBUTE_JOB_LIST = 0x0002000D
|
|
47
|
+
|
|
48
|
+
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
|
|
49
|
+
_JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS = 1
|
|
50
|
+
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS = 9
|
|
51
|
+
|
|
52
|
+
_WAIT_OBJECT_0 = 0x00000000
|
|
53
|
+
_WAIT_TIMEOUT = 0x00000102
|
|
54
|
+
_WAIT_FAILED = 0xFFFFFFFF
|
|
55
|
+
_ERROR_INSUFFICIENT_BUFFER = 122
|
|
56
|
+
|
|
57
|
+
_STD_INPUT_HANDLE = -10
|
|
58
|
+
_STD_OUTPUT_HANDLE = -11
|
|
59
|
+
_STD_ERROR_HANDLE = -12
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class WindowsShutdownJobError(RuntimeError):
|
|
63
|
+
"""Windows process-tree containment could not be established or observed."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class _SpawnedHandles:
|
|
68
|
+
process_handle: Any
|
|
69
|
+
thread_handle: Any
|
|
70
|
+
pid: int
|
|
71
|
+
deferred_handles: tuple[Any, ...] = ()
|
|
72
|
+
failures: tuple[str, ...] = ()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _JobAdapter(Protocol):
|
|
76
|
+
"""Injectable boundary around the Win32 calls used by the owner."""
|
|
77
|
+
|
|
78
|
+
def create_job(self) -> Any: ...
|
|
79
|
+
|
|
80
|
+
def configure_kill_on_close(self, job_handle: Any) -> None: ...
|
|
81
|
+
|
|
82
|
+
def create_process_in_job(
|
|
83
|
+
self,
|
|
84
|
+
job_handle: Any,
|
|
85
|
+
argv: Sequence[str | os.PathLike[str]],
|
|
86
|
+
env: Mapping[str, str],
|
|
87
|
+
) -> _SpawnedHandles: ...
|
|
88
|
+
|
|
89
|
+
def process_is_in_job(self, process_handle: Any, job_handle: Any) -> bool: ...
|
|
90
|
+
|
|
91
|
+
def resume_thread(self, thread_handle: Any) -> None: ...
|
|
92
|
+
|
|
93
|
+
def wait_process(self, process_handle: Any, milliseconds: int) -> bool: ...
|
|
94
|
+
|
|
95
|
+
def process_exit_code(self, process_handle: Any) -> int: ...
|
|
96
|
+
|
|
97
|
+
def terminate_job(self, job_handle: Any, exit_code: int) -> None: ...
|
|
98
|
+
|
|
99
|
+
def terminate_process(self, process_handle: Any, exit_code: int) -> None: ...
|
|
100
|
+
|
|
101
|
+
def active_processes(self, job_handle: Any) -> int: ...
|
|
102
|
+
|
|
103
|
+
def close_handle(self, handle: Any) -> None: ...
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _exact_error(context: str, error: BaseException) -> str:
|
|
107
|
+
return f"{context} raised {type(error).__name__}: {error}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _remaining_timeout(deadline: float | None) -> float | None:
|
|
111
|
+
if deadline is None:
|
|
112
|
+
return None
|
|
113
|
+
return max(0.0, deadline - time.monotonic())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _wait_milliseconds(remaining: float | None) -> int:
|
|
117
|
+
if remaining is None:
|
|
118
|
+
return _WAIT_SLICE_MILLISECONDS
|
|
119
|
+
if remaining <= 0.0:
|
|
120
|
+
return 0
|
|
121
|
+
return min(
|
|
122
|
+
_MAX_WAIT_MILLISECONDS,
|
|
123
|
+
max(1, int(math.ceil(remaining * 1000.0))),
|
|
124
|
+
_WAIT_SLICE_MILLISECONDS,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _wait_milliseconds_before_deadline(remaining: float) -> int:
|
|
129
|
+
"""Floor one retained-handle wait so it cannot request deadline overrun."""
|
|
130
|
+
|
|
131
|
+
if remaining <= 0.0:
|
|
132
|
+
return 0
|
|
133
|
+
return min(
|
|
134
|
+
_MAX_WAIT_MILLISECONDS,
|
|
135
|
+
int(math.floor(remaining * 1000.0)),
|
|
136
|
+
_WAIT_SLICE_MILLISECONDS,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _handle_value(handle: Any) -> int:
|
|
141
|
+
value = getattr(handle, "value", handle)
|
|
142
|
+
if value is None:
|
|
143
|
+
return 0
|
|
144
|
+
return int(value)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _invalid_handle_value() -> int:
|
|
148
|
+
value = ctypes.c_void_p(_INVALID_HANDLE_VALUE).value
|
|
149
|
+
assert value is not None
|
|
150
|
+
return int(value)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class WindowsContainedProcess:
|
|
154
|
+
"""Own one exact GUI process and its retained Windows Job Object.
|
|
155
|
+
|
|
156
|
+
A successful instance owns both handles until ``close_after_empty`` proves
|
|
157
|
+
that the direct process is signalled and the complete contained tree is
|
|
158
|
+
empty. Retryable adapter failures are retained verbatim in ``failures``.
|
|
159
|
+
Methods never release ownership merely because a fixed cleanup interval
|
|
160
|
+
elapsed.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
*,
|
|
166
|
+
adapter: _JobAdapter,
|
|
167
|
+
job_handle: Any,
|
|
168
|
+
process_handle: Any,
|
|
169
|
+
thread_handle: Any,
|
|
170
|
+
pid: int,
|
|
171
|
+
argv: Sequence[str | os.PathLike[str]],
|
|
172
|
+
deferred_handles: Sequence[Any] = (),
|
|
173
|
+
initial_failures: Sequence[str] = (),
|
|
174
|
+
) -> None:
|
|
175
|
+
self._adapter = adapter
|
|
176
|
+
self._job_handle: Any | None = job_handle
|
|
177
|
+
self._process_handle: Any | None = process_handle
|
|
178
|
+
self._thread_handle: Any | None = thread_handle
|
|
179
|
+
self._deferred_handles = list(deferred_handles)
|
|
180
|
+
self.pid = int(pid)
|
|
181
|
+
self.args = [os.fsdecode(argument) for argument in argv]
|
|
182
|
+
self.returncode: int | None = None
|
|
183
|
+
self._failures = list(initial_failures)
|
|
184
|
+
self._termination_requested = False
|
|
185
|
+
self._direct_exit_preceded_termination = False
|
|
186
|
+
self._direct_exit_observed_before_termination = False
|
|
187
|
+
self._termination_exit_code: int | None = None
|
|
188
|
+
self._closed = False
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def failures(self) -> tuple[str, ...]:
|
|
192
|
+
"""Exact retained adapter failures, in observation order."""
|
|
193
|
+
|
|
194
|
+
return tuple(self._failures)
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def termination_requested(self) -> bool:
|
|
198
|
+
"""Whether ``TerminateJobObject`` succeeded before tree observation."""
|
|
199
|
+
|
|
200
|
+
return self._termination_requested
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def direct_exit_preceded_termination(self) -> bool:
|
|
204
|
+
"""Whether direct exit was observed before the first kill attempt.
|
|
205
|
+
|
|
206
|
+
When false and ``termination_requested`` is true, the owner issued Job
|
|
207
|
+
Object termination before it observed a direct-child exit. That is the
|
|
208
|
+
operational distinction needed at the deadline; Windows cannot prove
|
|
209
|
+
physical causality if a natural exit races the kernel termination call.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
return self._direct_exit_preceded_termination
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def termination_exit_code(self) -> int | None:
|
|
216
|
+
"""The code supplied to the successful Job Object termination call."""
|
|
217
|
+
|
|
218
|
+
return self._termination_exit_code
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def closed(self) -> bool:
|
|
222
|
+
return self._closed
|
|
223
|
+
|
|
224
|
+
def _record_failure(self, context: str, error: BaseException) -> None:
|
|
225
|
+
try:
|
|
226
|
+
diagnostic = _exact_error(context, error)
|
|
227
|
+
if diagnostic in self._failures:
|
|
228
|
+
return
|
|
229
|
+
if len(self._failures) < _FAILURE_DIAGNOSTIC_LIMIT - 1:
|
|
230
|
+
self._failures.append(diagnostic)
|
|
231
|
+
return
|
|
232
|
+
marker = (
|
|
233
|
+
"additional distinct Windows containment diagnostics omitted "
|
|
234
|
+
f"after {_FAILURE_DIAGNOSTIC_LIMIT - 1} entries"
|
|
235
|
+
)
|
|
236
|
+
if marker not in self._failures:
|
|
237
|
+
self._failures.append(marker)
|
|
238
|
+
except BaseException:
|
|
239
|
+
# Retaining handles and retrying is more important than allocating
|
|
240
|
+
# a secondary diagnostic under severe memory pressure.
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
def _require_process_handle(self) -> Any:
|
|
244
|
+
handle = self._process_handle
|
|
245
|
+
if self._closed or handle is None:
|
|
246
|
+
raise WindowsShutdownJobError("retained Windows process handle is closed")
|
|
247
|
+
return handle
|
|
248
|
+
|
|
249
|
+
def _require_job_handle(self) -> Any:
|
|
250
|
+
handle = self._job_handle
|
|
251
|
+
if self._closed or handle is None:
|
|
252
|
+
raise WindowsShutdownJobError(
|
|
253
|
+
"retained Windows Job Object handle is closed"
|
|
254
|
+
)
|
|
255
|
+
return handle
|
|
256
|
+
|
|
257
|
+
def _close_thread_handle(self, *, retry: bool = True) -> None:
|
|
258
|
+
while self._thread_handle is not None:
|
|
259
|
+
handle = self._thread_handle
|
|
260
|
+
try:
|
|
261
|
+
self._adapter.close_handle(handle)
|
|
262
|
+
except BaseException as error:
|
|
263
|
+
self._record_failure("Windows initial-thread handle close", error)
|
|
264
|
+
if not retry:
|
|
265
|
+
return
|
|
266
|
+
time.sleep(_RETRY_INTERVAL_SECONDS)
|
|
267
|
+
continue
|
|
268
|
+
self._thread_handle = None
|
|
269
|
+
|
|
270
|
+
def poll(self) -> int | None:
|
|
271
|
+
"""Return the exact direct-process status without reopening its PID."""
|
|
272
|
+
|
|
273
|
+
if self.returncode is not None:
|
|
274
|
+
return self.returncode
|
|
275
|
+
process_handle = self._require_process_handle()
|
|
276
|
+
while True:
|
|
277
|
+
try:
|
|
278
|
+
signalled = self._adapter.wait_process(process_handle, 0)
|
|
279
|
+
except BaseException as error:
|
|
280
|
+
self._record_failure("Windows direct-process poll", error)
|
|
281
|
+
time.sleep(_RETRY_INTERVAL_SECONDS)
|
|
282
|
+
continue
|
|
283
|
+
if not signalled:
|
|
284
|
+
return None
|
|
285
|
+
try:
|
|
286
|
+
self.returncode = int(self._adapter.process_exit_code(process_handle))
|
|
287
|
+
except BaseException as error:
|
|
288
|
+
self._record_failure("Windows direct-process exit-status read", error)
|
|
289
|
+
time.sleep(_RETRY_INTERVAL_SECONDS)
|
|
290
|
+
continue
|
|
291
|
+
return self.returncode
|
|
292
|
+
|
|
293
|
+
def observe_before_deadline(self, hard_deadline: float) -> bool | None:
|
|
294
|
+
"""Observe the retained direct handle without crossing an absolute deadline.
|
|
295
|
+
|
|
296
|
+
``True`` means the direct process was observed signalled before
|
|
297
|
+
``hard_deadline``. ``False`` means it was still live at that observation,
|
|
298
|
+
and ``None`` means the deadline had been reached by the time the adapter
|
|
299
|
+
returned. The deadline result performs no zero-time follow-up poll,
|
|
300
|
+
status query, diagnostic allocation, or relative-timeout exception.
|
|
301
|
+
|
|
302
|
+
A successful pre-deadline observation is retained separately from the
|
|
303
|
+
exit code. The owner can therefore terminate residual Job members first
|
|
304
|
+
and read the exact direct-process status afterwards, including a natural
|
|
305
|
+
status equal to the Job termination code.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
# The authenticated ARM decoder has already validated this immutable
|
|
309
|
+
# float. Do not rebase or allocate a replacement in the deadline loop.
|
|
310
|
+
deadline = hard_deadline
|
|
311
|
+
process_handle = self._require_process_handle()
|
|
312
|
+
remaining = deadline - time.monotonic()
|
|
313
|
+
if remaining <= 0.0:
|
|
314
|
+
return None
|
|
315
|
+
wait_milliseconds = _wait_milliseconds_before_deadline(remaining)
|
|
316
|
+
if time.monotonic() >= deadline:
|
|
317
|
+
return None
|
|
318
|
+
# Do not format or retain adapter failures here. The supervisor first
|
|
319
|
+
# terminates the Job and only then materialises the exact exception as
|
|
320
|
+
# a diagnostic, even if this call crossed the absolute deadline.
|
|
321
|
+
signalled = self._adapter.wait_process(
|
|
322
|
+
process_handle,
|
|
323
|
+
wait_milliseconds,
|
|
324
|
+
)
|
|
325
|
+
if time.monotonic() >= deadline:
|
|
326
|
+
return None
|
|
327
|
+
if not signalled:
|
|
328
|
+
return False
|
|
329
|
+
self._direct_exit_observed_before_termination = True
|
|
330
|
+
return True
|
|
331
|
+
|
|
332
|
+
def wait(self, timeout: float | None = None) -> int:
|
|
333
|
+
"""Wait for the retained direct process without extending ``timeout``."""
|
|
334
|
+
|
|
335
|
+
if timeout is not None:
|
|
336
|
+
timeout = float(timeout)
|
|
337
|
+
if not math.isfinite(timeout) or timeout < 0.0:
|
|
338
|
+
raise ValueError("timeout must be finite and non-negative")
|
|
339
|
+
if self.returncode is not None:
|
|
340
|
+
return self.returncode
|
|
341
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
342
|
+
process_handle = self._require_process_handle()
|
|
343
|
+
while True:
|
|
344
|
+
remaining = _remaining_timeout(deadline)
|
|
345
|
+
if remaining is not None and remaining <= 0.0:
|
|
346
|
+
try:
|
|
347
|
+
signalled = self._adapter.wait_process(process_handle, 0)
|
|
348
|
+
except BaseException as error:
|
|
349
|
+
self._record_failure("Windows direct-process wait", error)
|
|
350
|
+
signalled = False
|
|
351
|
+
if not signalled:
|
|
352
|
+
assert timeout is not None
|
|
353
|
+
raise subprocess.TimeoutExpired(self.args, timeout)
|
|
354
|
+
try:
|
|
355
|
+
self.returncode = int(
|
|
356
|
+
self._adapter.process_exit_code(process_handle)
|
|
357
|
+
)
|
|
358
|
+
except BaseException as error:
|
|
359
|
+
self._record_failure(
|
|
360
|
+
"Windows direct-process exit-status read",
|
|
361
|
+
error,
|
|
362
|
+
)
|
|
363
|
+
assert timeout is not None
|
|
364
|
+
raise subprocess.TimeoutExpired(self.args, timeout) from error
|
|
365
|
+
return self.returncode
|
|
366
|
+
try:
|
|
367
|
+
signalled = self._adapter.wait_process(
|
|
368
|
+
process_handle,
|
|
369
|
+
_wait_milliseconds(remaining),
|
|
370
|
+
)
|
|
371
|
+
except BaseException as error:
|
|
372
|
+
self._record_failure("Windows direct-process wait", error)
|
|
373
|
+
after_failure = _remaining_timeout(deadline)
|
|
374
|
+
if after_failure is not None and after_failure <= 0.0:
|
|
375
|
+
assert timeout is not None
|
|
376
|
+
raise subprocess.TimeoutExpired(self.args, timeout) from error
|
|
377
|
+
time.sleep(
|
|
378
|
+
min(
|
|
379
|
+
_RETRY_INTERVAL_SECONDS,
|
|
380
|
+
after_failure
|
|
381
|
+
if after_failure is not None
|
|
382
|
+
else _RETRY_INTERVAL_SECONDS,
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
continue
|
|
386
|
+
if not signalled:
|
|
387
|
+
continue
|
|
388
|
+
try:
|
|
389
|
+
self.returncode = int(self._adapter.process_exit_code(process_handle))
|
|
390
|
+
except BaseException as error:
|
|
391
|
+
self._record_failure("Windows direct-process exit-status read", error)
|
|
392
|
+
after_failure = _remaining_timeout(deadline)
|
|
393
|
+
if after_failure is not None and after_failure <= 0.0:
|
|
394
|
+
assert timeout is not None
|
|
395
|
+
raise subprocess.TimeoutExpired(self.args, timeout) from error
|
|
396
|
+
continue
|
|
397
|
+
return self.returncode
|
|
398
|
+
|
|
399
|
+
def terminate_tree(self, exit_code: int = _FORCED_SHUTDOWN_EXIT_CODE) -> None:
|
|
400
|
+
"""Terminate the whole retained job, retrying without releasing it.
|
|
401
|
+
|
|
402
|
+
The first adapter call is always ``TerminateJobObject``. In particular,
|
|
403
|
+
no liveness query is performed before the first termination attempt.
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
job_handle = self._require_job_handle()
|
|
407
|
+
requested_code = int(exit_code)
|
|
408
|
+
self._direct_exit_preceded_termination = bool(
|
|
409
|
+
self.returncode is not None or self._direct_exit_observed_before_termination
|
|
410
|
+
)
|
|
411
|
+
while True:
|
|
412
|
+
try:
|
|
413
|
+
self._adapter.terminate_job(job_handle, requested_code)
|
|
414
|
+
except BaseException as error:
|
|
415
|
+
self._record_failure("Windows Job Object termination", error)
|
|
416
|
+
# After the first kill attempt it is safe to distinguish an
|
|
417
|
+
# already-empty job from a genuine termination failure.
|
|
418
|
+
try:
|
|
419
|
+
active = int(self._adapter.active_processes(job_handle)) != 0
|
|
420
|
+
except BaseException as observation_error:
|
|
421
|
+
self._record_failure(
|
|
422
|
+
"Windows Job Object post-termination observation",
|
|
423
|
+
observation_error,
|
|
424
|
+
)
|
|
425
|
+
else:
|
|
426
|
+
if not active:
|
|
427
|
+
return
|
|
428
|
+
time.sleep(_RETRY_INTERVAL_SECONDS)
|
|
429
|
+
continue
|
|
430
|
+
self._termination_requested = True
|
|
431
|
+
self._termination_exit_code = requested_code
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
def tree_active(self) -> bool:
|
|
435
|
+
"""Report whether any process in the retained nested job tree is active."""
|
|
436
|
+
|
|
437
|
+
job_handle = self._require_job_handle()
|
|
438
|
+
while True:
|
|
439
|
+
try:
|
|
440
|
+
return int(self._adapter.active_processes(job_handle)) != 0
|
|
441
|
+
except BaseException as error:
|
|
442
|
+
self._record_failure("Windows Job Object liveness query", error)
|
|
443
|
+
time.sleep(_RETRY_INTERVAL_SECONDS)
|
|
444
|
+
|
|
445
|
+
def _tree_active_until(
|
|
446
|
+
self,
|
|
447
|
+
deadline: float | None,
|
|
448
|
+
timeout: float | None,
|
|
449
|
+
) -> bool:
|
|
450
|
+
job_handle = self._require_job_handle()
|
|
451
|
+
while True:
|
|
452
|
+
try:
|
|
453
|
+
return int(self._adapter.active_processes(job_handle)) != 0
|
|
454
|
+
except BaseException as error:
|
|
455
|
+
self._record_failure("Windows Job Object liveness query", error)
|
|
456
|
+
remaining = _remaining_timeout(deadline)
|
|
457
|
+
if remaining is not None and remaining <= 0.0:
|
|
458
|
+
assert timeout is not None
|
|
459
|
+
raise subprocess.TimeoutExpired(self.args, timeout) from error
|
|
460
|
+
time.sleep(
|
|
461
|
+
min(
|
|
462
|
+
_RETRY_INTERVAL_SECONDS,
|
|
463
|
+
remaining if remaining is not None else _RETRY_INTERVAL_SECONDS,
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
def wait_tree_empty(self, timeout: float | None = None) -> None:
|
|
468
|
+
"""Wait for complete tree disappearance while retaining job ownership."""
|
|
469
|
+
|
|
470
|
+
if timeout is not None:
|
|
471
|
+
timeout = float(timeout)
|
|
472
|
+
if not math.isfinite(timeout) or timeout < 0.0:
|
|
473
|
+
raise ValueError("timeout must be finite and non-negative")
|
|
474
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
475
|
+
while True:
|
|
476
|
+
if not self._tree_active_until(deadline, timeout):
|
|
477
|
+
return
|
|
478
|
+
remaining = _remaining_timeout(deadline)
|
|
479
|
+
if remaining is not None and remaining <= 0.0:
|
|
480
|
+
assert timeout is not None
|
|
481
|
+
raise subprocess.TimeoutExpired(self.args, timeout)
|
|
482
|
+
time.sleep(
|
|
483
|
+
min(
|
|
484
|
+
_RETRY_INTERVAL_SECONDS,
|
|
485
|
+
remaining if remaining is not None else _RETRY_INTERVAL_SECONDS,
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
def close_after_empty(self) -> None:
|
|
490
|
+
"""Release handles only after the direct process and tree are quiescent."""
|
|
491
|
+
|
|
492
|
+
if self._closed:
|
|
493
|
+
return
|
|
494
|
+
if self.poll() is None:
|
|
495
|
+
raise WindowsShutdownJobError(
|
|
496
|
+
"refusing to close a live retained Windows direct process"
|
|
497
|
+
)
|
|
498
|
+
if self.tree_active():
|
|
499
|
+
raise WindowsShutdownJobError(
|
|
500
|
+
"refusing to close a retained Windows Job Object with active processes"
|
|
501
|
+
)
|
|
502
|
+
self._close_thread_handle()
|
|
503
|
+
while self._deferred_handles:
|
|
504
|
+
handle = self._deferred_handles[0]
|
|
505
|
+
try:
|
|
506
|
+
self._adapter.close_handle(handle)
|
|
507
|
+
except BaseException as error:
|
|
508
|
+
self._record_failure("Windows inherited-handle close", error)
|
|
509
|
+
time.sleep(_RETRY_INTERVAL_SECONDS)
|
|
510
|
+
continue
|
|
511
|
+
self._deferred_handles.pop(0)
|
|
512
|
+
while self._process_handle is not None:
|
|
513
|
+
handle = self._process_handle
|
|
514
|
+
try:
|
|
515
|
+
self._adapter.close_handle(handle)
|
|
516
|
+
except BaseException as error:
|
|
517
|
+
self._record_failure("Windows direct-process handle close", error)
|
|
518
|
+
time.sleep(_RETRY_INTERVAL_SECONDS)
|
|
519
|
+
continue
|
|
520
|
+
self._process_handle = None
|
|
521
|
+
while self._job_handle is not None:
|
|
522
|
+
handle = self._job_handle
|
|
523
|
+
try:
|
|
524
|
+
self._adapter.close_handle(handle)
|
|
525
|
+
except BaseException as error:
|
|
526
|
+
self._record_failure("Windows Job Object handle close", error)
|
|
527
|
+
time.sleep(_RETRY_INTERVAL_SECONDS)
|
|
528
|
+
continue
|
|
529
|
+
self._job_handle = None
|
|
530
|
+
self._closed = True
|
|
531
|
+
|
|
532
|
+
def _terminate_exact_suspended_process(self, exit_code: int) -> None:
|
|
533
|
+
"""Abort a process that failed containment verification before resume."""
|
|
534
|
+
|
|
535
|
+
process_handle = self._require_process_handle()
|
|
536
|
+
while self.poll() is None:
|
|
537
|
+
try:
|
|
538
|
+
self._adapter.terminate_process(process_handle, int(exit_code))
|
|
539
|
+
except BaseException as error:
|
|
540
|
+
self._record_failure(
|
|
541
|
+
"Windows suspended direct-process termination",
|
|
542
|
+
error,
|
|
543
|
+
)
|
|
544
|
+
time.sleep(_RETRY_INTERVAL_SECONDS)
|
|
545
|
+
continue
|
|
546
|
+
break
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _close_empty_job(adapter: _JobAdapter, job_handle: Any) -> str | None:
|
|
550
|
+
"""Try once to close a proven-empty job and return an exact failure."""
|
|
551
|
+
|
|
552
|
+
try:
|
|
553
|
+
adapter.close_handle(job_handle)
|
|
554
|
+
except BaseException as error:
|
|
555
|
+
# No process has been created/assigned, so returning lets launcher exit
|
|
556
|
+
# close this empty kernel object without risking a live-tree release or
|
|
557
|
+
# extending the absolute startup interval indefinitely.
|
|
558
|
+
return _exact_error("empty Windows Job Object handle close", error)
|
|
559
|
+
return None
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _abort_failed_spawn(
|
|
563
|
+
child: WindowsContainedProcess,
|
|
564
|
+
*,
|
|
565
|
+
membership_verified: bool,
|
|
566
|
+
) -> None:
|
|
567
|
+
"""Drain a contained/suspended process before propagating spawn failure."""
|
|
568
|
+
|
|
569
|
+
if membership_verified:
|
|
570
|
+
child.terminate_tree()
|
|
571
|
+
else:
|
|
572
|
+
# The initial thread has not been resumed, so exact retained-handle
|
|
573
|
+
# termination cannot miss a descendant and does not rely on uncertain
|
|
574
|
+
# Job membership. No PID is opened or targeted.
|
|
575
|
+
child._terminate_exact_suspended_process(_FORCED_SHUTDOWN_EXIT_CODE)
|
|
576
|
+
child.wait()
|
|
577
|
+
child.wait_tree_empty()
|
|
578
|
+
child.close_after_empty()
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def spawn_contained(
|
|
582
|
+
argv: Sequence[str | os.PathLike[str]],
|
|
583
|
+
env: Mapping[str, str],
|
|
584
|
+
*,
|
|
585
|
+
adapter: _JobAdapter | None = None,
|
|
586
|
+
) -> WindowsContainedProcess:
|
|
587
|
+
"""Create a suspended GUI atomically inside one retained Windows job.
|
|
588
|
+
|
|
589
|
+
``adapter`` is an intentional non-Windows regression-test seam. Production
|
|
590
|
+
callers omit it and receive the lazy ctypes-backed Win32 implementation.
|
|
591
|
+
"""
|
|
592
|
+
|
|
593
|
+
preserved_argv = list(argv)
|
|
594
|
+
if not preserved_argv:
|
|
595
|
+
raise ValueError("contained Windows process argv must not be empty")
|
|
596
|
+
preserved_environment = dict(env)
|
|
597
|
+
active_adapter = _CtypesWindowsJobAdapter() if adapter is None else adapter
|
|
598
|
+
|
|
599
|
+
try:
|
|
600
|
+
job_handle = active_adapter.create_job()
|
|
601
|
+
except BaseException as error:
|
|
602
|
+
raise WindowsShutdownJobError(
|
|
603
|
+
_exact_error("Windows Job Object creation", error)
|
|
604
|
+
) from error
|
|
605
|
+
try:
|
|
606
|
+
active_adapter.configure_kill_on_close(job_handle)
|
|
607
|
+
except BaseException as error:
|
|
608
|
+
cleanup_failure = _close_empty_job(active_adapter, job_handle)
|
|
609
|
+
diagnostic = _exact_error(
|
|
610
|
+
"Windows Job Object kill-on-close configuration",
|
|
611
|
+
error,
|
|
612
|
+
)
|
|
613
|
+
if cleanup_failure is not None:
|
|
614
|
+
diagnostic = "; ".join((diagnostic, cleanup_failure))
|
|
615
|
+
raise WindowsShutdownJobError(diagnostic) from error
|
|
616
|
+
try:
|
|
617
|
+
spawned = active_adapter.create_process_in_job(
|
|
618
|
+
job_handle,
|
|
619
|
+
preserved_argv,
|
|
620
|
+
preserved_environment,
|
|
621
|
+
)
|
|
622
|
+
except BaseException as error:
|
|
623
|
+
cleanup_failure = _close_empty_job(active_adapter, job_handle)
|
|
624
|
+
diagnostic = _exact_error("contained Windows GUI process creation", error)
|
|
625
|
+
if cleanup_failure is not None:
|
|
626
|
+
diagnostic = "; ".join((diagnostic, cleanup_failure))
|
|
627
|
+
raise WindowsShutdownJobError(diagnostic) from error
|
|
628
|
+
|
|
629
|
+
child = WindowsContainedProcess(
|
|
630
|
+
adapter=active_adapter,
|
|
631
|
+
job_handle=job_handle,
|
|
632
|
+
process_handle=spawned.process_handle,
|
|
633
|
+
thread_handle=spawned.thread_handle,
|
|
634
|
+
pid=spawned.pid,
|
|
635
|
+
argv=preserved_argv,
|
|
636
|
+
deferred_handles=spawned.deferred_handles,
|
|
637
|
+
initial_failures=spawned.failures,
|
|
638
|
+
)
|
|
639
|
+
membership_verified = False
|
|
640
|
+
try:
|
|
641
|
+
membership_verified = bool(
|
|
642
|
+
active_adapter.process_is_in_job(
|
|
643
|
+
spawned.process_handle,
|
|
644
|
+
job_handle,
|
|
645
|
+
)
|
|
646
|
+
)
|
|
647
|
+
if not membership_verified:
|
|
648
|
+
raise WindowsShutdownJobError(
|
|
649
|
+
"Windows created the suspended GUI outside its retained Job Object"
|
|
650
|
+
)
|
|
651
|
+
active_adapter.resume_thread(spawned.thread_handle)
|
|
652
|
+
# A failed CloseHandle must not hold startup past its absolute
|
|
653
|
+
# authentication deadline after the GUI has been resumed. Retain the
|
|
654
|
+
# exact thread handle and retry its close during final tree teardown.
|
|
655
|
+
child._close_thread_handle(retry=False)
|
|
656
|
+
except BaseException as error:
|
|
657
|
+
_abort_failed_spawn(child, membership_verified=membership_verified)
|
|
658
|
+
retained_failures = child.failures
|
|
659
|
+
if isinstance(error, WindowsShutdownJobError):
|
|
660
|
+
diagnostic = str(error)
|
|
661
|
+
else:
|
|
662
|
+
diagnostic = _exact_error("contained Windows GUI process activation", error)
|
|
663
|
+
if retained_failures:
|
|
664
|
+
diagnostic = "; ".join((diagnostic, *retained_failures))
|
|
665
|
+
if isinstance(error, WindowsShutdownJobError):
|
|
666
|
+
raise WindowsShutdownJobError(diagnostic) from error
|
|
667
|
+
raise WindowsShutdownJobError(diagnostic) from error
|
|
668
|
+
return child
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
class _JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
|
|
672
|
+
_fields_ = [
|
|
673
|
+
("PerProcessUserTimeLimit", ctypes.c_longlong),
|
|
674
|
+
("PerJobUserTimeLimit", ctypes.c_longlong),
|
|
675
|
+
("LimitFlags", wintypes.DWORD),
|
|
676
|
+
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
677
|
+
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
678
|
+
("ActiveProcessLimit", wintypes.DWORD),
|
|
679
|
+
("Affinity", ctypes.c_size_t),
|
|
680
|
+
("PriorityClass", wintypes.DWORD),
|
|
681
|
+
("SchedulingClass", wintypes.DWORD),
|
|
682
|
+
]
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
class _IO_COUNTERS(ctypes.Structure):
|
|
686
|
+
_fields_ = [
|
|
687
|
+
("ReadOperationCount", ctypes.c_ulonglong),
|
|
688
|
+
("WriteOperationCount", ctypes.c_ulonglong),
|
|
689
|
+
("OtherOperationCount", ctypes.c_ulonglong),
|
|
690
|
+
("ReadTransferCount", ctypes.c_ulonglong),
|
|
691
|
+
("WriteTransferCount", ctypes.c_ulonglong),
|
|
692
|
+
("OtherTransferCount", ctypes.c_ulonglong),
|
|
693
|
+
]
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
class _JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
|
|
697
|
+
_fields_ = [
|
|
698
|
+
("BasicLimitInformation", _JOBOBJECT_BASIC_LIMIT_INFORMATION),
|
|
699
|
+
("IoInfo", _IO_COUNTERS),
|
|
700
|
+
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
701
|
+
("JobMemoryLimit", ctypes.c_size_t),
|
|
702
|
+
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
703
|
+
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
704
|
+
]
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
class _JOBOBJECT_BASIC_ACCOUNTING_INFORMATION(ctypes.Structure):
|
|
708
|
+
_fields_ = [
|
|
709
|
+
("TotalUserTime", ctypes.c_longlong),
|
|
710
|
+
("TotalKernelTime", ctypes.c_longlong),
|
|
711
|
+
("ThisPeriodTotalUserTime", ctypes.c_longlong),
|
|
712
|
+
("ThisPeriodTotalKernelTime", ctypes.c_longlong),
|
|
713
|
+
("TotalPageFaultCount", wintypes.DWORD),
|
|
714
|
+
("TotalProcesses", wintypes.DWORD),
|
|
715
|
+
("ActiveProcesses", wintypes.DWORD),
|
|
716
|
+
("TotalTerminatedProcesses", wintypes.DWORD),
|
|
717
|
+
]
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
class _STARTUPINFOW(ctypes.Structure):
|
|
721
|
+
_fields_ = [
|
|
722
|
+
("cb", wintypes.DWORD),
|
|
723
|
+
("lpReserved", wintypes.LPWSTR),
|
|
724
|
+
("lpDesktop", wintypes.LPWSTR),
|
|
725
|
+
("lpTitle", wintypes.LPWSTR),
|
|
726
|
+
("dwX", wintypes.DWORD),
|
|
727
|
+
("dwY", wintypes.DWORD),
|
|
728
|
+
("dwXSize", wintypes.DWORD),
|
|
729
|
+
("dwYSize", wintypes.DWORD),
|
|
730
|
+
("dwXCountChars", wintypes.DWORD),
|
|
731
|
+
("dwYCountChars", wintypes.DWORD),
|
|
732
|
+
("dwFillAttribute", wintypes.DWORD),
|
|
733
|
+
("dwFlags", wintypes.DWORD),
|
|
734
|
+
("wShowWindow", wintypes.WORD),
|
|
735
|
+
("cbReserved2", wintypes.WORD),
|
|
736
|
+
("lpReserved2", ctypes.POINTER(wintypes.BYTE)),
|
|
737
|
+
("hStdInput", wintypes.HANDLE),
|
|
738
|
+
("hStdOutput", wintypes.HANDLE),
|
|
739
|
+
("hStdError", wintypes.HANDLE),
|
|
740
|
+
]
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
class _STARTUPINFOEXW(ctypes.Structure):
|
|
744
|
+
_fields_ = [
|
|
745
|
+
("StartupInfo", _STARTUPINFOW),
|
|
746
|
+
("lpAttributeList", ctypes.c_void_p),
|
|
747
|
+
]
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
class _PROCESS_INFORMATION(ctypes.Structure):
|
|
751
|
+
_fields_ = [
|
|
752
|
+
("hProcess", wintypes.HANDLE),
|
|
753
|
+
("hThread", wintypes.HANDLE),
|
|
754
|
+
("dwProcessId", wintypes.DWORD),
|
|
755
|
+
("dwThreadId", wintypes.DWORD),
|
|
756
|
+
]
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _environment_block(env: Mapping[str, str]) -> str:
|
|
760
|
+
entries: list[tuple[str, str]] = []
|
|
761
|
+
for raw_key, raw_value in env.items():
|
|
762
|
+
key = os.fsdecode(raw_key)
|
|
763
|
+
value = os.fsdecode(raw_value)
|
|
764
|
+
if "\0" in key or "\0" in value:
|
|
765
|
+
raise ValueError("Windows environment entries must not contain NUL")
|
|
766
|
+
if not key or "=" in key[1:]:
|
|
767
|
+
raise ValueError(f"invalid Windows environment name {key!r}")
|
|
768
|
+
entries.append((key, value))
|
|
769
|
+
entries.sort(key=lambda item: item[0].upper())
|
|
770
|
+
return "\0".join(f"{key}={value}" for key, value in entries) + "\0\0"
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
class _CtypesWindowsJobAdapter:
|
|
774
|
+
"""Lazy Win32 implementation; construction is rejected off Windows."""
|
|
775
|
+
|
|
776
|
+
def __init__(self) -> None:
|
|
777
|
+
if os.name != "nt":
|
|
778
|
+
raise OSError("Windows Job Object containment is only available on Windows")
|
|
779
|
+
win_dll = ctypes.WinDLL # type: ignore[attr-defined]
|
|
780
|
+
self._kernel32 = win_dll("kernel32", use_last_error=True)
|
|
781
|
+
self._bind_functions()
|
|
782
|
+
|
|
783
|
+
@staticmethod
|
|
784
|
+
def _last_error() -> OSError:
|
|
785
|
+
get_last_error = ctypes.get_last_error # type: ignore[attr-defined]
|
|
786
|
+
win_error = ctypes.WinError # type: ignore[attr-defined]
|
|
787
|
+
return win_error(get_last_error())
|
|
788
|
+
|
|
789
|
+
def _bind_functions(self) -> None:
|
|
790
|
+
kernel32 = self._kernel32
|
|
791
|
+
kernel32.CreateJobObjectW.argtypes = (ctypes.c_void_p, wintypes.LPCWSTR)
|
|
792
|
+
kernel32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
793
|
+
kernel32.SetInformationJobObject.argtypes = (
|
|
794
|
+
wintypes.HANDLE,
|
|
795
|
+
ctypes.c_int,
|
|
796
|
+
ctypes.c_void_p,
|
|
797
|
+
wintypes.DWORD,
|
|
798
|
+
)
|
|
799
|
+
kernel32.SetInformationJobObject.restype = wintypes.BOOL
|
|
800
|
+
kernel32.QueryInformationJobObject.argtypes = (
|
|
801
|
+
wintypes.HANDLE,
|
|
802
|
+
ctypes.c_int,
|
|
803
|
+
ctypes.c_void_p,
|
|
804
|
+
wintypes.DWORD,
|
|
805
|
+
ctypes.POINTER(wintypes.DWORD),
|
|
806
|
+
)
|
|
807
|
+
kernel32.QueryInformationJobObject.restype = wintypes.BOOL
|
|
808
|
+
kernel32.InitializeProcThreadAttributeList.argtypes = (
|
|
809
|
+
ctypes.c_void_p,
|
|
810
|
+
wintypes.DWORD,
|
|
811
|
+
wintypes.DWORD,
|
|
812
|
+
ctypes.POINTER(ctypes.c_size_t),
|
|
813
|
+
)
|
|
814
|
+
kernel32.InitializeProcThreadAttributeList.restype = wintypes.BOOL
|
|
815
|
+
kernel32.UpdateProcThreadAttribute.argtypes = (
|
|
816
|
+
ctypes.c_void_p,
|
|
817
|
+
wintypes.DWORD,
|
|
818
|
+
ctypes.c_size_t,
|
|
819
|
+
ctypes.c_void_p,
|
|
820
|
+
ctypes.c_size_t,
|
|
821
|
+
ctypes.c_void_p,
|
|
822
|
+
ctypes.c_void_p,
|
|
823
|
+
)
|
|
824
|
+
kernel32.UpdateProcThreadAttribute.restype = wintypes.BOOL
|
|
825
|
+
kernel32.DeleteProcThreadAttributeList.argtypes = (ctypes.c_void_p,)
|
|
826
|
+
kernel32.DeleteProcThreadAttributeList.restype = None
|
|
827
|
+
kernel32.CreateProcessW.argtypes = (
|
|
828
|
+
wintypes.LPCWSTR,
|
|
829
|
+
wintypes.LPWSTR,
|
|
830
|
+
ctypes.c_void_p,
|
|
831
|
+
ctypes.c_void_p,
|
|
832
|
+
wintypes.BOOL,
|
|
833
|
+
wintypes.DWORD,
|
|
834
|
+
ctypes.c_void_p,
|
|
835
|
+
wintypes.LPCWSTR,
|
|
836
|
+
ctypes.c_void_p,
|
|
837
|
+
ctypes.POINTER(_PROCESS_INFORMATION),
|
|
838
|
+
)
|
|
839
|
+
kernel32.CreateProcessW.restype = wintypes.BOOL
|
|
840
|
+
kernel32.IsProcessInJob.argtypes = (
|
|
841
|
+
wintypes.HANDLE,
|
|
842
|
+
wintypes.HANDLE,
|
|
843
|
+
ctypes.POINTER(wintypes.BOOL),
|
|
844
|
+
)
|
|
845
|
+
kernel32.IsProcessInJob.restype = wintypes.BOOL
|
|
846
|
+
kernel32.ResumeThread.argtypes = (wintypes.HANDLE,)
|
|
847
|
+
kernel32.ResumeThread.restype = wintypes.DWORD
|
|
848
|
+
kernel32.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD)
|
|
849
|
+
kernel32.WaitForSingleObject.restype = wintypes.DWORD
|
|
850
|
+
kernel32.GetExitCodeProcess.argtypes = (
|
|
851
|
+
wintypes.HANDLE,
|
|
852
|
+
ctypes.POINTER(wintypes.DWORD),
|
|
853
|
+
)
|
|
854
|
+
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
|
|
855
|
+
kernel32.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
|
|
856
|
+
kernel32.TerminateJobObject.restype = wintypes.BOOL
|
|
857
|
+
kernel32.TerminateProcess.argtypes = (wintypes.HANDLE, wintypes.UINT)
|
|
858
|
+
kernel32.TerminateProcess.restype = wintypes.BOOL
|
|
859
|
+
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
|
|
860
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
861
|
+
kernel32.GetStdHandle.argtypes = (wintypes.DWORD,)
|
|
862
|
+
kernel32.GetStdHandle.restype = wintypes.HANDLE
|
|
863
|
+
kernel32.GetCurrentProcess.argtypes = ()
|
|
864
|
+
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
|
|
865
|
+
kernel32.DuplicateHandle.argtypes = (
|
|
866
|
+
wintypes.HANDLE,
|
|
867
|
+
wintypes.HANDLE,
|
|
868
|
+
wintypes.HANDLE,
|
|
869
|
+
ctypes.POINTER(wintypes.HANDLE),
|
|
870
|
+
wintypes.DWORD,
|
|
871
|
+
wintypes.BOOL,
|
|
872
|
+
wintypes.DWORD,
|
|
873
|
+
)
|
|
874
|
+
kernel32.DuplicateHandle.restype = wintypes.BOOL
|
|
875
|
+
kernel32.GetFileType.argtypes = (wintypes.HANDLE,)
|
|
876
|
+
kernel32.GetFileType.restype = wintypes.DWORD
|
|
877
|
+
|
|
878
|
+
def create_job(self) -> Any:
|
|
879
|
+
handle = self._kernel32.CreateJobObjectW(None, None)
|
|
880
|
+
if not handle:
|
|
881
|
+
raise self._last_error()
|
|
882
|
+
return handle
|
|
883
|
+
|
|
884
|
+
def configure_kill_on_close(self, job_handle: Any) -> None:
|
|
885
|
+
information = _JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
|
|
886
|
+
information.BasicLimitInformation.LimitFlags = (
|
|
887
|
+
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
888
|
+
)
|
|
889
|
+
if not self._kernel32.SetInformationJobObject(
|
|
890
|
+
job_handle,
|
|
891
|
+
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS,
|
|
892
|
+
ctypes.byref(information),
|
|
893
|
+
ctypes.sizeof(information),
|
|
894
|
+
):
|
|
895
|
+
raise self._last_error()
|
|
896
|
+
|
|
897
|
+
def _duplicate_standard_handle(self, selector: int) -> Any:
|
|
898
|
+
source = self._kernel32.GetStdHandle(selector)
|
|
899
|
+
if not source or _handle_value(source) == _invalid_handle_value():
|
|
900
|
+
return wintypes.HANDLE(_INVALID_HANDLE_VALUE)
|
|
901
|
+
duplicate = wintypes.HANDLE()
|
|
902
|
+
current_process = self._kernel32.GetCurrentProcess()
|
|
903
|
+
if not self._kernel32.DuplicateHandle(
|
|
904
|
+
current_process,
|
|
905
|
+
source,
|
|
906
|
+
current_process,
|
|
907
|
+
ctypes.byref(duplicate),
|
|
908
|
+
0,
|
|
909
|
+
True,
|
|
910
|
+
_DUPLICATE_SAME_ACCESS,
|
|
911
|
+
):
|
|
912
|
+
raise self._last_error()
|
|
913
|
+
return duplicate
|
|
914
|
+
|
|
915
|
+
def _close_duplicated_standard_handles(
|
|
916
|
+
self,
|
|
917
|
+
handles: Sequence[Any],
|
|
918
|
+
) -> tuple[tuple[Any, ...], tuple[str, ...]]:
|
|
919
|
+
deferred: list[Any] = []
|
|
920
|
+
failures: list[str] = []
|
|
921
|
+
for handle in handles:
|
|
922
|
+
if _handle_value(handle) == _invalid_handle_value():
|
|
923
|
+
continue
|
|
924
|
+
try:
|
|
925
|
+
self.close_handle(handle)
|
|
926
|
+
except BaseException as error:
|
|
927
|
+
failures.append(
|
|
928
|
+
_exact_error("Windows duplicated standard-handle close", error)
|
|
929
|
+
)
|
|
930
|
+
deferred.append(handle)
|
|
931
|
+
return tuple(deferred), tuple(failures)
|
|
932
|
+
|
|
933
|
+
def _attribute_list(
|
|
934
|
+
self,
|
|
935
|
+
job_handle: Any,
|
|
936
|
+
inherited_handles: Sequence[Any],
|
|
937
|
+
) -> tuple[Any, Any, Any | None, Any]:
|
|
938
|
+
attribute_count = 1 + bool(inherited_handles)
|
|
939
|
+
required_size = ctypes.c_size_t()
|
|
940
|
+
initialize = self._kernel32.InitializeProcThreadAttributeList
|
|
941
|
+
ctypes.set_last_error(0) # type: ignore[attr-defined]
|
|
942
|
+
initial_result = initialize(
|
|
943
|
+
None,
|
|
944
|
+
int(attribute_count),
|
|
945
|
+
0,
|
|
946
|
+
ctypes.byref(required_size),
|
|
947
|
+
)
|
|
948
|
+
get_last_error = ctypes.get_last_error # type: ignore[attr-defined]
|
|
949
|
+
if (
|
|
950
|
+
initial_result
|
|
951
|
+
or get_last_error() != _ERROR_INSUFFICIENT_BUFFER
|
|
952
|
+
or required_size.value == 0
|
|
953
|
+
):
|
|
954
|
+
raise self._last_error()
|
|
955
|
+
buffer = ctypes.create_string_buffer(required_size.value)
|
|
956
|
+
attribute_list = ctypes.cast(buffer, ctypes.c_void_p)
|
|
957
|
+
if not initialize(
|
|
958
|
+
attribute_list,
|
|
959
|
+
int(attribute_count),
|
|
960
|
+
0,
|
|
961
|
+
ctypes.byref(required_size),
|
|
962
|
+
):
|
|
963
|
+
raise self._last_error()
|
|
964
|
+
|
|
965
|
+
job_array = (wintypes.HANDLE * 1)(job_handle)
|
|
966
|
+
if not self._kernel32.UpdateProcThreadAttribute(
|
|
967
|
+
attribute_list,
|
|
968
|
+
0,
|
|
969
|
+
_PROC_THREAD_ATTRIBUTE_JOB_LIST,
|
|
970
|
+
ctypes.cast(job_array, ctypes.c_void_p),
|
|
971
|
+
ctypes.sizeof(job_array),
|
|
972
|
+
None,
|
|
973
|
+
None,
|
|
974
|
+
):
|
|
975
|
+
error = self._last_error()
|
|
976
|
+
self._kernel32.DeleteProcThreadAttributeList(attribute_list)
|
|
977
|
+
raise error
|
|
978
|
+
|
|
979
|
+
handle_array = None
|
|
980
|
+
if inherited_handles:
|
|
981
|
+
handle_array = (wintypes.HANDLE * len(inherited_handles))(
|
|
982
|
+
*inherited_handles
|
|
983
|
+
)
|
|
984
|
+
if not self._kernel32.UpdateProcThreadAttribute(
|
|
985
|
+
attribute_list,
|
|
986
|
+
0,
|
|
987
|
+
_PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
|
|
988
|
+
ctypes.cast(handle_array, ctypes.c_void_p),
|
|
989
|
+
ctypes.sizeof(handle_array),
|
|
990
|
+
None,
|
|
991
|
+
None,
|
|
992
|
+
):
|
|
993
|
+
error = self._last_error()
|
|
994
|
+
self._kernel32.DeleteProcThreadAttributeList(attribute_list)
|
|
995
|
+
raise error
|
|
996
|
+
return buffer, attribute_list, handle_array, job_array
|
|
997
|
+
|
|
998
|
+
def create_process_in_job(
|
|
999
|
+
self,
|
|
1000
|
+
job_handle: Any,
|
|
1001
|
+
argv: Sequence[str | os.PathLike[str]],
|
|
1002
|
+
env: Mapping[str, str],
|
|
1003
|
+
) -> _SpawnedHandles:
|
|
1004
|
+
decoded_argv = [os.fsdecode(argument) for argument in argv]
|
|
1005
|
+
command_line = ctypes.create_unicode_buffer(
|
|
1006
|
+
subprocess.list2cmdline(decoded_argv)
|
|
1007
|
+
)
|
|
1008
|
+
environment_text = _environment_block(env)
|
|
1009
|
+
environment = ctypes.create_unicode_buffer(
|
|
1010
|
+
environment_text,
|
|
1011
|
+
len(environment_text),
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
standard_handles: list[Any] = []
|
|
1015
|
+
try:
|
|
1016
|
+
for selector in (
|
|
1017
|
+
_STD_INPUT_HANDLE,
|
|
1018
|
+
_STD_OUTPUT_HANDLE,
|
|
1019
|
+
_STD_ERROR_HANDLE,
|
|
1020
|
+
):
|
|
1021
|
+
standard_handles.append(self._duplicate_standard_handle(selector))
|
|
1022
|
+
except BaseException as error:
|
|
1023
|
+
_deferred, cleanup_failures = self._close_duplicated_standard_handles(
|
|
1024
|
+
standard_handles
|
|
1025
|
+
)
|
|
1026
|
+
diagnostic = _exact_error("Windows standard-handle duplication", error)
|
|
1027
|
+
if cleanup_failures:
|
|
1028
|
+
diagnostic = "; ".join((diagnostic, *cleanup_failures))
|
|
1029
|
+
raise WindowsShutdownJobError(diagnostic) from error
|
|
1030
|
+
inheritable_standard_handles = [
|
|
1031
|
+
handle
|
|
1032
|
+
for handle in standard_handles
|
|
1033
|
+
if _handle_value(handle) != _invalid_handle_value()
|
|
1034
|
+
and not (
|
|
1035
|
+
_handle_value(handle) & 0x3 == 0x3
|
|
1036
|
+
and self._kernel32.GetFileType(handle) == _FILE_TYPE_CHAR
|
|
1037
|
+
)
|
|
1038
|
+
]
|
|
1039
|
+
# The HANDLE_LIST route is valid only when all three STARTUPINFO
|
|
1040
|
+
# standard handles can be listed. Windows console handles with their
|
|
1041
|
+
# low bits set are explicitly forbidden from HANDLE_LIST; an absent
|
|
1042
|
+
# standard handle is invalid too. In either case, use CreateProcess's
|
|
1043
|
+
# ordinary inherited-standard-stream behavior without
|
|
1044
|
+
# STARTF_USESTDHANDLES, matching subprocess.Popen(..., stdio=None).
|
|
1045
|
+
explicit_standard_handles = len(inheritable_standard_handles) == 3
|
|
1046
|
+
inherited_handles = (
|
|
1047
|
+
inheritable_standard_handles if explicit_standard_handles else []
|
|
1048
|
+
)
|
|
1049
|
+
attribute_list: Any | None = None
|
|
1050
|
+
spawned: _SpawnedHandles | None = None
|
|
1051
|
+
creation_error: BaseException | None = None
|
|
1052
|
+
try:
|
|
1053
|
+
_buffer, attribute_list, _handle_array, _job_array = self._attribute_list(
|
|
1054
|
+
job_handle, inherited_handles
|
|
1055
|
+
)
|
|
1056
|
+
startup = _STARTUPINFOEXW()
|
|
1057
|
+
startup.StartupInfo.cb = ctypes.sizeof(_STARTUPINFOEXW)
|
|
1058
|
+
if explicit_standard_handles:
|
|
1059
|
+
startup.StartupInfo.dwFlags = _STARTF_USESTDHANDLES
|
|
1060
|
+
(
|
|
1061
|
+
startup.StartupInfo.hStdInput,
|
|
1062
|
+
startup.StartupInfo.hStdOutput,
|
|
1063
|
+
startup.StartupInfo.hStdError,
|
|
1064
|
+
) = standard_handles
|
|
1065
|
+
startup.lpAttributeList = attribute_list
|
|
1066
|
+
process_information = _PROCESS_INFORMATION()
|
|
1067
|
+
if not self._kernel32.CreateProcessW(
|
|
1068
|
+
None,
|
|
1069
|
+
ctypes.cast(command_line, wintypes.LPWSTR),
|
|
1070
|
+
None,
|
|
1071
|
+
None,
|
|
1072
|
+
bool(inherited_handles),
|
|
1073
|
+
_WINDOWS_CONTAINED_CREATION_FLAGS,
|
|
1074
|
+
ctypes.cast(environment, ctypes.c_void_p),
|
|
1075
|
+
None,
|
|
1076
|
+
ctypes.byref(startup),
|
|
1077
|
+
ctypes.byref(process_information),
|
|
1078
|
+
):
|
|
1079
|
+
raise self._last_error()
|
|
1080
|
+
spawned = _SpawnedHandles(
|
|
1081
|
+
process_handle=process_information.hProcess,
|
|
1082
|
+
thread_handle=process_information.hThread,
|
|
1083
|
+
pid=int(process_information.dwProcessId),
|
|
1084
|
+
)
|
|
1085
|
+
except BaseException as error:
|
|
1086
|
+
creation_error = error
|
|
1087
|
+
finally:
|
|
1088
|
+
if attribute_list is not None:
|
|
1089
|
+
self._kernel32.DeleteProcThreadAttributeList(attribute_list)
|
|
1090
|
+
deferred_handles, cleanup_failures = (
|
|
1091
|
+
self._close_duplicated_standard_handles(standard_handles)
|
|
1092
|
+
)
|
|
1093
|
+
if creation_error is not None:
|
|
1094
|
+
diagnostic = _exact_error(
|
|
1095
|
+
"Windows contained-process creation", creation_error
|
|
1096
|
+
)
|
|
1097
|
+
if cleanup_failures:
|
|
1098
|
+
diagnostic = "; ".join((diagnostic, *cleanup_failures))
|
|
1099
|
+
raise WindowsShutdownJobError(diagnostic) from creation_error
|
|
1100
|
+
assert spawned is not None
|
|
1101
|
+
return _SpawnedHandles(
|
|
1102
|
+
process_handle=spawned.process_handle,
|
|
1103
|
+
thread_handle=spawned.thread_handle,
|
|
1104
|
+
pid=spawned.pid,
|
|
1105
|
+
deferred_handles=deferred_handles,
|
|
1106
|
+
failures=cleanup_failures,
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
def process_is_in_job(self, process_handle: Any, job_handle: Any) -> bool:
|
|
1110
|
+
result = wintypes.BOOL()
|
|
1111
|
+
if not self._kernel32.IsProcessInJob(
|
|
1112
|
+
process_handle,
|
|
1113
|
+
job_handle,
|
|
1114
|
+
ctypes.byref(result),
|
|
1115
|
+
):
|
|
1116
|
+
raise self._last_error()
|
|
1117
|
+
return bool(result.value)
|
|
1118
|
+
|
|
1119
|
+
def resume_thread(self, thread_handle: Any) -> None:
|
|
1120
|
+
if self._kernel32.ResumeThread(thread_handle) == _WAIT_FAILED:
|
|
1121
|
+
raise self._last_error()
|
|
1122
|
+
|
|
1123
|
+
def wait_process(self, process_handle: Any, milliseconds: int) -> bool:
|
|
1124
|
+
result = int(
|
|
1125
|
+
self._kernel32.WaitForSingleObject(process_handle, int(milliseconds))
|
|
1126
|
+
)
|
|
1127
|
+
if result == _WAIT_OBJECT_0:
|
|
1128
|
+
return True
|
|
1129
|
+
if result == _WAIT_TIMEOUT:
|
|
1130
|
+
return False
|
|
1131
|
+
if result == _WAIT_FAILED:
|
|
1132
|
+
raise self._last_error()
|
|
1133
|
+
raise OSError(f"unexpected WaitForSingleObject result {result}")
|
|
1134
|
+
|
|
1135
|
+
def process_exit_code(self, process_handle: Any) -> int:
|
|
1136
|
+
result = wintypes.DWORD()
|
|
1137
|
+
if not self._kernel32.GetExitCodeProcess(
|
|
1138
|
+
process_handle,
|
|
1139
|
+
ctypes.byref(result),
|
|
1140
|
+
):
|
|
1141
|
+
raise self._last_error()
|
|
1142
|
+
return int(result.value)
|
|
1143
|
+
|
|
1144
|
+
def terminate_job(self, job_handle: Any, exit_code: int) -> None:
|
|
1145
|
+
if not self._kernel32.TerminateJobObject(job_handle, int(exit_code)):
|
|
1146
|
+
raise self._last_error()
|
|
1147
|
+
|
|
1148
|
+
def terminate_process(self, process_handle: Any, exit_code: int) -> None:
|
|
1149
|
+
if not self._kernel32.TerminateProcess(process_handle, int(exit_code)):
|
|
1150
|
+
raise self._last_error()
|
|
1151
|
+
|
|
1152
|
+
def active_processes(self, job_handle: Any) -> int:
|
|
1153
|
+
information = _JOBOBJECT_BASIC_ACCOUNTING_INFORMATION()
|
|
1154
|
+
if not self._kernel32.QueryInformationJobObject(
|
|
1155
|
+
job_handle,
|
|
1156
|
+
_JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS,
|
|
1157
|
+
ctypes.byref(information),
|
|
1158
|
+
ctypes.sizeof(information),
|
|
1159
|
+
None,
|
|
1160
|
+
):
|
|
1161
|
+
raise self._last_error()
|
|
1162
|
+
return int(information.ActiveProcesses)
|
|
1163
|
+
|
|
1164
|
+
def close_handle(self, handle: Any) -> None:
|
|
1165
|
+
if not self._kernel32.CloseHandle(handle):
|
|
1166
|
+
raise self._last_error()
|