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.
Files changed (130) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +67 -0
  4. qplot/_build_info.json +1 -0
  5. qplot/_metadata.py +75 -0
  6. qplot/_repair.py +14 -0
  7. qplot/_shutdown_supervisor.py +4049 -0
  8. qplot/_version.py +12 -0
  9. qplot/_windows_shutdown_job.py +1166 -0
  10. qplot/configuration/__init__.py +3 -0
  11. qplot/configuration/config.py +578 -0
  12. qplot/configuration/config_schema.json +357 -0
  13. qplot/configuration/fonts.py +3 -0
  14. qplot/configuration/scripts.py +255 -0
  15. qplot/configuration/themes/__init__.py +13 -0
  16. qplot/configuration/themes/_base.py +592 -0
  17. qplot/configuration/themes/blank.py +17 -0
  18. qplot/configuration/themes/dark.py +54 -0
  19. qplot/configuration/themes/light.py +54 -0
  20. qplot/datahandling/LoadFromDB.py +224 -0
  21. qplot/datahandling/__init__.py +47 -0
  22. qplot/datahandling/_trusted_live_helper.py +812 -0
  23. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  24. qplot/datahandling/database.py +2340 -0
  25. qplot/datahandling/dimensions.py +43 -0
  26. qplot/datahandling/file_identity.py +461 -0
  27. qplot/datahandling/parameter_data.py +149 -0
  28. qplot/datahandling/plot_progress.py +17 -0
  29. qplot/datahandling/qcodes_cache.py +277 -0
  30. qplot/datahandling/qcodes_compat.py +14 -0
  31. qplot/datahandling/readSQL.py +2442 -0
  32. qplot/datahandling/readonly.py +2566 -0
  33. qplot/datahandling/source_activity.py +21 -0
  34. qplot/datahandling/trusted_array_preview.py +148 -0
  35. qplot/datahandling/trusted_derived_cache.py +1605 -0
  36. qplot/datahandling/trusted_derived_rendering.py +876 -0
  37. qplot/datahandling/trusted_heatmap.py +246 -0
  38. qplot/datahandling/trusted_live.py +2808 -0
  39. qplot/datahandling/trusted_live_queries.py +5907 -0
  40. qplot/datahandling/trusted_live_service.py +1919 -0
  41. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  42. qplot/datahandling/trusted_plot.py +283 -0
  43. qplot/datahandling/trusted_presentation.py +1065 -0
  44. qplot/datahandling/trusted_snapshot.py +1132 -0
  45. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  46. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  47. qplot/diagnostics.py +241 -0
  48. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  49. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  50. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  51. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  52. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  53. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  54. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  55. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  56. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  57. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  58. qplot/testdata.py +1804 -0
  59. qplot/tools/__init__.py +13 -0
  60. qplot/tools/finite_means.py +123 -0
  61. qplot/tools/general.py +139 -0
  62. qplot/tools/heatmap_geometry.py +391 -0
  63. qplot/tools/operation_registry.py +179 -0
  64. qplot/tools/plot_tools.py +650 -0
  65. qplot/tools/sample_statistics.py +111 -0
  66. qplot/tools/worker.py +2838 -0
  67. qplot/windows/__init__.py +9 -0
  68. qplot/windows/_colorbar.py +422 -0
  69. qplot/windows/_commands.py +523 -0
  70. qplot/windows/_config_persistence.py +123 -0
  71. qplot/windows/_database_actions.py +4933 -0
  72. qplot/windows/_dataset_handle.py +221 -0
  73. qplot/windows/_display_precision.py +48 -0
  74. qplot/windows/_dragdrop.py +70 -0
  75. qplot/windows/_export_paths.py +1167 -0
  76. qplot/windows/_help.py +210 -0
  77. qplot/windows/_mixed_appearance.py +303 -0
  78. qplot/windows/_mixed_plots.py +461 -0
  79. qplot/windows/_native_averaging.py +323 -0
  80. qplot/windows/_native_transforms.py +551 -0
  81. qplot/windows/_plot1d_snap.py +612 -0
  82. qplot/windows/_plot1d_traces.py +2236 -0
  83. qplot/windows/_plot2d_colorbar.py +1127 -0
  84. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  85. qplot/windows/_plot2d_layers.py +2291 -0
  86. qplot/windows/_plot2d_sweeps.py +712 -0
  87. qplot/windows/_plotWin.py +2082 -0
  88. qplot/windows/_plot_actions.py +2703 -0
  89. qplot/windows/_plot_annotation.py +186 -0
  90. qplot/windows/_plot_appearance.py +283 -0
  91. qplot/windows/_plot_axis_scaling.py +1521 -0
  92. qplot/windows/_plot_data_viewbox.py +94 -0
  93. qplot/windows/_plot_drop_axes.py +224 -0
  94. qplot/windows/_plot_export.py +1445 -0
  95. qplot/windows/_plot_feedback.py +112 -0
  96. qplot/windows/_plot_marquee.py +659 -0
  97. qplot/windows/_plot_refresh.py +950 -0
  98. qplot/windows/_plot_source_promotion.py +178 -0
  99. qplot/windows/_plot_state.py +237 -0
  100. qplot/windows/_plot_status.py +115 -0
  101. qplot/windows/_plot_tiling.py +35 -0
  102. qplot/windows/_plot_transform_labels.py +60 -0
  103. qplot/windows/_preferences.py +724 -0
  104. qplot/windows/_refresh_interval.py +172 -0
  105. qplot/windows/_run_controls.py +789 -0
  106. qplot/windows/_shortcuts.py +47 -0
  107. qplot/windows/_subplots/__init__.py +8 -0
  108. qplot/windows/_subplots/subplot1d.py +532 -0
  109. qplot/windows/_subplots/subplot2d.py +919 -0
  110. qplot/windows/_svg_export.py +209 -0
  111. qplot/windows/_trusted_derived_qt.py +1759 -0
  112. qplot/windows/_widgets/__init__.py +23 -0
  113. qplot/windows/_widgets/_run_formatting.py +387 -0
  114. qplot/windows/_widgets/details_tables.py +1119 -0
  115. qplot/windows/_widgets/dropbox.py +290 -0
  116. qplot/windows/_widgets/operations.py +460 -0
  117. qplot/windows/_widgets/preview.py +2997 -0
  118. qplot/windows/_widgets/run_list_items.py +488 -0
  119. qplot/windows/_widgets/toolbar.py +111 -0
  120. qplot/windows/_widgets/treeWidgets.py +2572 -0
  121. qplot/windows/_window_controls.py +414 -0
  122. qplot/windows/main.py +1632 -0
  123. qplot/windows/plot1d.py +313 -0
  124. qplot/windows/plot2d.py +1935 -0
  125. qplotter-1.6.0.dist-info/METADATA +322 -0
  126. qplotter-1.6.0.dist-info/RECORD +130 -0
  127. qplotter-1.6.0.dist-info/WHEEL +5 -0
  128. qplotter-1.6.0.dist-info/entry_points.txt +4 -0
  129. qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
  130. 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()