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,4049 @@
1
+ """Contained process-tree supervision for the production qPlot GUI.
2
+
3
+ The supervisor is deliberately independent of Qt. It creates an OS-owned
4
+ containment boundary before the GUI can import Qt or start helpers, retains the
5
+ exact child/tree ownership objects until the complete tree is gone, and
6
+ enforces the single deadline accepted from the GUI. No process is opened
7
+ later by PID and there is no cancellation message after the deadline is armed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import errno
13
+ import hashlib
14
+ import hmac
15
+ import json
16
+ import math
17
+ import os
18
+ import secrets
19
+ import select
20
+ import signal
21
+ import socket
22
+ import struct
23
+ import subprocess
24
+ import sys
25
+ import threading
26
+ import time
27
+ from collections.abc import Callable, Mapping, Sequence
28
+ from dataclasses import dataclass, field
29
+ from enum import Enum
30
+ from types import TracebackType
31
+ from typing import Any
32
+
33
+ _FORCED_SHUTDOWN_EXIT_CODE = 70
34
+ _BOOTSTRAP_ENVIRONMENT_KEY = "_QPLOT_SHUTDOWN_SUPERVISOR_V1"
35
+ _API_LAUNCHER_BOOTSTRAP_ENVIRONMENT_KEY = "_QPLOT_API_LAUNCHER_V1"
36
+ _GUI_CHILD_SENTINEL = "_qplot_supervised_gui_v1"
37
+ _API_LAUNCHER_SENTINEL = "_qplot_public_api_launcher_v1"
38
+ _LOOPBACK_HOST = "127.0.0.1"
39
+ _DEFAULT_STARTUP_TIMEOUT_SECONDS = 5.0
40
+ _OBSERVATION_INTERVAL_SECONDS = 0.01
41
+ _TERMINATION_RETRY_SECONDS = 0.005
42
+ _DIAGNOSTIC_PUBLISH_BUDGET_SECONDS = 0.025
43
+ _API_RESULT_IO_TIMEOUT_SECONDS = 1.0
44
+ _API_RESULT_MAX_BYTES = 256 * 1024
45
+ _TERMINATION_DIAGNOSTIC_LIMIT = 64
46
+ _POSIX_KILL_STABILITY_PASSES = 3
47
+
48
+ _PROTOCOL_MAGIC = b"QPLTSV1\0"
49
+ _PROTOCOL_VERSION = 1
50
+ _HELLO = 1
51
+ _STARTUP_READY = 2
52
+ _ARM = 3
53
+ _ARM_ACK = 4
54
+ _API_LAUNCHER_HELLO = 5
55
+ _API_LAUNCHER_READY = 6
56
+ _API_LAUNCHER_RESULT = 7
57
+ _API_LAUNCHER_CANCEL = 8
58
+ _NONCE_BYTES = 32
59
+ _AUTHENTICATION_BYTES = 32
60
+
61
+ # Every protocol record has one strict, fixed size. The eight-byte payload is
62
+ # an unsigned PID for HELLO/READY and an IEEE-754 deadline for ARM/ARM_ACK.
63
+ _FRAME_BODY = struct.Struct("!8sBB6x32s32s8s")
64
+ _FRAME_SIZE = _FRAME_BODY.size + _AUTHENTICATION_BYTES
65
+ _PID_PAYLOAD = struct.Struct("!Q")
66
+ _DEADLINE_PAYLOAD = struct.Struct("!d")
67
+ _API_RESULT_HEADER = struct.Struct("!8sBB6x32sI")
68
+
69
+
70
+ class ShutdownSupervisorError(RuntimeError):
71
+ """A launcher bootstrap or authenticated-protocol operation failed."""
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class _Bootstrap:
76
+ host: str
77
+ port: int
78
+ authentication_key: bytes
79
+ session_nonce: bytes
80
+ startup_timeout: float
81
+ database_path: str | None
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class _ApiLauncherBootstrap:
86
+ host: str
87
+ port: int
88
+ authentication_key: bytes
89
+ session_nonce: bytes
90
+ startup_deadline: float
91
+ caller_pid: int
92
+ child_argv: tuple[str, ...]
93
+ database_path: str | None
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class _SupervisionOutcome:
98
+ return_code: int
99
+ forced: bool = False
100
+ signal_number: int | None = None
101
+ diagnostics: tuple[str, ...] = ()
102
+
103
+
104
+ def _outcome_with_diagnostics(
105
+ outcome: _SupervisionOutcome,
106
+ *diagnostics: str,
107
+ ) -> _SupervisionOutcome:
108
+ return _SupervisionOutcome(
109
+ outcome.return_code,
110
+ forced=outcome.forced,
111
+ signal_number=outcome.signal_number,
112
+ diagnostics=outcome.diagnostics + tuple(diagnostics),
113
+ )
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class _CommittedArm:
118
+ """The immutable authenticated ARM record installed by one assignment."""
119
+
120
+ deadline: float
121
+ arm_nonce: bytes
122
+ deadline_payload: bytes
123
+
124
+
125
+ @dataclass
126
+ class _ArmedState:
127
+ """Holder whose committed-record assignment is the irreversible transition."""
128
+
129
+ committed: _CommittedArm | None = None
130
+
131
+ @property
132
+ def deadline(self) -> float | None:
133
+ record = self.committed
134
+ return None if record is None else record.deadline
135
+
136
+ @property
137
+ def arm_nonce(self) -> bytes | None:
138
+ record = self.committed
139
+ return None if record is None else record.arm_nonce
140
+
141
+ @property
142
+ def deadline_payload(self) -> bytes | None:
143
+ record = self.committed
144
+ return None if record is None else record.deadline_payload
145
+
146
+
147
+ @dataclass
148
+ class _ReadyState:
149
+ """Whether authenticated STARTUP_READY has been sent successfully."""
150
+
151
+ committed: bool = False
152
+
153
+
154
+ @dataclass
155
+ class _ApiLauncherReadyState:
156
+ """Retain the caller channel across a post-READY interruption."""
157
+
158
+ channel: socket.socket | None = None
159
+ committed: bool = False
160
+
161
+
162
+ @dataclass
163
+ class _LauncherSignalState:
164
+ """The first ordinary termination signal delivered to the launcher."""
165
+
166
+ signal_number: int | None = None
167
+ claimed: bool = False
168
+ cancellation_event: threading.Event | None = None
169
+ cancellation_claimed: bool = False
170
+
171
+
172
+ @dataclass
173
+ class _ApiCallerControlState:
174
+ """The immutable caller-cancellation/final-outcome transition."""
175
+
176
+ cancellation_event: threading.Event = field(default_factory=threading.Event)
177
+ _lock: threading.Lock = field(default_factory=threading.Lock)
178
+ _cancellation_diagnostic: str | None = None
179
+ _final_outcome_committed: bool = False
180
+
181
+ def commit_cancellation(self, diagnostic: str) -> bool:
182
+ with self._lock:
183
+ if (
184
+ self._final_outcome_committed
185
+ or self._cancellation_diagnostic is not None
186
+ ):
187
+ return False
188
+ self._cancellation_diagnostic = diagnostic
189
+ self.cancellation_event.set()
190
+ return True
191
+
192
+ def commit_final_outcome(self) -> None:
193
+ with self._lock:
194
+ self._final_outcome_committed = True
195
+
196
+ @property
197
+ def cancellation_diagnostic(self) -> str | None:
198
+ with self._lock:
199
+ return self._cancellation_diagnostic
200
+
201
+
202
+ @dataclass
203
+ class _ApiLauncherResultObservation:
204
+ """One result reader's outcome plus process-death EOF observation."""
205
+
206
+ outcome: _SupervisionOutcome | None = None
207
+ error: BaseException | None = None
208
+ eof_observed: bool = False
209
+ completed: threading.Event = field(default_factory=threading.Event)
210
+
211
+
212
+ class _CancellationWorkerState(Enum):
213
+ """One committed lifecycle for the caller-to-launcher cancellation owner."""
214
+
215
+ NOT_STARTED = "not-started"
216
+ STARTING = "starting"
217
+ RUNNING = "running"
218
+ EOF_FALLBACK = "eof-fallback"
219
+ COMPLETE = "complete"
220
+
221
+
222
+ @dataclass
223
+ class _ApiLauncherCancellationSender:
224
+ """Own the caller's sole irreversible control-direction operation.
225
+
226
+ Only the dedicated sender thread writes cancellation bytes. Requesters
227
+ merely set an event and ensure that worker exists, so a caller signal
228
+ cannot leave a partially sent record owned by an unwinding main thread.
229
+ The worker keeps its local byte offset across every absorbed
230
+ ``BaseException`` and resolves exactly once to either a complete
231
+ authenticated frame or write-side EOF.
232
+ """
233
+
234
+ channel: socket.socket
235
+ frame: bytes
236
+ _lock: threading.Lock = field(default_factory=threading.Lock)
237
+ _error_lock: threading.Lock = field(default_factory=threading.Lock)
238
+ _worker_condition: threading.Condition = field(
239
+ default_factory=threading.Condition,
240
+ )
241
+ _requested: threading.Event = field(default_factory=threading.Event)
242
+ _completed: threading.Event = field(default_factory=threading.Event)
243
+ _worker_entered: threading.Event = field(default_factory=threading.Event)
244
+ _worker_state: _CancellationWorkerState = _CancellationWorkerState.NOT_STARTED
245
+ _worker_owner_ident: int | None = None
246
+ _worker_creation_attempted: bool = False
247
+ _thread: threading.Thread | None = None
248
+ _start_attempted: bool = False
249
+ _offset: int = 0
250
+ _started_at: float | None = None
251
+ _deadline: float | None = None
252
+ _first_error: BaseException | None = None
253
+ diagnostic: str | None = None
254
+
255
+ def request(self) -> None:
256
+ """Request cancellation without allowing control flow to escape."""
257
+
258
+ while True:
259
+ try:
260
+ self._requested.set()
261
+ self._ensure_worker_started()
262
+ return
263
+ except BaseException as error:
264
+ self._remember_error(error)
265
+
266
+ def _ensure_worker_started(self) -> None:
267
+ owner_ident = threading.get_ident()
268
+ while True:
269
+ try:
270
+ _public_api_cancellation_boundary("worker_lookup")
271
+ except BaseException as error:
272
+ self._remember_error(error)
273
+ continue
274
+
275
+ fallback_owner = False
276
+ worker_to_start: threading.Thread | None = None
277
+ resolve_committed_start: threading.Thread | None = None
278
+ try:
279
+ with self._worker_condition:
280
+ state = self._worker_state
281
+ if state in {
282
+ _CancellationWorkerState.RUNNING,
283
+ _CancellationWorkerState.COMPLETE,
284
+ }:
285
+ return
286
+ if state is _CancellationWorkerState.EOF_FALLBACK:
287
+ if self._worker_owner_ident == owner_ident:
288
+ fallback_owner = True
289
+ else:
290
+ try:
291
+ self._worker_condition.wait(
292
+ _OBSERVATION_INTERVAL_SECONDS
293
+ )
294
+ except BaseException as error:
295
+ self._remember_error(error)
296
+ continue
297
+ elif state is _CancellationWorkerState.STARTING:
298
+ if self._worker_owner_ident == owner_ident:
299
+ pass
300
+ else:
301
+ try:
302
+ self._worker_condition.wait(
303
+ _OBSERVATION_INTERVAL_SECONDS
304
+ )
305
+ except BaseException as error:
306
+ self._remember_error(error)
307
+ continue
308
+ else:
309
+ # Ownership is committed before STARTING. An
310
+ # interruption after either assignment therefore
311
+ # leaves the same requester able to resume, while all
312
+ # other requesters wait for that durable owner.
313
+ committed_owner = self._worker_owner_ident
314
+ if committed_owner is None:
315
+ self._worker_owner_ident = owner_ident
316
+ elif committed_owner != owner_ident:
317
+ try:
318
+ self._worker_condition.wait(
319
+ _OBSERVATION_INTERVAL_SECONDS
320
+ )
321
+ except BaseException as error:
322
+ self._remember_error(error)
323
+ continue
324
+ self._worker_state = _CancellationWorkerState.STARTING
325
+
326
+ if fallback_owner:
327
+ self._worker_condition.notify_all()
328
+ continue_startup = False
329
+ else:
330
+ continue_startup = True
331
+
332
+ if continue_startup and self._thread is None:
333
+ if self._worker_creation_attempted:
334
+ fallback_owner = self._commit_worker_eof_fallback_locked()
335
+ self._worker_condition.notify_all()
336
+ continue_startup = False
337
+ else:
338
+ while True:
339
+ try:
340
+ _public_api_cancellation_boundary("worker_creation")
341
+ break
342
+ except BaseException as error:
343
+ self._remember_error(error)
344
+
345
+ # The committed attempt prevents a factory call
346
+ # from being repeated if control flow is lost
347
+ # immediately before or after its side effect.
348
+ self._worker_creation_attempted = True
349
+ try:
350
+ worker = _new_public_api_cancellation_worker(self)
351
+ except BaseException as error:
352
+ self._remember_error(error)
353
+ fallback_owner = (
354
+ self._commit_worker_eof_fallback_locked()
355
+ )
356
+ self._worker_condition.notify_all()
357
+ continue_startup = False
358
+ else:
359
+ # The object itself is the durable result of
360
+ # creation. A post-assignment interruption is
361
+ # resumed without constructing another worker.
362
+ self._thread = worker
363
+
364
+ assigned_worker = self._thread
365
+ if continue_startup and assigned_worker is not None:
366
+ while True:
367
+ try:
368
+ _public_api_cancellation_boundary("worker_assignment")
369
+ break
370
+ except BaseException as error:
371
+ self._remember_error(error)
372
+
373
+ if self._start_attempted:
374
+ # The only permitted start call was already
375
+ # committed. Never retry it: prove entry outside
376
+ # this lock or fail closed with the unique EOF
377
+ # owner.
378
+ resolve_committed_start = assigned_worker
379
+ else:
380
+ while True:
381
+ try:
382
+ _public_api_cancellation_boundary("worker_start")
383
+ break
384
+ except BaseException as error:
385
+ self._remember_error(error)
386
+
387
+ # Commit before the only Thread.start call. If an
388
+ # interruption lands after this assignment, retry
389
+ # resolves the uncertain attempt and never invokes
390
+ # start again.
391
+ self._start_attempted = True
392
+ worker_to_start = assigned_worker
393
+ except BaseException as error:
394
+ self._remember_error(error)
395
+ continue
396
+
397
+ if fallback_owner:
398
+ self._fail_closed_without_worker()
399
+ return
400
+
401
+ started = False
402
+ attempted_worker = worker_to_start or resolve_committed_start
403
+ if worker_to_start is not None:
404
+ # STARTING excludes every other requester, but the condition
405
+ # is released before Thread.start because the worker's socket
406
+ # operation must never run under a lifecycle lock.
407
+ try:
408
+ worker_to_start.start()
409
+ except BaseException as error:
410
+ self._remember_error(error)
411
+
412
+ if attempted_worker is not None:
413
+ while True:
414
+ try:
415
+ started = (
416
+ attempted_worker.ident is not None
417
+ or self._worker_entered.is_set()
418
+ )
419
+ break
420
+ except BaseException as error:
421
+ self._remember_error(error)
422
+ if not started:
423
+ try:
424
+ self._worker_entered.wait(_OBSERVATION_INTERVAL_SECONDS)
425
+ except BaseException as error:
426
+ self._remember_error(error)
427
+ while True:
428
+ try:
429
+ started = (
430
+ attempted_worker.ident is not None
431
+ or self._worker_entered.is_set()
432
+ )
433
+ break
434
+ except BaseException as error:
435
+ self._remember_error(error)
436
+
437
+ if started:
438
+ while True:
439
+ try:
440
+ _public_api_cancellation_boundary("worker_start_commit")
441
+ break
442
+ except BaseException as error:
443
+ self._remember_error(error)
444
+
445
+ while True:
446
+ try:
447
+ with self._worker_condition:
448
+ # A fast worker may already have resolved the socket
449
+ # operation and committed COMPLETE before start returns.
450
+ if self._worker_state is _CancellationWorkerState.STARTING:
451
+ # The worker entry handshake uses this same
452
+ # condition. Recheck it while committing the
453
+ # terminal startup direction so EOF can never race
454
+ # a late sender into socket I/O.
455
+ if started or self._worker_entered.is_set():
456
+ self._worker_state = _CancellationWorkerState.RUNNING
457
+ else:
458
+ fallback_owner = (
459
+ self._commit_worker_eof_fallback_locked()
460
+ )
461
+ self._worker_condition.notify_all()
462
+ break
463
+ except BaseException as error:
464
+ self._remember_error(error)
465
+
466
+ if fallback_owner:
467
+ self._fail_closed_without_worker()
468
+ return
469
+
470
+ def _commit_worker_eof_fallback_locked(self) -> bool:
471
+ """Commit the unique synchronous EOF owner under the start condition."""
472
+
473
+ if self._worker_state is _CancellationWorkerState.EOF_FALLBACK:
474
+ return False
475
+ while True:
476
+ try:
477
+ _public_api_cancellation_boundary("worker_eof_fallback_commit")
478
+ self._worker_state = _CancellationWorkerState.EOF_FALLBACK
479
+ return True
480
+ except BaseException as error:
481
+ self._remember_error(error)
482
+
483
+ def _remember_error(self, error: BaseException) -> None:
484
+ while True:
485
+ try:
486
+ with self._error_lock:
487
+ if self._first_error is None:
488
+ self._first_error = error
489
+ return
490
+ except BaseException:
491
+ # Diagnostic retention must not become a new caller-control
492
+ # escape path. The committed lifecycle remains authoritative.
493
+ continue
494
+
495
+ def _fail_closed_without_worker(self) -> None:
496
+ while True:
497
+ try:
498
+ _public_api_cancellation_boundary("write_side_shutdown")
499
+ _public_api_cancellation_shutdown(self.channel)
500
+ break
501
+ except OSError:
502
+ break
503
+ except BaseException as error:
504
+ self._remember_error(error)
505
+ self._finish()
506
+
507
+ def _run(self) -> None:
508
+ while True:
509
+ try:
510
+ with self._worker_condition:
511
+ self._worker_entered.set()
512
+ self._worker_condition.notify_all()
513
+ if self._worker_state in {
514
+ _CancellationWorkerState.EOF_FALLBACK,
515
+ _CancellationWorkerState.COMPLETE,
516
+ }:
517
+ return
518
+ break
519
+ except BaseException as error:
520
+ self._remember_error(error)
521
+ try:
522
+ while True:
523
+ try:
524
+ self._requested.wait()
525
+ break
526
+ except BaseException as error:
527
+ self._remember_error(error)
528
+ if self._requested.is_set():
529
+ break
530
+ _send_public_api_cancellation_record(self)
531
+ except BaseException as error:
532
+ self._remember_error(error)
533
+ self._fail_closed_without_worker()
534
+
535
+ def _finish(self) -> None:
536
+ while True:
537
+ try:
538
+ with self._worker_condition:
539
+ if self._worker_state is _CancellationWorkerState.STARTING:
540
+ self._worker_condition.wait(_OBSERVATION_INTERVAL_SECONDS)
541
+ continue
542
+ break
543
+ except BaseException as error:
544
+ self._remember_error(error)
545
+ while True:
546
+ try:
547
+ _public_api_cancellation_boundary("diagnostic_construction")
548
+ if self._first_error is not None:
549
+ self.diagnostic = _exact_error(
550
+ "public-API launcher cancellation",
551
+ self._first_error,
552
+ )
553
+ break
554
+ except BaseException as error:
555
+ self._remember_error(error)
556
+ while True:
557
+ try:
558
+ self._completed.set()
559
+ break
560
+ except BaseException as error:
561
+ self._remember_error(error)
562
+ while True:
563
+ try:
564
+ with self._worker_condition:
565
+ self._worker_state = _CancellationWorkerState.COMPLETE
566
+ self._worker_condition.notify_all()
567
+ return
568
+ except BaseException as error:
569
+ self._remember_error(error)
570
+
571
+ @property
572
+ def requested(self) -> bool:
573
+ return self._requested.is_set()
574
+
575
+ @property
576
+ def completed(self) -> threading.Event:
577
+ return self._completed
578
+
579
+
580
+ @dataclass
581
+ class _PendingCallerControlFlow:
582
+ """The exact first caller exception retained across containment cleanup."""
583
+
584
+ error: BaseException
585
+ traceback: TracebackType | None
586
+
587
+
588
+ class _InterruptGuardTransition(Enum):
589
+ """Separate attempted and confirmed signal-handler side effects."""
590
+
591
+ NOT_ATTEMPTED = "not-attempted"
592
+ ATTEMPTED = "attempted"
593
+ CONFIRMED = "confirmed"
594
+
595
+
596
+ @dataclass
597
+ class _CallerCleanupInterruptGuard:
598
+ """Temporarily absorb and count later SIGINTs during containment cleanup."""
599
+
600
+ _original_handler: tuple[Any] | None = None
601
+ _absorber: Any = field(init=False, repr=False)
602
+ _installation: _InterruptGuardTransition = _InterruptGuardTransition.NOT_ATTEMPTED
603
+ _restoration: _InterruptGuardTransition = _InterruptGuardTransition.NOT_ATTEMPTED
604
+ _not_applicable: bool = False
605
+ absorbed_sigints: int = 0
606
+
607
+ def __post_init__(self) -> None:
608
+ # Bound-method lookup creates a new wrapper each time. Retain the
609
+ # exact object installed with signal.signal so verification can use
610
+ # identity rather than an ambiguous equality comparison.
611
+ self._absorber = self._absorb_sigint
612
+
613
+ @property
614
+ def previous_handler(self) -> Any:
615
+ retained = self._original_handler
616
+ return None if retained is None else retained[0]
617
+
618
+ @property
619
+ def active(self) -> bool:
620
+ return (
621
+ self._installation is _InterruptGuardTransition.CONFIRMED
622
+ and self._restoration is not _InterruptGuardTransition.CONFIRMED
623
+ )
624
+
625
+ @property
626
+ def engagement_complete(self) -> bool:
627
+ return self.active or self._not_applicable
628
+
629
+ def engage(self) -> None:
630
+ if self.engagement_complete or (
631
+ self._restoration is _InterruptGuardTransition.CONFIRMED
632
+ ):
633
+ return
634
+ if threading.current_thread() is not threading.main_thread():
635
+ self._not_applicable = True
636
+ return
637
+
638
+ if self._original_handler is None:
639
+ _public_api_interrupt_guard_boundary("original_capture_before")
640
+ original_handler = signal.getsignal(signal.SIGINT)
641
+ _public_api_interrupt_guard_boundary("original_capture_after")
642
+ # One tuple assignment is the immutable capture commit. Retry
643
+ # paths never replace this exact caller-owned object.
644
+ self._original_handler = (original_handler,)
645
+
646
+ _public_api_interrupt_guard_boundary("installation_verify_before")
647
+ current_handler = signal.getsignal(signal.SIGINT)
648
+ if current_handler is self._absorber:
649
+ self._installation = _InterruptGuardTransition.CONFIRMED
650
+ return
651
+
652
+ self._installation = _InterruptGuardTransition.ATTEMPTED
653
+ _public_api_interrupt_guard_boundary("installation_signal_before")
654
+ signal.signal(signal.SIGINT, self._absorber)
655
+ _public_api_interrupt_guard_boundary("installation_signal_after")
656
+ current_handler = signal.getsignal(signal.SIGINT)
657
+ if current_handler is not self._absorber:
658
+ raise ShutdownSupervisorError(
659
+ "public-API caller cleanup SIGINT guard installation "
660
+ "could not be confirmed"
661
+ )
662
+ self._installation = _InterruptGuardTransition.CONFIRMED
663
+
664
+ def _absorb_sigint(self, _signum: int, _frame: Any) -> None:
665
+ self.absorbed_sigints += 1
666
+
667
+ def restore(self) -> None:
668
+ if self._not_applicable or (
669
+ self._restoration is _InterruptGuardTransition.CONFIRMED
670
+ ):
671
+ return
672
+ retained = self._original_handler
673
+ if retained is None:
674
+ return
675
+ if threading.current_thread() is not threading.main_thread():
676
+ return
677
+
678
+ original_handler = retained[0]
679
+ _public_api_interrupt_guard_boundary("restoration_verify_before")
680
+ current_handler = signal.getsignal(signal.SIGINT)
681
+ if current_handler is original_handler:
682
+ self._restoration = _InterruptGuardTransition.CONFIRMED
683
+ return
684
+
685
+ self._restoration = _InterruptGuardTransition.ATTEMPTED
686
+ _public_api_interrupt_guard_boundary("restoration_signal_before")
687
+ signal.signal(signal.SIGINT, original_handler)
688
+ _public_api_interrupt_guard_boundary("restoration_signal_after")
689
+ current_handler = signal.getsignal(signal.SIGINT)
690
+ if current_handler is not original_handler:
691
+ raise ShutdownSupervisorError(
692
+ "public-API caller cleanup SIGINT handler restoration "
693
+ "could not be confirmed"
694
+ )
695
+ self._restoration = _InterruptGuardTransition.CONFIRMED
696
+
697
+
698
+ @dataclass
699
+ class _LauncherSignalGuards:
700
+ """Signal dispositions and mask state replaced for one supervision run."""
701
+
702
+ previous_handlers: dict[int, Any]
703
+ previous_mask: set[signal.Signals] | None = None
704
+
705
+
706
+ class _LauncherSignalReceived(BaseException):
707
+ """Control-flow marker raised after a launcher signal has been recorded."""
708
+
709
+ def __init__(self, signal_number: int):
710
+ super().__init__(signal_number)
711
+ self.signal_number = signal_number
712
+
713
+
714
+ class _LauncherCancellationReceived(BaseException):
715
+ """Control-flow marker for an immutable public-API cancellation."""
716
+
717
+
718
+ @dataclass
719
+ class _PosixProcessGroup:
720
+ """Identity-bound POSIX session/process group led by the unreaped child."""
721
+
722
+ pgid: int | None = None
723
+
724
+ def popen_options(self) -> dict[str, Any]:
725
+ return {"start_new_session": True}
726
+
727
+ def assign(self, child: Any) -> None:
728
+ expected_pgid = int(child.pid)
729
+ # start_new_session establishes this identity in the child before
730
+ # exec. Retain it even if verification itself is interrupted/fails so
731
+ # the launcher still has a bounded cleanup target.
732
+ self.pgid = expected_pgid
733
+ while True:
734
+ try:
735
+ actual_pgid = os.getpgid(expected_pgid)
736
+ break
737
+ except InterruptedError:
738
+ continue
739
+ if actual_pgid != expected_pgid:
740
+ raise ShutdownSupervisorError(
741
+ "shutdown launcher GUI child did not enter its dedicated "
742
+ f"process group: expected {expected_pgid}, found {actual_pgid}"
743
+ )
744
+
745
+ def terminate(self) -> None:
746
+ pgid = self.pgid
747
+ if pgid is None:
748
+ raise ShutdownSupervisorError(
749
+ "shutdown launcher POSIX process group is not assigned"
750
+ )
751
+ os.killpg(pgid, signal.SIGKILL)
752
+
753
+ def active(self) -> bool:
754
+ pgid = self.pgid
755
+ if pgid is None:
756
+ return False
757
+ while True:
758
+ try:
759
+ os.killpg(pgid, 0)
760
+ except ProcessLookupError:
761
+ return False
762
+ except InterruptedError:
763
+ continue
764
+ except PermissionError:
765
+ raise
766
+ return True
767
+
768
+ def close(self) -> None:
769
+ return None
770
+
771
+
772
+ def _exact_error(context: str, error: BaseException) -> str:
773
+ return f"{context} raised {type(error).__name__}: {error}"
774
+
775
+
776
+ def _report_launcher_failure(diagnostic: str) -> None:
777
+ """Report a pre-arm failure without importing qPlot's logging stack."""
778
+
779
+ try:
780
+ print(f"qPlot shutdown launcher: {diagnostic}", file=sys.stderr, flush=True)
781
+ except BaseException:
782
+ # Reporting is best effort. A broken inherited stderr must not turn a
783
+ # protocol failure into a second exception.
784
+ pass
785
+
786
+
787
+ def _launcher_termination_signals() -> tuple[int, ...]:
788
+ names = (
789
+ ("SIGINT", "SIGTERM")
790
+ if os.name == "nt"
791
+ else (
792
+ "SIGHUP",
793
+ "SIGINT",
794
+ "SIGQUIT",
795
+ "SIGTERM",
796
+ )
797
+ )
798
+ return tuple(
799
+ dict.fromkeys(
800
+ int(signal_number)
801
+ for name in names
802
+ if (signal_number := getattr(signal, name, None)) is not None
803
+ )
804
+ )
805
+
806
+
807
+ def _record_launcher_signal(
808
+ state: _LauncherSignalState,
809
+ signum: int,
810
+ _frame: Any,
811
+ ) -> None:
812
+ """Record only the first signal; ownership is released by the main loop."""
813
+
814
+ if state.signal_number is None:
815
+ state.signal_number = int(signum)
816
+
817
+
818
+ def _install_launcher_signal_guards(
819
+ state: _LauncherSignalState,
820
+ ) -> _LauncherSignalGuards:
821
+ """Protect spawn and establish explicit POSIX child-reaping semantics."""
822
+
823
+ guards = _LauncherSignalGuards(previous_handlers={})
824
+ try:
825
+ for signal_number in _launcher_termination_signals():
826
+ guards.previous_handlers[signal_number] = signal.getsignal(signal_number)
827
+
828
+ def handler(signum: int, frame: Any, *, _state=state) -> None:
829
+ _record_launcher_signal(_state, signum, frame)
830
+
831
+ signal.signal(signal_number, handler)
832
+ if os.name != "nt" and hasattr(signal, "SIGCHLD"):
833
+ signal_number = int(signal.SIGCHLD)
834
+ guards.previous_handlers[signal_number] = signal.getsignal(signal_number)
835
+ signal.signal(signal_number, signal.SIG_DFL)
836
+ pthread_sigmask = getattr(signal, "pthread_sigmask", None)
837
+ if os.name != "nt" and pthread_sigmask is not None:
838
+ guards.previous_mask = pthread_sigmask(
839
+ signal.SIG_UNBLOCK,
840
+ set(_launcher_termination_signals()),
841
+ )
842
+ except BaseException:
843
+ _restore_launcher_signal_guards(guards)
844
+ raise
845
+ return guards
846
+
847
+
848
+ def _restore_launcher_signal_guards(guards: _LauncherSignalGuards) -> None:
849
+ pthread_sigmask = getattr(signal, "pthread_sigmask", None)
850
+ if (
851
+ os.name != "nt"
852
+ and pthread_sigmask is not None
853
+ and guards.previous_mask is not None
854
+ ):
855
+ try:
856
+ pthread_sigmask(signal.SIG_SETMASK, guards.previous_mask)
857
+ except (OSError, RuntimeError, ValueError):
858
+ pass
859
+ for signal_number, previous_handler in guards.previous_handlers.items():
860
+ try:
861
+ signal.signal(signal_number, previous_handler)
862
+ except (OSError, RuntimeError, ValueError):
863
+ pass
864
+
865
+
866
+ def _raise_if_launcher_signalled(state: _LauncherSignalState) -> None:
867
+ if _claim_launcher_cancellation(state):
868
+ raise _LauncherCancellationReceived
869
+ signal_number = _claim_launcher_signal(state)
870
+ if signal_number is not None:
871
+ raise _LauncherSignalReceived(signal_number)
872
+
873
+
874
+ def _claim_launcher_cancellation(state: _LauncherSignalState) -> bool:
875
+ """Claim the immutable API caller-cancellation event exactly once."""
876
+
877
+ cancellation_event = state.cancellation_event
878
+ if (
879
+ cancellation_event is None
880
+ or state.cancellation_claimed
881
+ or not cancellation_event.is_set()
882
+ ):
883
+ return False
884
+ state.cancellation_claimed = True
885
+ return True
886
+
887
+
888
+ def _claim_launcher_signal(state: _LauncherSignalState) -> int | None:
889
+ """Claim the first pending signal exactly once for cleanup/propagation."""
890
+
891
+ signal_number = state.signal_number
892
+ if signal_number is None or state.claimed:
893
+ return None
894
+ state.claimed = True
895
+ return int(signal_number)
896
+
897
+
898
+ def _encode_frame(
899
+ frame_type: int,
900
+ *,
901
+ authentication_key: bytes,
902
+ session_nonce: bytes,
903
+ message_nonce: bytes,
904
+ payload: bytes,
905
+ ) -> bytes:
906
+ if len(authentication_key) != _AUTHENTICATION_BYTES:
907
+ raise ValueError("supervisor authentication key has the wrong length")
908
+ if len(session_nonce) != _NONCE_BYTES:
909
+ raise ValueError("supervisor session nonce has the wrong length")
910
+ if len(message_nonce) != _NONCE_BYTES:
911
+ raise ValueError("supervisor message nonce has the wrong length")
912
+ if len(payload) != 8:
913
+ raise ValueError("supervisor frame payload has the wrong length")
914
+ body = _FRAME_BODY.pack(
915
+ _PROTOCOL_MAGIC,
916
+ _PROTOCOL_VERSION,
917
+ frame_type,
918
+ session_nonce,
919
+ message_nonce,
920
+ payload,
921
+ )
922
+ authentication = hmac.digest(authentication_key, body, hashlib.sha256)
923
+ return body + authentication
924
+
925
+
926
+ def _decode_frame(
927
+ frame: bytes,
928
+ *,
929
+ authentication_key: bytes,
930
+ session_nonce: bytes,
931
+ ) -> tuple[int, bytes, bytes]:
932
+ if len(frame) != _FRAME_SIZE:
933
+ raise ShutdownSupervisorError(
934
+ f"invalid supervisor frame length {len(frame)}; expected {_FRAME_SIZE}"
935
+ )
936
+ body = frame[: _FRAME_BODY.size]
937
+ supplied_authentication = frame[_FRAME_BODY.size :]
938
+ expected_authentication = hmac.digest(
939
+ authentication_key,
940
+ body,
941
+ hashlib.sha256,
942
+ )
943
+ if not hmac.compare_digest(supplied_authentication, expected_authentication):
944
+ raise ShutdownSupervisorError("invalid supervisor frame authentication")
945
+ (
946
+ magic,
947
+ version,
948
+ frame_type,
949
+ supplied_session,
950
+ message_nonce,
951
+ payload,
952
+ ) = _FRAME_BODY.unpack(body)
953
+ if magic != _PROTOCOL_MAGIC:
954
+ raise ShutdownSupervisorError("invalid supervisor frame magic")
955
+ if version != _PROTOCOL_VERSION:
956
+ raise ShutdownSupervisorError(
957
+ f"unsupported supervisor protocol version {version}"
958
+ )
959
+ if not hmac.compare_digest(supplied_session, session_nonce):
960
+ raise ShutdownSupervisorError("invalid supervisor session nonce")
961
+ return frame_type, message_nonce, payload
962
+
963
+
964
+ def _encode_arm(
965
+ authentication_key: bytes,
966
+ session_nonce: bytes,
967
+ arm_nonce: bytes,
968
+ hard_deadline: float,
969
+ ) -> bytes:
970
+ """Encode one authenticated ARM record (private regression-test seam)."""
971
+
972
+ deadline = float(hard_deadline)
973
+ if not math.isfinite(deadline):
974
+ raise ValueError("shutdown hard deadline must be finite")
975
+ return _encode_frame(
976
+ _ARM,
977
+ authentication_key=authentication_key,
978
+ session_nonce=session_nonce,
979
+ message_nonce=arm_nonce,
980
+ payload=_DEADLINE_PAYLOAD.pack(deadline),
981
+ )
982
+
983
+
984
+ def _receive_exact(
985
+ channel: socket.socket,
986
+ size: int,
987
+ *,
988
+ deadline: float | None = None,
989
+ signal_state: _LauncherSignalState | None = None,
990
+ ) -> bytes:
991
+ payload = bytearray()
992
+ while len(payload) < size:
993
+ if signal_state is not None:
994
+ _raise_if_launcher_signalled(signal_state)
995
+ if deadline is not None:
996
+ remaining = _timeout_within(deadline)
997
+ if remaining <= 0.0:
998
+ raise TimeoutError("supervisor control-channel receive timed out")
999
+ channel.settimeout(min(_OBSERVATION_INTERVAL_SECONDS, remaining))
1000
+ try:
1001
+ chunk = channel.recv(size - len(payload))
1002
+ except TimeoutError:
1003
+ if deadline is None:
1004
+ raise
1005
+ continue
1006
+ if not chunk:
1007
+ raise ShutdownSupervisorError(
1008
+ "supervisor control channel closed before a complete frame"
1009
+ )
1010
+ payload.extend(chunk)
1011
+ return bytes(payload)
1012
+
1013
+
1014
+ def _timeout_within(deadline: float) -> float:
1015
+ return max(0.0, deadline - time.monotonic())
1016
+
1017
+
1018
+ def _supervised_child_claimed_pid() -> int:
1019
+ """Return the PID retained by the direct launcher process handle.
1020
+
1021
+ CPython's Windows virtual-environment executable is a redirector that
1022
+ remains as the direct child while the base interpreter runs qPlot as its
1023
+ descendant. That interpreter must authenticate the redirector PID so the
1024
+ launcher's retained process handle and the protocol identify the same
1025
+ contained process.
1026
+ """
1027
+
1028
+ if os.name == "nt" and sys.prefix != sys.base_prefix:
1029
+ base_executable = getattr(sys, "_base_executable", None)
1030
+ if base_executable is not None and os.path.normcase(
1031
+ os.path.abspath(os.fsdecode(base_executable))
1032
+ ) != os.path.normcase(os.path.abspath(sys.executable)):
1033
+ return os.getppid()
1034
+ return os.getpid()
1035
+
1036
+
1037
+ class ShutdownSupervisorClient:
1038
+ """GUI-side endpoint for the launcher's single authenticated ARM message."""
1039
+
1040
+ def __init__(self, bootstrap: _Bootstrap):
1041
+ self._bootstrap = bootstrap
1042
+ self._channel: socket.socket | None = None
1043
+ self._connected = False
1044
+ self._arm_attempted = False
1045
+ self._arm_acknowledged = False
1046
+ self._pending_arm_nonce: bytes | None = None
1047
+ self._pending_deadline_payload: bytes | None = None
1048
+
1049
+ @classmethod
1050
+ def from_environment(cls) -> ShutdownSupervisorClient:
1051
+ """Consume the private launcher marker before Qt or helpers start."""
1052
+
1053
+ encoded = os.environ.pop(_BOOTSTRAP_ENVIRONMENT_KEY, None)
1054
+ if encoded is None:
1055
+ raise ShutdownSupervisorError(
1056
+ "private shutdown-launcher bootstrap marker is missing"
1057
+ )
1058
+ try:
1059
+ raw = json.loads(encoded)
1060
+ except (TypeError, ValueError) as error:
1061
+ raise ShutdownSupervisorError(
1062
+ _exact_error("shutdown-launcher bootstrap decoding", error)
1063
+ ) from error
1064
+ expected_fields = {
1065
+ "version",
1066
+ "host",
1067
+ "port",
1068
+ "authentication_key",
1069
+ "session_nonce",
1070
+ "startup_timeout",
1071
+ "database_path",
1072
+ }
1073
+ if not isinstance(raw, dict) or set(raw) != expected_fields:
1074
+ raise ShutdownSupervisorError(
1075
+ "shutdown-launcher bootstrap fields are invalid"
1076
+ )
1077
+ if raw["version"] != _PROTOCOL_VERSION:
1078
+ raise ShutdownSupervisorError(
1079
+ "shutdown-launcher bootstrap version is unsupported"
1080
+ )
1081
+ host = raw["host"]
1082
+ port = raw["port"]
1083
+ startup_timeout = raw["startup_timeout"]
1084
+ database_path = raw["database_path"]
1085
+ if host != _LOOPBACK_HOST:
1086
+ raise ShutdownSupervisorError(
1087
+ "shutdown-launcher bootstrap endpoint is not loopback"
1088
+ )
1089
+ if type(port) is not int or not 0 < port < 65536:
1090
+ raise ShutdownSupervisorError("shutdown-launcher bootstrap port is invalid")
1091
+ if not isinstance(startup_timeout, (int, float)) or isinstance(
1092
+ startup_timeout, bool
1093
+ ):
1094
+ raise ShutdownSupervisorError(
1095
+ "shutdown-launcher bootstrap timeout is invalid"
1096
+ )
1097
+ startup_timeout = float(startup_timeout)
1098
+ if not math.isfinite(startup_timeout) or startup_timeout <= 0.0:
1099
+ raise ShutdownSupervisorError(
1100
+ "shutdown-launcher bootstrap timeout is invalid"
1101
+ )
1102
+ if database_path is not None and not isinstance(database_path, str):
1103
+ raise ShutdownSupervisorError(
1104
+ "shutdown-launcher database-path override is invalid"
1105
+ )
1106
+ try:
1107
+ authentication_key = bytes.fromhex(raw["authentication_key"])
1108
+ session_nonce = bytes.fromhex(raw["session_nonce"])
1109
+ except (TypeError, ValueError) as error:
1110
+ raise ShutdownSupervisorError(
1111
+ _exact_error("shutdown-launcher bootstrap nonce decoding", error)
1112
+ ) from error
1113
+ if len(authentication_key) != _AUTHENTICATION_BYTES:
1114
+ raise ShutdownSupervisorError(
1115
+ "shutdown-launcher bootstrap authentication key has the wrong length"
1116
+ )
1117
+ if len(session_nonce) != _NONCE_BYTES:
1118
+ raise ShutdownSupervisorError(
1119
+ "shutdown-launcher bootstrap session nonce has the wrong length"
1120
+ )
1121
+ return cls(
1122
+ _Bootstrap(
1123
+ host=host,
1124
+ port=port,
1125
+ authentication_key=authentication_key,
1126
+ session_nonce=session_nonce,
1127
+ startup_timeout=startup_timeout,
1128
+ database_path=database_path,
1129
+ )
1130
+ )
1131
+
1132
+ @property
1133
+ def database_path(self) -> str | None:
1134
+ return self._bootstrap.database_path
1135
+
1136
+ @property
1137
+ def connected(self) -> bool:
1138
+ return self._connected
1139
+
1140
+ @property
1141
+ def arm_acknowledged(self) -> bool:
1142
+ return self._arm_acknowledged
1143
+
1144
+ def connect(self) -> ShutdownSupervisorClient:
1145
+ """Complete authenticated startup before importing or creating Qt."""
1146
+
1147
+ if self._connected:
1148
+ return self
1149
+ deadline = time.monotonic() + self._bootstrap.startup_timeout
1150
+ channel = socket.create_connection(
1151
+ (self._bootstrap.host, self._bootstrap.port),
1152
+ timeout=self._bootstrap.startup_timeout,
1153
+ )
1154
+ self._channel = channel
1155
+ claimed_pid = _supervised_child_claimed_pid()
1156
+ hello_nonce = secrets.token_bytes(_NONCE_BYTES)
1157
+ hello = _encode_frame(
1158
+ _HELLO,
1159
+ authentication_key=self._bootstrap.authentication_key,
1160
+ session_nonce=self._bootstrap.session_nonce,
1161
+ message_nonce=hello_nonce,
1162
+ payload=_PID_PAYLOAD.pack(claimed_pid),
1163
+ )
1164
+ try:
1165
+ channel.settimeout(_timeout_within(deadline))
1166
+ channel.sendall(hello)
1167
+ ready = _receive_exact(channel, _FRAME_SIZE)
1168
+ frame_type, message_nonce, payload = _decode_frame(
1169
+ ready,
1170
+ authentication_key=self._bootstrap.authentication_key,
1171
+ session_nonce=self._bootstrap.session_nonce,
1172
+ )
1173
+ if frame_type != _STARTUP_READY:
1174
+ raise ShutdownSupervisorError(
1175
+ f"unexpected supervisor startup frame type {frame_type}"
1176
+ )
1177
+ if not hmac.compare_digest(message_nonce, hello_nonce):
1178
+ raise ShutdownSupervisorError(
1179
+ "supervisor startup acknowledgement nonce does not match"
1180
+ )
1181
+ if _PID_PAYLOAD.unpack(payload)[0] != claimed_pid:
1182
+ raise ShutdownSupervisorError(
1183
+ "supervisor startup acknowledgement PID does not match"
1184
+ )
1185
+ except BaseException:
1186
+ channel.close()
1187
+ self._channel = None
1188
+ raise
1189
+ channel.settimeout(None)
1190
+ self._connected = True
1191
+ return self
1192
+
1193
+ def _require_channel(self) -> socket.socket:
1194
+ if not self._connected or self._channel is None:
1195
+ raise ShutdownSupervisorError("shutdown launcher is not connected")
1196
+ return self._channel
1197
+
1198
+ def _send_arm(self, hard_deadline: float) -> bytes:
1199
+ """Send ARM and retain its nonce for a separately testable ACK read."""
1200
+
1201
+ channel = self._require_channel()
1202
+ deadline = float(hard_deadline)
1203
+ if not math.isfinite(deadline):
1204
+ raise ValueError("shutdown hard deadline must be finite")
1205
+ arm_nonce = secrets.token_bytes(_NONCE_BYTES)
1206
+ deadline_payload = _DEADLINE_PAYLOAD.pack(deadline)
1207
+ frame = _encode_arm(
1208
+ self._bootstrap.authentication_key,
1209
+ self._bootstrap.session_nonce,
1210
+ arm_nonce,
1211
+ deadline,
1212
+ )
1213
+ channel.settimeout(_timeout_within(deadline))
1214
+ channel.sendall(frame)
1215
+ self._pending_arm_nonce = arm_nonce
1216
+ self._pending_deadline_payload = deadline_payload
1217
+ return arm_nonce
1218
+
1219
+ def _receive_arm_ack(self, hard_deadline: float) -> None:
1220
+ """Validate the ACK for the most recently sent ARM record."""
1221
+
1222
+ channel = self._require_channel()
1223
+ arm_nonce = self._pending_arm_nonce
1224
+ deadline_payload = self._pending_deadline_payload
1225
+ if arm_nonce is None or deadline_payload is None:
1226
+ raise ShutdownSupervisorError("no ARM record is awaiting acknowledgement")
1227
+ channel.settimeout(_timeout_within(float(hard_deadline)))
1228
+ acknowledgement = _receive_exact(channel, _FRAME_SIZE)
1229
+ frame_type, message_nonce, payload = _decode_frame(
1230
+ acknowledgement,
1231
+ authentication_key=self._bootstrap.authentication_key,
1232
+ session_nonce=self._bootstrap.session_nonce,
1233
+ )
1234
+ if frame_type != _ARM_ACK:
1235
+ raise ShutdownSupervisorError(
1236
+ f"unexpected supervisor ARM acknowledgement type {frame_type}"
1237
+ )
1238
+ if not hmac.compare_digest(message_nonce, arm_nonce):
1239
+ raise ShutdownSupervisorError(
1240
+ "supervisor ARM acknowledgement nonce does not match"
1241
+ )
1242
+ if not hmac.compare_digest(payload, deadline_payload):
1243
+ raise ShutdownSupervisorError(
1244
+ "supervisor ARM acknowledgement deadline does not match"
1245
+ )
1246
+
1247
+ def arm(self, hard_deadline: float) -> str | None:
1248
+ """Send the sole ARM request and report exact acknowledgement failure."""
1249
+
1250
+ if self._arm_attempted:
1251
+ return "process shutdown launcher ARM was already attempted"
1252
+ self._arm_attempted = True
1253
+ try:
1254
+ self._send_arm(hard_deadline)
1255
+ except BaseException as error:
1256
+ return _exact_error("process shutdown launcher ARM send", error)
1257
+ try:
1258
+ self._receive_arm_ack(hard_deadline)
1259
+ except BaseException as error:
1260
+ # The launcher installs its immutable deadline before attempting
1261
+ # the ACK. Therefore an ACK failure means external state is
1262
+ # unknown, not that the launcher is safely disarmed.
1263
+ return _exact_error(
1264
+ "process shutdown launcher ARM acknowledgement",
1265
+ error,
1266
+ )
1267
+ self._arm_acknowledged = True
1268
+ return None
1269
+
1270
+ def close(self) -> None:
1271
+ """Close the local endpoint; after ARM this cannot cancel the deadline."""
1272
+
1273
+ channel = self._channel
1274
+ self._channel = None
1275
+ self._connected = False
1276
+ if channel is not None:
1277
+ try:
1278
+ channel.close()
1279
+ except OSError:
1280
+ pass
1281
+
1282
+
1283
+ def _bootstrap_payload(
1284
+ listener: socket.socket,
1285
+ *,
1286
+ authentication_key: bytes,
1287
+ session_nonce: bytes,
1288
+ startup_timeout: float,
1289
+ database_path: str | os.PathLike[str] | None,
1290
+ ) -> str:
1291
+ address = listener.getsockname()
1292
+ path_override = None if database_path is None else os.fsdecode(database_path)
1293
+ return json.dumps(
1294
+ {
1295
+ "version": _PROTOCOL_VERSION,
1296
+ "host": _LOOPBACK_HOST,
1297
+ "port": int(address[1]),
1298
+ "authentication_key": authentication_key.hex(),
1299
+ "session_nonce": session_nonce.hex(),
1300
+ "startup_timeout": startup_timeout,
1301
+ "database_path": path_override,
1302
+ },
1303
+ ensure_ascii=True,
1304
+ separators=(",", ":"),
1305
+ sort_keys=True,
1306
+ )
1307
+
1308
+
1309
+ def _api_launcher_bootstrap_payload(
1310
+ listener: socket.socket,
1311
+ *,
1312
+ authentication_key: bytes,
1313
+ session_nonce: bytes,
1314
+ startup_deadline: float,
1315
+ caller_pid: int,
1316
+ child_argv: Sequence[str | os.PathLike[str]],
1317
+ database_path: str | os.PathLike[str] | None,
1318
+ ) -> str:
1319
+ """Serialize the caller/launcher channel without inheriting a handle."""
1320
+
1321
+ address = listener.getsockname()
1322
+ path_override = None if database_path is None else os.fsdecode(database_path)
1323
+ return json.dumps(
1324
+ {
1325
+ "version": _PROTOCOL_VERSION,
1326
+ "host": _LOOPBACK_HOST,
1327
+ "port": int(address[1]),
1328
+ "authentication_key": authentication_key.hex(),
1329
+ "session_nonce": session_nonce.hex(),
1330
+ "startup_deadline": float(startup_deadline),
1331
+ "caller_pid": int(caller_pid),
1332
+ "child_argv": [os.fsdecode(argument) for argument in child_argv],
1333
+ "database_path": path_override,
1334
+ },
1335
+ ensure_ascii=True,
1336
+ separators=(",", ":"),
1337
+ sort_keys=True,
1338
+ )
1339
+
1340
+
1341
+ def _api_launcher_bootstrap_from_environment() -> _ApiLauncherBootstrap:
1342
+ """Consume and strictly validate the public API launcher's private state."""
1343
+
1344
+ encoded = os.environ.pop(_API_LAUNCHER_BOOTSTRAP_ENVIRONMENT_KEY, None)
1345
+ if encoded is None:
1346
+ raise ShutdownSupervisorError(
1347
+ "private public-API launcher bootstrap marker is missing"
1348
+ )
1349
+ try:
1350
+ raw = json.loads(encoded)
1351
+ except (TypeError, ValueError) as error:
1352
+ raise ShutdownSupervisorError(
1353
+ _exact_error("public-API launcher bootstrap decoding", error)
1354
+ ) from error
1355
+ expected_fields = {
1356
+ "version",
1357
+ "host",
1358
+ "port",
1359
+ "authentication_key",
1360
+ "session_nonce",
1361
+ "startup_deadline",
1362
+ "caller_pid",
1363
+ "child_argv",
1364
+ "database_path",
1365
+ }
1366
+ if not isinstance(raw, dict) or set(raw) != expected_fields:
1367
+ raise ShutdownSupervisorError(
1368
+ "public-API launcher bootstrap fields are invalid"
1369
+ )
1370
+ if raw["version"] != _PROTOCOL_VERSION:
1371
+ raise ShutdownSupervisorError(
1372
+ "public-API launcher bootstrap version is unsupported"
1373
+ )
1374
+ host = raw["host"]
1375
+ port = raw["port"]
1376
+ startup_deadline = raw["startup_deadline"]
1377
+ caller_pid = raw["caller_pid"]
1378
+ child_argv = raw["child_argv"]
1379
+ database_path = raw["database_path"]
1380
+ if host != _LOOPBACK_HOST:
1381
+ raise ShutdownSupervisorError(
1382
+ "public-API launcher bootstrap endpoint is not loopback"
1383
+ )
1384
+ if type(port) is not int or not 0 < port < 65536:
1385
+ raise ShutdownSupervisorError("public-API launcher bootstrap port is invalid")
1386
+ if not isinstance(startup_deadline, (int, float)) or isinstance(
1387
+ startup_deadline, bool
1388
+ ):
1389
+ raise ShutdownSupervisorError(
1390
+ "public-API launcher bootstrap deadline is invalid"
1391
+ )
1392
+ startup_deadline = float(startup_deadline)
1393
+ if not math.isfinite(startup_deadline):
1394
+ raise ShutdownSupervisorError(
1395
+ "public-API launcher bootstrap deadline is invalid"
1396
+ )
1397
+ if type(caller_pid) is not int or caller_pid <= 0:
1398
+ raise ShutdownSupervisorError("public-API launcher caller PID is invalid")
1399
+ if (
1400
+ not isinstance(child_argv, list)
1401
+ or not child_argv
1402
+ or not all(isinstance(argument, str) for argument in child_argv)
1403
+ ):
1404
+ raise ShutdownSupervisorError("public-API launcher child arguments are invalid")
1405
+ if database_path is not None and not isinstance(database_path, str):
1406
+ raise ShutdownSupervisorError(
1407
+ "public-API launcher database-path override is invalid"
1408
+ )
1409
+ try:
1410
+ authentication_key = bytes.fromhex(raw["authentication_key"])
1411
+ session_nonce = bytes.fromhex(raw["session_nonce"])
1412
+ except (TypeError, ValueError) as error:
1413
+ raise ShutdownSupervisorError(
1414
+ _exact_error("public-API launcher bootstrap nonce decoding", error)
1415
+ ) from error
1416
+ if len(authentication_key) != _AUTHENTICATION_BYTES:
1417
+ raise ShutdownSupervisorError(
1418
+ "public-API launcher authentication key has the wrong length"
1419
+ )
1420
+ if len(session_nonce) != _NONCE_BYTES:
1421
+ raise ShutdownSupervisorError(
1422
+ "public-API launcher session nonce has the wrong length"
1423
+ )
1424
+ return _ApiLauncherBootstrap(
1425
+ host=host,
1426
+ port=port,
1427
+ authentication_key=authentication_key,
1428
+ session_nonce=session_nonce,
1429
+ startup_deadline=startup_deadline,
1430
+ caller_pid=caller_pid,
1431
+ child_argv=tuple(child_argv),
1432
+ database_path=database_path,
1433
+ )
1434
+
1435
+
1436
+ def _encode_api_launcher_outcome(
1437
+ outcome: _SupervisionOutcome,
1438
+ *,
1439
+ authentication_key: bytes,
1440
+ session_nonce: bytes,
1441
+ ) -> bytes:
1442
+ payload = json.dumps(
1443
+ {
1444
+ "return_code": int(outcome.return_code),
1445
+ "forced": bool(outcome.forced),
1446
+ "signal_number": outcome.signal_number,
1447
+ "diagnostics": list(outcome.diagnostics),
1448
+ },
1449
+ ensure_ascii=True,
1450
+ separators=(",", ":"),
1451
+ sort_keys=True,
1452
+ ).encode("utf-8")
1453
+ if len(payload) > _API_RESULT_MAX_BYTES:
1454
+ raise ShutdownSupervisorError(
1455
+ "public-API launcher outcome exceeds the bounded result size"
1456
+ )
1457
+ header = _API_RESULT_HEADER.pack(
1458
+ _PROTOCOL_MAGIC,
1459
+ _PROTOCOL_VERSION,
1460
+ _API_LAUNCHER_RESULT,
1461
+ session_nonce,
1462
+ len(payload),
1463
+ )
1464
+ authentication = hmac.digest(
1465
+ authentication_key,
1466
+ header + payload,
1467
+ hashlib.sha256,
1468
+ )
1469
+ return header + payload + authentication
1470
+
1471
+
1472
+ def _decode_api_launcher_outcome(
1473
+ frame: bytes,
1474
+ *,
1475
+ authentication_key: bytes,
1476
+ session_nonce: bytes,
1477
+ ) -> _SupervisionOutcome:
1478
+ minimum_size = _API_RESULT_HEADER.size + _AUTHENTICATION_BYTES
1479
+ if len(frame) < minimum_size:
1480
+ raise ShutdownSupervisorError("public-API launcher result frame is incomplete")
1481
+ header = frame[: _API_RESULT_HEADER.size]
1482
+ magic, version, frame_type, supplied_session, payload_size = (
1483
+ _API_RESULT_HEADER.unpack(header)
1484
+ )
1485
+ if magic != _PROTOCOL_MAGIC:
1486
+ raise ShutdownSupervisorError("invalid public-API launcher result magic")
1487
+ if version != _PROTOCOL_VERSION:
1488
+ raise ShutdownSupervisorError(
1489
+ f"unsupported public-API launcher result version {version}"
1490
+ )
1491
+ if frame_type != _API_LAUNCHER_RESULT:
1492
+ raise ShutdownSupervisorError(
1493
+ f"unexpected public-API launcher result type {frame_type}"
1494
+ )
1495
+ if not hmac.compare_digest(supplied_session, session_nonce):
1496
+ raise ShutdownSupervisorError(
1497
+ "invalid public-API launcher result session nonce"
1498
+ )
1499
+ if payload_size > _API_RESULT_MAX_BYTES:
1500
+ raise ShutdownSupervisorError(
1501
+ "public-API launcher result exceeds the bounded result size"
1502
+ )
1503
+ expected_size = minimum_size + payload_size
1504
+ if len(frame) != expected_size:
1505
+ raise ShutdownSupervisorError(
1506
+ "public-API launcher result frame length does not match its header"
1507
+ )
1508
+ payload_end = _API_RESULT_HEADER.size + payload_size
1509
+ payload = frame[_API_RESULT_HEADER.size : payload_end]
1510
+ supplied_authentication = frame[payload_end:]
1511
+ expected_authentication = hmac.digest(
1512
+ authentication_key,
1513
+ header + payload,
1514
+ hashlib.sha256,
1515
+ )
1516
+ if not hmac.compare_digest(
1517
+ supplied_authentication,
1518
+ expected_authentication,
1519
+ ):
1520
+ raise ShutdownSupervisorError(
1521
+ "invalid public-API launcher result authentication"
1522
+ )
1523
+ try:
1524
+ raw = json.loads(payload)
1525
+ except (TypeError, ValueError) as error:
1526
+ raise ShutdownSupervisorError(
1527
+ _exact_error("public-API launcher result decoding", error)
1528
+ ) from error
1529
+ expected_fields = {
1530
+ "return_code",
1531
+ "forced",
1532
+ "signal_number",
1533
+ "diagnostics",
1534
+ }
1535
+ if not isinstance(raw, dict) or set(raw) != expected_fields:
1536
+ raise ShutdownSupervisorError("public-API launcher result fields are invalid")
1537
+ return_code = raw["return_code"]
1538
+ forced = raw["forced"]
1539
+ signal_number = raw["signal_number"]
1540
+ diagnostics = raw["diagnostics"]
1541
+ if type(return_code) is not int:
1542
+ raise ShutdownSupervisorError("public-API launcher result status is invalid")
1543
+ if type(forced) is not bool:
1544
+ raise ShutdownSupervisorError(
1545
+ "public-API launcher result forced flag is invalid"
1546
+ )
1547
+ if signal_number is not None and (
1548
+ type(signal_number) is not int or signal_number <= 0
1549
+ ):
1550
+ raise ShutdownSupervisorError("public-API launcher result signal is invalid")
1551
+ if (
1552
+ not isinstance(diagnostics, list)
1553
+ or len(diagnostics) > _TERMINATION_DIAGNOSTIC_LIMIT
1554
+ or not all(isinstance(diagnostic, str) for diagnostic in diagnostics)
1555
+ ):
1556
+ raise ShutdownSupervisorError(
1557
+ "public-API launcher result diagnostics are invalid"
1558
+ )
1559
+ return _SupervisionOutcome(
1560
+ return_code,
1561
+ forced=forced,
1562
+ signal_number=signal_number,
1563
+ diagnostics=tuple(diagnostics),
1564
+ )
1565
+
1566
+
1567
+ def _posix_child_exit_observed(
1568
+ child: Any,
1569
+ signal_state: _LauncherSignalState,
1570
+ *,
1571
+ deadline: float | None = None,
1572
+ ) -> bool | None:
1573
+ """Peek at a POSIX child without reaping its PID/process-group anchor."""
1574
+
1575
+ waitid = getattr(os, "waitid", None)
1576
+ if waitid is None:
1577
+ return _kqueue_child_exit_observed(
1578
+ child,
1579
+ signal_state,
1580
+ deadline=deadline,
1581
+ )
1582
+ options = os.WEXITED | os.WNOHANG | os.WNOWAIT
1583
+ while True:
1584
+ if deadline is not None and time.monotonic() >= deadline:
1585
+ return None
1586
+ _raise_if_launcher_signalled(signal_state)
1587
+ try:
1588
+ result = waitid(os.P_PID, int(child.pid), options)
1589
+ except InterruptedError:
1590
+ continue
1591
+ except ChildProcessError:
1592
+ # SIGCHLD is forced to SIG_DFL before spawn and no other launcher
1593
+ # code reaps this child. With no recorded return code, ECHILD is
1594
+ # an ownership failure: retain the launcher and keep retrying.
1595
+ return_code = getattr(child, "returncode", None)
1596
+ if return_code is not None:
1597
+ return True
1598
+ if deadline is None:
1599
+ time.sleep(_TERMINATION_RETRY_SECONDS)
1600
+ continue
1601
+ remaining = deadline - time.monotonic()
1602
+ if remaining <= 0.0:
1603
+ return None
1604
+ time.sleep(min(_TERMINATION_RETRY_SECONDS, remaining))
1605
+ continue
1606
+ except OSError as error:
1607
+ if error.errno == errno.EINTR:
1608
+ continue
1609
+ raise
1610
+ if deadline is not None and time.monotonic() >= deadline:
1611
+ return None
1612
+ return result is not None and int(result.si_pid) == int(child.pid)
1613
+
1614
+
1615
+ def _kqueue_child_exit_observed(
1616
+ child: Any,
1617
+ signal_state: _LauncherSignalState,
1618
+ *,
1619
+ deadline: float | None = None,
1620
+ ) -> bool | None:
1621
+ """Retain a Darwin child as a zombie when Python lacks ``os.waitid``."""
1622
+
1623
+ kqueue_factory = getattr(select, "kqueue", None)
1624
+ kevent_factory = getattr(select, "kevent", None)
1625
+ if kqueue_factory is None or kevent_factory is None:
1626
+ raise ShutdownSupervisorError(
1627
+ "POSIX child observation requires waitid or kqueue"
1628
+ )
1629
+ try:
1630
+ constants = {
1631
+ name: int(getattr(select, name))
1632
+ for name in (
1633
+ "KQ_FILTER_PROC",
1634
+ "KQ_EV_ADD",
1635
+ "KQ_EV_ENABLE",
1636
+ "KQ_EV_ONESHOT",
1637
+ "KQ_NOTE_EXIT",
1638
+ )
1639
+ }
1640
+ except (AttributeError, TypeError, ValueError) as error:
1641
+ raise ShutdownSupervisorError(
1642
+ "POSIX child observation requires kqueue process-exit constants"
1643
+ ) from error
1644
+ kq_filter_proc = constants["KQ_FILTER_PROC"]
1645
+ kq_ev_add = constants["KQ_EV_ADD"]
1646
+ kq_ev_enable = constants["KQ_EV_ENABLE"]
1647
+ kq_ev_oneshot = constants["KQ_EV_ONESHOT"]
1648
+ kq_note_exit = constants["KQ_NOTE_EXIT"]
1649
+ while True:
1650
+ if deadline is not None and time.monotonic() >= deadline:
1651
+ return None
1652
+ _raise_if_launcher_signalled(signal_state)
1653
+ queue = None
1654
+ try:
1655
+ queue = kqueue_factory()
1656
+ change = kevent_factory(
1657
+ int(child.pid),
1658
+ filter=kq_filter_proc,
1659
+ flags=(kq_ev_add | kq_ev_enable | kq_ev_oneshot),
1660
+ fflags=kq_note_exit,
1661
+ )
1662
+ events = queue.control([change], 1, 0.0)
1663
+ except InterruptedError:
1664
+ continue
1665
+ finally:
1666
+ if queue is not None:
1667
+ try:
1668
+ queue.close()
1669
+ except OSError:
1670
+ pass
1671
+ if deadline is not None and time.monotonic() >= deadline:
1672
+ return None
1673
+ return any(
1674
+ int(event.ident) == int(child.pid) and int(event.fflags) & kq_note_exit
1675
+ for event in events
1676
+ )
1677
+
1678
+
1679
+ def _child_exit_observed(
1680
+ child: Any,
1681
+ signal_state: _LauncherSignalState,
1682
+ ) -> bool:
1683
+ if os.name != "nt":
1684
+ observed = _posix_child_exit_observed(child, signal_state)
1685
+ assert observed is not None
1686
+ return observed
1687
+ _raise_if_launcher_signalled(signal_state)
1688
+ try:
1689
+ child.wait(timeout=0.0)
1690
+ except subprocess.TimeoutExpired:
1691
+ return False
1692
+ return True
1693
+
1694
+
1695
+ def _accept_child_channel(
1696
+ listener: socket.socket,
1697
+ child: Any,
1698
+ *,
1699
+ deadline: float,
1700
+ signal_state: _LauncherSignalState,
1701
+ ) -> tuple[socket.socket | None, bool]:
1702
+ listener.settimeout(_OBSERVATION_INTERVAL_SECONDS)
1703
+ while True:
1704
+ _raise_if_launcher_signalled(signal_state)
1705
+ if _child_exit_observed(child, signal_state):
1706
+ return None, True
1707
+ remaining = _timeout_within(deadline)
1708
+ if remaining <= 0.0:
1709
+ raise ShutdownSupervisorError("shutdown launcher child readiness timed out")
1710
+ listener.settimeout(min(_OBSERVATION_INTERVAL_SECONDS, remaining))
1711
+ try:
1712
+ channel, _address = listener.accept()
1713
+ except TimeoutError:
1714
+ continue
1715
+ return channel, False
1716
+
1717
+
1718
+ def _authenticate_child_channel(
1719
+ channel: socket.socket,
1720
+ child: Any,
1721
+ *,
1722
+ authentication_key: bytes,
1723
+ session_nonce: bytes,
1724
+ deadline: float,
1725
+ signal_state: _LauncherSignalState,
1726
+ ready_state: _ReadyState,
1727
+ ) -> None:
1728
+ hello = _receive_exact(
1729
+ channel,
1730
+ _FRAME_SIZE,
1731
+ deadline=deadline,
1732
+ signal_state=signal_state,
1733
+ )
1734
+ frame_type, hello_nonce, payload = _decode_frame(
1735
+ hello,
1736
+ authentication_key=authentication_key,
1737
+ session_nonce=session_nonce,
1738
+ )
1739
+ if frame_type != _HELLO:
1740
+ raise ShutdownSupervisorError(
1741
+ f"unexpected supervisor child startup frame type {frame_type}"
1742
+ )
1743
+ claimed_pid = _PID_PAYLOAD.unpack(payload)[0]
1744
+ if claimed_pid != child.pid:
1745
+ raise ShutdownSupervisorError(
1746
+ f"supervisor child PID {claimed_pid} does not match {child.pid}"
1747
+ )
1748
+ ready = _encode_frame(
1749
+ _STARTUP_READY,
1750
+ authentication_key=authentication_key,
1751
+ session_nonce=session_nonce,
1752
+ message_nonce=hello_nonce,
1753
+ payload=_PID_PAYLOAD.pack(child.pid),
1754
+ )
1755
+ remaining = _timeout_within(deadline)
1756
+ if remaining <= 0.0:
1757
+ raise TimeoutError(
1758
+ "shutdown launcher child readiness deadline expired before READY"
1759
+ )
1760
+ channel.settimeout(remaining)
1761
+ channel.sendall(ready)
1762
+ if _timeout_within(deadline) <= 0.0:
1763
+ raise TimeoutError(
1764
+ "shutdown launcher child readiness deadline expired while sending READY"
1765
+ )
1766
+ ready_state.committed = True
1767
+ channel.settimeout(None)
1768
+
1769
+
1770
+ def _observe_until_arm(
1771
+ channel: socket.socket,
1772
+ child: Any,
1773
+ *,
1774
+ authentication_key: bytes,
1775
+ session_nonce: bytes,
1776
+ armed_state: _ArmedState,
1777
+ signal_state: _LauncherSignalState,
1778
+ ) -> tuple[bool, str | None]:
1779
+ """Observe one pre-ARM frame while continuing to reap a normal child."""
1780
+
1781
+ received = bytearray()
1782
+ channel.setblocking(False)
1783
+ while True:
1784
+ _raise_if_launcher_signalled(signal_state)
1785
+ try:
1786
+ child_exited = _child_exit_observed(child, signal_state)
1787
+ except BaseException as error:
1788
+ if isinstance(
1789
+ error,
1790
+ (_LauncherSignalReceived, _LauncherCancellationReceived),
1791
+ ):
1792
+ raise
1793
+ return (
1794
+ False,
1795
+ _exact_error("shutdown launcher child observation", error),
1796
+ )
1797
+ if child_exited:
1798
+ return True, None
1799
+ try:
1800
+ readable, _, _ = select.select(
1801
+ [channel],
1802
+ [],
1803
+ [],
1804
+ _OBSERVATION_INTERVAL_SECONDS,
1805
+ )
1806
+ except (OSError, ValueError) as error:
1807
+ return (
1808
+ False,
1809
+ _exact_error("shutdown launcher pre-ARM observation", error),
1810
+ )
1811
+ if not readable:
1812
+ continue
1813
+ try:
1814
+ chunk = channel.recv(_FRAME_SIZE - len(received))
1815
+ except BlockingIOError:
1816
+ continue
1817
+ except OSError as error:
1818
+ return (
1819
+ False,
1820
+ _exact_error("shutdown launcher pre-ARM receive", error),
1821
+ )
1822
+ if not chunk:
1823
+ return (
1824
+ False,
1825
+ "shutdown launcher control channel closed before ARM",
1826
+ )
1827
+ received.extend(chunk)
1828
+ if len(received) < _FRAME_SIZE:
1829
+ continue
1830
+ try:
1831
+ frame_type, arm_nonce, payload = _decode_frame(
1832
+ bytes(received),
1833
+ authentication_key=authentication_key,
1834
+ session_nonce=session_nonce,
1835
+ )
1836
+ if frame_type != _ARM:
1837
+ raise ShutdownSupervisorError(
1838
+ f"unexpected pre-ARM frame type {frame_type}"
1839
+ )
1840
+ hard_deadline = _DEADLINE_PAYLOAD.unpack(payload)[0]
1841
+ candidate = _CommittedArm(hard_deadline, arm_nonce, payload)
1842
+ if not math.isfinite(hard_deadline):
1843
+ raise ShutdownSupervisorError("ARM deadline is not finite")
1844
+ # This assignment is the irreversible ARMED transition. It is
1845
+ # deliberately before ACK allocation/encoding/send or any other
1846
+ # fallible protocol work. Every enclosing exception handler tests
1847
+ # this field and therefore remains fail-closed for this deadline.
1848
+ armed_state.committed = candidate
1849
+ except (ShutdownSupervisorError, struct.error, ValueError) as error:
1850
+ return (
1851
+ False,
1852
+ _exact_error("shutdown launcher pre-ARM protocol", error),
1853
+ )
1854
+ return False, None
1855
+
1856
+
1857
+ def _send_arm_acknowledgement(channel: socket.socket, acknowledgement: bytes) -> None:
1858
+ """Send a nonblocking ACK after the deadline state is installed.
1859
+
1860
+ Failure is reported only after the already-armed child has exited or been
1861
+ terminated. Raising here therefore records the exact send detail without
1862
+ weakening or delaying the immutable deadline.
1863
+ """
1864
+
1865
+ channel.setblocking(False)
1866
+ # The fixed record is far smaller than a loopback socket's send buffer.
1867
+ sent = channel.send(acknowledgement)
1868
+ if sent != len(acknowledgement):
1869
+ raise ShutdownSupervisorError(
1870
+ "shutdown launcher ARM acknowledgement send was partial: "
1871
+ f"sent {sent} of {len(acknowledgement)} bytes"
1872
+ )
1873
+
1874
+
1875
+ def _set_child_return_code(child: Any, status: int) -> int:
1876
+ return_code = os.waitstatus_to_exitcode(status)
1877
+ child.returncode = return_code
1878
+ return int(return_code)
1879
+
1880
+
1881
+ def _record_termination_diagnostic(
1882
+ diagnostics: list[str],
1883
+ context: str,
1884
+ error: BaseException,
1885
+ ) -> None:
1886
+ try:
1887
+ diagnostic = _exact_error(context, error)
1888
+ if diagnostic in diagnostics:
1889
+ return
1890
+ if len(diagnostics) < _TERMINATION_DIAGNOSTIC_LIMIT - 1:
1891
+ diagnostics.append(diagnostic)
1892
+ return
1893
+ marker = (
1894
+ "shutdown launcher additional distinct termination diagnostics "
1895
+ f"omitted after {_TERMINATION_DIAGNOSTIC_LIMIT - 1} entries"
1896
+ )
1897
+ if marker not in diagnostics:
1898
+ diagnostics.append(marker)
1899
+ except BaseException:
1900
+ # Retaining ownership and retrying is more important than formatting a
1901
+ # diagnostic under memory pressure or another exceptional condition.
1902
+ pass
1903
+
1904
+
1905
+ def _materialize_termination_diagnostics(
1906
+ initial_diagnostics: Sequence[str],
1907
+ deferred_context: str | None,
1908
+ deferred_error: BaseException | None,
1909
+ ) -> list[str]:
1910
+ """Allocate/format diagnostics only after the first tree-kill attempt."""
1911
+
1912
+ diagnostics = list(initial_diagnostics)
1913
+ if deferred_context is not None and deferred_error is not None:
1914
+ _record_termination_diagnostic(
1915
+ diagnostics,
1916
+ deferred_context,
1917
+ deferred_error,
1918
+ )
1919
+ return diagnostics
1920
+
1921
+
1922
+ def _outcome_from_posix_status(
1923
+ child: Any,
1924
+ status: int,
1925
+ *,
1926
+ termination_requested: bool,
1927
+ direct_exit_preceded_termination: bool = False,
1928
+ launcher_signal: int | None = None,
1929
+ diagnostics: Sequence[str] = (),
1930
+ ) -> _SupervisionOutcome:
1931
+ return_code = _set_child_return_code(child, status)
1932
+ if launcher_signal is not None:
1933
+ return _SupervisionOutcome(
1934
+ -int(launcher_signal),
1935
+ signal_number=int(launcher_signal),
1936
+ diagnostics=tuple(diagnostics),
1937
+ )
1938
+ forced = bool(
1939
+ termination_requested
1940
+ and not direct_exit_preceded_termination
1941
+ and os.WIFSIGNALED(status)
1942
+ and os.WTERMSIG(status) == signal.SIGKILL
1943
+ )
1944
+ if forced:
1945
+ return _SupervisionOutcome(
1946
+ _FORCED_SHUTDOWN_EXIT_CODE,
1947
+ forced=True,
1948
+ diagnostics=tuple(diagnostics),
1949
+ )
1950
+ signal_number = -return_code if return_code < 0 else None
1951
+ return _SupervisionOutcome(
1952
+ return_code,
1953
+ signal_number=signal_number,
1954
+ diagnostics=tuple(diagnostics),
1955
+ )
1956
+
1957
+
1958
+ def _reap_terminated_posix_child(
1959
+ child: Any,
1960
+ *,
1961
+ termination_requested: bool,
1962
+ direct_exit_preceded_termination: bool,
1963
+ process_group: _PosixProcessGroup,
1964
+ launcher_signal: int | None = None,
1965
+ diagnostics: list[str] | None = None,
1966
+ ) -> _SupervisionOutcome:
1967
+ retained_diagnostics = [] if diagnostics is None else diagnostics
1968
+ while True:
1969
+ try:
1970
+ waited_pid, status = os.waitpid(child.pid, 0)
1971
+ except InterruptedError as error:
1972
+ _record_termination_diagnostic(
1973
+ retained_diagnostics,
1974
+ "shutdown launcher POSIX child reap",
1975
+ error,
1976
+ )
1977
+ continue
1978
+ except ChildProcessError as error:
1979
+ return_code = getattr(child, "returncode", None)
1980
+ if return_code is not None:
1981
+ if launcher_signal is not None:
1982
+ outcome = _SupervisionOutcome(
1983
+ -int(launcher_signal),
1984
+ signal_number=int(launcher_signal),
1985
+ diagnostics=tuple(retained_diagnostics),
1986
+ )
1987
+ break
1988
+ forced = bool(
1989
+ termination_requested
1990
+ and not direct_exit_preceded_termination
1991
+ and launcher_signal is None
1992
+ and return_code == -signal.SIGKILL
1993
+ )
1994
+ if forced:
1995
+ outcome = _SupervisionOutcome(
1996
+ _FORCED_SHUTDOWN_EXIT_CODE,
1997
+ forced=True,
1998
+ diagnostics=tuple(retained_diagnostics),
1999
+ )
2000
+ else:
2001
+ signal_number = -int(return_code) if int(return_code) < 0 else None
2002
+ outcome = _SupervisionOutcome(
2003
+ int(return_code),
2004
+ signal_number=signal_number,
2005
+ diagnostics=tuple(retained_diagnostics),
2006
+ )
2007
+ break
2008
+ _record_termination_diagnostic(
2009
+ retained_diagnostics,
2010
+ "shutdown launcher POSIX child reap",
2011
+ error,
2012
+ )
2013
+ time.sleep(_TERMINATION_RETRY_SECONDS)
2014
+ continue
2015
+ except OSError as error:
2016
+ if error.errno == errno.EINTR:
2017
+ continue
2018
+ _record_termination_diagnostic(
2019
+ retained_diagnostics,
2020
+ "shutdown launcher POSIX child reap",
2021
+ error,
2022
+ )
2023
+ time.sleep(_TERMINATION_RETRY_SECONDS)
2024
+ continue
2025
+ except BaseException as error:
2026
+ _record_termination_diagnostic(
2027
+ retained_diagnostics,
2028
+ "shutdown launcher POSIX child reap",
2029
+ error,
2030
+ )
2031
+ time.sleep(_TERMINATION_RETRY_SECONDS)
2032
+ continue
2033
+ if waited_pid == child.pid:
2034
+ outcome = _outcome_from_posix_status(
2035
+ child,
2036
+ status,
2037
+ termination_requested=termination_requested,
2038
+ direct_exit_preceded_termination=(direct_exit_preceded_termination),
2039
+ launcher_signal=launcher_signal,
2040
+ diagnostics=retained_diagnostics,
2041
+ )
2042
+ break
2043
+
2044
+ # The leader is now reaped, so its PGID must never be signalled again. A
2045
+ # zero signal only observes orphan/zombie disappearance and cannot harm a
2046
+ # process in the extraordinarily unlikely event of immediate PGID reuse.
2047
+ while True:
2048
+ try:
2049
+ active = process_group.active()
2050
+ except BaseException as error:
2051
+ _record_termination_diagnostic(
2052
+ retained_diagnostics,
2053
+ "shutdown launcher POSIX process-group observation",
2054
+ error,
2055
+ )
2056
+ time.sleep(_TERMINATION_RETRY_SECONDS)
2057
+ continue
2058
+ if not active:
2059
+ break
2060
+ time.sleep(_OBSERVATION_INTERVAL_SECONDS)
2061
+ if tuple(retained_diagnostics) == outcome.diagnostics:
2062
+ return outcome
2063
+ return _SupervisionOutcome(
2064
+ outcome.return_code,
2065
+ forced=outcome.forced,
2066
+ signal_number=outcome.signal_number,
2067
+ diagnostics=tuple(retained_diagnostics),
2068
+ )
2069
+
2070
+
2071
+ def _terminate_and_reap_posix_child(
2072
+ child: Any,
2073
+ process_group: _PosixProcessGroup,
2074
+ *,
2075
+ launcher_signal: int | None = None,
2076
+ initial_diagnostics: Sequence[str] = (),
2077
+ deferred_diagnostic_context: str | None = None,
2078
+ deferred_diagnostic_error: BaseException | None = None,
2079
+ child_exit_observed: bool = False,
2080
+ ) -> _SupervisionOutcome:
2081
+ diagnostics: list[str] | None = None
2082
+ termination_requested = False
2083
+ while True:
2084
+ try:
2085
+ # On a hard-deadline path this call is the first external action.
2086
+ # Formatting/reporting, polling, and reaping all happen later.
2087
+ process_group.terminate()
2088
+ termination_requested = True
2089
+ if diagnostics is None:
2090
+ diagnostics = _materialize_termination_diagnostics(
2091
+ initial_diagnostics,
2092
+ deferred_diagnostic_context,
2093
+ deferred_diagnostic_error,
2094
+ )
2095
+ break
2096
+ except ProcessLookupError:
2097
+ # No member remains in the identity-bound group. The direct child
2098
+ # can still be an unreaped zombie whose exact status is available.
2099
+ if diagnostics is None:
2100
+ diagnostics = _materialize_termination_diagnostics(
2101
+ initial_diagnostics,
2102
+ deferred_diagnostic_context,
2103
+ deferred_diagnostic_error,
2104
+ )
2105
+ break
2106
+ except PermissionError as error:
2107
+ if diagnostics is None:
2108
+ diagnostics = _materialize_termination_diagnostics(
2109
+ initial_diagnostics,
2110
+ deferred_diagnostic_context,
2111
+ deferred_diagnostic_error,
2112
+ )
2113
+ # Darwin reports EPERM when a dedicated group contains only its
2114
+ # already-exited zombie leader. Once waitid(WNOWAIT) proves that
2115
+ # state there is no signalable member; reap the retained leader.
2116
+ if child_exit_observed:
2117
+ break
2118
+ try:
2119
+ child_exit_observed = bool(
2120
+ _posix_child_exit_observed(
2121
+ child,
2122
+ _LauncherSignalState(),
2123
+ )
2124
+ )
2125
+ except BaseException:
2126
+ child_exit_observed = False
2127
+ if child_exit_observed:
2128
+ break
2129
+ _record_termination_diagnostic(
2130
+ diagnostics,
2131
+ "shutdown launcher POSIX process-group termination",
2132
+ error,
2133
+ )
2134
+ time.sleep(_TERMINATION_RETRY_SECONDS)
2135
+ except InterruptedError as error:
2136
+ if diagnostics is None:
2137
+ diagnostics = _materialize_termination_diagnostics(
2138
+ initial_diagnostics,
2139
+ deferred_diagnostic_context,
2140
+ deferred_diagnostic_error,
2141
+ )
2142
+ _record_termination_diagnostic(
2143
+ diagnostics,
2144
+ "shutdown launcher POSIX process-group termination",
2145
+ error,
2146
+ )
2147
+ continue
2148
+ except OSError as error:
2149
+ if error.errno == errno.EINTR:
2150
+ if diagnostics is None:
2151
+ diagnostics = _materialize_termination_diagnostics(
2152
+ initial_diagnostics,
2153
+ deferred_diagnostic_context,
2154
+ deferred_diagnostic_error,
2155
+ )
2156
+ continue
2157
+ if diagnostics is None:
2158
+ diagnostics = _materialize_termination_diagnostics(
2159
+ initial_diagnostics,
2160
+ deferred_diagnostic_context,
2161
+ deferred_diagnostic_error,
2162
+ )
2163
+ _record_termination_diagnostic(
2164
+ diagnostics,
2165
+ "shutdown launcher POSIX process-group termination",
2166
+ error,
2167
+ )
2168
+ time.sleep(_TERMINATION_RETRY_SECONDS)
2169
+ except BaseException as error:
2170
+ if diagnostics is None:
2171
+ diagnostics = _materialize_termination_diagnostics(
2172
+ initial_diagnostics,
2173
+ deferred_diagnostic_context,
2174
+ deferred_diagnostic_error,
2175
+ )
2176
+ _record_termination_diagnostic(
2177
+ diagnostics,
2178
+ "shutdown launcher POSIX process-group termination",
2179
+ error,
2180
+ )
2181
+ time.sleep(_TERMINATION_RETRY_SECONDS)
2182
+ assert diagnostics is not None
2183
+ if termination_requested:
2184
+ # Retain the unreaped session leader while repeating the group signal.
2185
+ # This catches a descendant whose fork raced the kernel's first group
2186
+ # walk; no positive signal is ever issued after the leader is reaped.
2187
+ successful_passes = 1
2188
+ while successful_passes < _POSIX_KILL_STABILITY_PASSES:
2189
+ time.sleep(_TERMINATION_RETRY_SECONDS)
2190
+ try:
2191
+ process_group.terminate()
2192
+ except ProcessLookupError:
2193
+ break
2194
+ except PermissionError as error:
2195
+ # The caller may already have proved the retained leader's
2196
+ # exit with waitid(WNOWAIT). Darwin can accept the first group
2197
+ # signal and then report EPERM once only that zombie remains;
2198
+ # do not discard the stronger retained observation by trying
2199
+ # to peek at the same wait status again.
2200
+ if child_exit_observed:
2201
+ break
2202
+ try:
2203
+ if _posix_child_exit_observed(
2204
+ child,
2205
+ _LauncherSignalState(),
2206
+ ):
2207
+ break
2208
+ except BaseException:
2209
+ pass
2210
+ _record_termination_diagnostic(
2211
+ diagnostics,
2212
+ "shutdown launcher POSIX process-group stabilization",
2213
+ error,
2214
+ )
2215
+ continue
2216
+ except BaseException as error:
2217
+ _record_termination_diagnostic(
2218
+ diagnostics,
2219
+ "shutdown launcher POSIX process-group stabilization",
2220
+ error,
2221
+ )
2222
+ continue
2223
+ successful_passes += 1
2224
+ return _reap_terminated_posix_child(
2225
+ child,
2226
+ termination_requested=termination_requested,
2227
+ direct_exit_preceded_termination=child_exit_observed,
2228
+ process_group=process_group,
2229
+ launcher_signal=launcher_signal,
2230
+ diagnostics=diagnostics,
2231
+ )
2232
+
2233
+
2234
+ def _wait_for_armed_posix_child(
2235
+ child: Any,
2236
+ hard_deadline: float,
2237
+ process_group: _PosixProcessGroup,
2238
+ signal_state: _LauncherSignalState,
2239
+ ) -> _SupervisionOutcome:
2240
+ while True:
2241
+ if _claim_launcher_cancellation(signal_state):
2242
+ return _terminate_and_reap_posix_child(child, process_group)
2243
+ launcher_signal = _claim_launcher_signal(signal_state)
2244
+ if launcher_signal is not None:
2245
+ return _terminate_and_reap_posix_child(
2246
+ child,
2247
+ process_group,
2248
+ launcher_signal=launcher_signal,
2249
+ )
2250
+ remaining = hard_deadline - time.monotonic()
2251
+ if remaining <= 0.0:
2252
+ # On the deadline branch, termination is the first external action.
2253
+ # There is no logging, diagnostic I/O, cleanup, or liveness poll.
2254
+ return _terminate_and_reap_posix_child(child, process_group)
2255
+ try:
2256
+ child_exited = _posix_child_exit_observed(
2257
+ child,
2258
+ signal_state,
2259
+ deadline=hard_deadline,
2260
+ )
2261
+ except _LauncherCancellationReceived:
2262
+ return _terminate_and_reap_posix_child(child, process_group)
2263
+ except _LauncherSignalReceived as event:
2264
+ return _terminate_and_reap_posix_child(
2265
+ child,
2266
+ process_group,
2267
+ launcher_signal=event.signal_number,
2268
+ )
2269
+ except BaseException as error:
2270
+ return _terminate_and_reap_posix_child(
2271
+ child,
2272
+ process_group,
2273
+ deferred_diagnostic_context=(
2274
+ "shutdown launcher POSIX child observation"
2275
+ ),
2276
+ deferred_diagnostic_error=error,
2277
+ )
2278
+ if child_exited is None:
2279
+ return _terminate_and_reap_posix_child(child, process_group)
2280
+ if child_exited:
2281
+ return _terminate_and_reap_posix_child(
2282
+ child,
2283
+ process_group,
2284
+ child_exit_observed=True,
2285
+ )
2286
+ remaining = hard_deadline - time.monotonic()
2287
+ if remaining <= 0.0:
2288
+ return _terminate_and_reap_posix_child(child, process_group)
2289
+ time.sleep(min(_OBSERVATION_INTERVAL_SECONDS, remaining))
2290
+
2291
+
2292
+ def _finish_windows_contained_child(
2293
+ child: Any,
2294
+ *,
2295
+ launcher_signal: int | None = None,
2296
+ initial_diagnostics: Sequence[str] = (),
2297
+ ) -> _SupervisionOutcome:
2298
+ """Drain a terminated retained Job and release handles only after empty."""
2299
+
2300
+ return_code = int(child.wait())
2301
+ child.wait_tree_empty()
2302
+ child.close_after_empty()
2303
+ diagnostics = tuple(initial_diagnostics) + tuple(child.failures)
2304
+ if launcher_signal is not None:
2305
+ return _SupervisionOutcome(
2306
+ 128 + int(launcher_signal),
2307
+ signal_number=int(launcher_signal),
2308
+ diagnostics=diagnostics,
2309
+ )
2310
+ # TerminateJobObject supplies its requested code to processes it actually
2311
+ # terminates. A different retained direct-process status therefore proves
2312
+ # that exit won the deadline race even if it was not cached before the
2313
+ # required kill-first call. A natural exit with the same code is the one
2314
+ # inherently ambiguous Windows case and remains conservatively forced.
2315
+ forced = bool(
2316
+ child.termination_requested
2317
+ and not child.direct_exit_preceded_termination
2318
+ and return_code == child.termination_exit_code
2319
+ )
2320
+ if forced:
2321
+ return _SupervisionOutcome(
2322
+ _FORCED_SHUTDOWN_EXIT_CODE,
2323
+ forced=True,
2324
+ diagnostics=diagnostics,
2325
+ )
2326
+ return _SupervisionOutcome(return_code, diagnostics=diagnostics)
2327
+
2328
+
2329
+ def _terminate_and_reap_windows_child(
2330
+ child: Any,
2331
+ *,
2332
+ launcher_signal: int | None = None,
2333
+ initial_diagnostics: Sequence[str] = (),
2334
+ deferred_diagnostic_context: str | None = None,
2335
+ deferred_diagnostic_error: BaseException | None = None,
2336
+ ) -> _SupervisionOutcome:
2337
+ """Terminate the complete retained Job first, then wait and close it."""
2338
+
2339
+ diagnostics: list[str] | None = None
2340
+ while True:
2341
+ try:
2342
+ # This is the first external action on every deadline path. The
2343
+ # retained Job handle, never a PID, identifies the complete tree.
2344
+ child.terminate_tree(_FORCED_SHUTDOWN_EXIT_CODE)
2345
+ if diagnostics is None:
2346
+ diagnostics = _materialize_termination_diagnostics(
2347
+ initial_diagnostics,
2348
+ deferred_diagnostic_context,
2349
+ deferred_diagnostic_error,
2350
+ )
2351
+ break
2352
+ except BaseException as error:
2353
+ if diagnostics is None:
2354
+ diagnostics = _materialize_termination_diagnostics(
2355
+ initial_diagnostics,
2356
+ deferred_diagnostic_context,
2357
+ deferred_diagnostic_error,
2358
+ )
2359
+ _record_termination_diagnostic(
2360
+ diagnostics,
2361
+ "shutdown launcher Windows Job termination",
2362
+ error,
2363
+ )
2364
+ time.sleep(_TERMINATION_RETRY_SECONDS)
2365
+ assert diagnostics is not None
2366
+ return _finish_windows_contained_child(
2367
+ child,
2368
+ launcher_signal=launcher_signal,
2369
+ initial_diagnostics=diagnostics,
2370
+ )
2371
+
2372
+
2373
+ def _wait_for_armed_windows_child(
2374
+ child: Any,
2375
+ hard_deadline: float,
2376
+ signal_state: _LauncherSignalState,
2377
+ ) -> _SupervisionOutcome:
2378
+ while True:
2379
+ if _claim_launcher_cancellation(signal_state):
2380
+ return _terminate_and_reap_windows_child(child)
2381
+ launcher_signal = _claim_launcher_signal(signal_state)
2382
+ if launcher_signal is not None:
2383
+ return _terminate_and_reap_windows_child(
2384
+ child,
2385
+ launcher_signal=launcher_signal,
2386
+ )
2387
+ remaining = hard_deadline - time.monotonic()
2388
+ if remaining <= 0.0:
2389
+ return _terminate_and_reap_windows_child(child)
2390
+ try:
2391
+ child_exited = child.observe_before_deadline(hard_deadline)
2392
+ except BaseException as error:
2393
+ return _terminate_and_reap_windows_child(
2394
+ child,
2395
+ deferred_diagnostic_context=(
2396
+ "shutdown launcher Windows child observation"
2397
+ ),
2398
+ deferred_diagnostic_error=error,
2399
+ )
2400
+ if child_exited is None:
2401
+ return _terminate_and_reap_windows_child(child)
2402
+ if not child_exited:
2403
+ continue
2404
+ # Even a normally exited GUI can have descendants. Its retained
2405
+ # signalled-handle observation is retained, so Job termination only
2406
+ # cleans that residual tree and cannot turn the direct exit into a
2407
+ # forced result even when its natural status is also 70.
2408
+ return _terminate_and_reap_windows_child(child)
2409
+
2410
+
2411
+ def _terminate_and_reap_armed_child(
2412
+ child: Any,
2413
+ process_group: _PosixProcessGroup | None,
2414
+ *,
2415
+ launcher_signal: int | None = None,
2416
+ initial_diagnostics: Sequence[str] = (),
2417
+ deferred_diagnostic_context: str | None = None,
2418
+ deferred_diagnostic_error: BaseException | None = None,
2419
+ child_exit_observed: bool = False,
2420
+ ) -> _SupervisionOutcome:
2421
+ if os.name == "nt":
2422
+ return _terminate_and_reap_windows_child(
2423
+ child,
2424
+ launcher_signal=launcher_signal,
2425
+ initial_diagnostics=initial_diagnostics,
2426
+ deferred_diagnostic_context=deferred_diagnostic_context,
2427
+ deferred_diagnostic_error=deferred_diagnostic_error,
2428
+ )
2429
+ if process_group is None:
2430
+ raise ShutdownSupervisorError(
2431
+ "shutdown launcher POSIX process group is unavailable"
2432
+ )
2433
+ return _terminate_and_reap_posix_child(
2434
+ child,
2435
+ process_group,
2436
+ launcher_signal=launcher_signal,
2437
+ initial_diagnostics=initial_diagnostics,
2438
+ deferred_diagnostic_context=deferred_diagnostic_context,
2439
+ deferred_diagnostic_error=deferred_diagnostic_error,
2440
+ child_exit_observed=child_exit_observed,
2441
+ )
2442
+
2443
+
2444
+ def _wait_for_armed_child(
2445
+ child: Any,
2446
+ hard_deadline: float,
2447
+ process_group: _PosixProcessGroup | None,
2448
+ signal_state: _LauncherSignalState,
2449
+ ) -> _SupervisionOutcome:
2450
+ if os.name == "nt":
2451
+ return _wait_for_armed_windows_child(child, hard_deadline, signal_state)
2452
+ if process_group is None:
2453
+ raise ShutdownSupervisorError(
2454
+ "shutdown launcher POSIX process group is unavailable"
2455
+ )
2456
+ return _wait_for_armed_posix_child(
2457
+ child,
2458
+ hard_deadline,
2459
+ process_group,
2460
+ signal_state,
2461
+ )
2462
+
2463
+
2464
+ def _observe_and_reap(
2465
+ child: Any,
2466
+ process_group: _PosixProcessGroup | None,
2467
+ signal_state: _LauncherSignalState,
2468
+ *,
2469
+ initial_diagnostics: Sequence[str] = (),
2470
+ ) -> _SupervisionOutcome:
2471
+ """Observe a live child; only clean residual descendants after it exits."""
2472
+
2473
+ diagnostics = list(initial_diagnostics)
2474
+ reported_error = False
2475
+ while True:
2476
+ if _claim_launcher_cancellation(signal_state):
2477
+ return _terminate_and_reap_armed_child(
2478
+ child,
2479
+ process_group,
2480
+ initial_diagnostics=diagnostics,
2481
+ )
2482
+ launcher_signal = _claim_launcher_signal(signal_state)
2483
+ if launcher_signal is not None:
2484
+ return _terminate_and_reap_armed_child(
2485
+ child,
2486
+ process_group,
2487
+ launcher_signal=launcher_signal,
2488
+ initial_diagnostics=diagnostics,
2489
+ )
2490
+ try:
2491
+ child_exited = _child_exit_observed(child, signal_state)
2492
+ except _LauncherCancellationReceived:
2493
+ return _terminate_and_reap_armed_child(
2494
+ child,
2495
+ process_group,
2496
+ initial_diagnostics=diagnostics,
2497
+ )
2498
+ except _LauncherSignalReceived as event:
2499
+ return _terminate_and_reap_armed_child(
2500
+ child,
2501
+ process_group,
2502
+ launcher_signal=event.signal_number,
2503
+ initial_diagnostics=diagnostics,
2504
+ )
2505
+ except BaseException as error:
2506
+ # Before ARM there is no authority to signal the child. Remaining
2507
+ # its direct parent and retrying is preferable to orphaning it.
2508
+ if not reported_error:
2509
+ _record_termination_diagnostic(
2510
+ diagnostics,
2511
+ "shutdown launcher child observation",
2512
+ error,
2513
+ )
2514
+ reported_error = True
2515
+ try:
2516
+ time.sleep(_OBSERVATION_INTERVAL_SECONDS)
2517
+ except BaseException:
2518
+ pass
2519
+ continue
2520
+ if child_exited:
2521
+ # The exited direct child remains unreaped on POSIX. Terminate its
2522
+ # still-identity-bound group before reaping so leaked helpers can
2523
+ # neither survive nor expose an unrelated reused PGID.
2524
+ return _terminate_and_reap_armed_child(
2525
+ child,
2526
+ process_group,
2527
+ initial_diagnostics=diagnostics,
2528
+ child_exit_observed=True,
2529
+ )
2530
+ time.sleep(_OBSERVATION_INTERVAL_SECONDS)
2531
+
2532
+
2533
+ def _supervise_child_outcome(
2534
+ child_argv: Sequence[str | os.PathLike[str]],
2535
+ *,
2536
+ env: Mapping[str, str] | None = None,
2537
+ startup_timeout: float = _DEFAULT_STARTUP_TIMEOUT_SECONDS,
2538
+ database_path: str | os.PathLike[str] | None = None,
2539
+ popen_factory: Callable[..., Any] | None = None,
2540
+ _cancellation_event: threading.Event | None = None,
2541
+ ) -> _SupervisionOutcome:
2542
+ """Launch, authenticate, and supervise one contained qPlot process tree."""
2543
+
2544
+ timeout = float(startup_timeout)
2545
+ if not math.isfinite(timeout) or timeout <= 0.0:
2546
+ raise ValueError("startup_timeout must be finite and positive")
2547
+
2548
+ # The single startup deadline includes launcher setup and process creation;
2549
+ # it is never restarted after Popen returns.
2550
+ startup_deadline = time.monotonic() + timeout
2551
+ listener: socket.socket | None = None
2552
+ child: Any = None
2553
+ channel: socket.socket | None = None
2554
+ process_group: _PosixProcessGroup | None = None
2555
+ signal_guards = _LauncherSignalGuards(previous_handlers={})
2556
+ signal_state = _LauncherSignalState(cancellation_event=_cancellation_event)
2557
+ ready_state = _ReadyState()
2558
+ armed_state = _ArmedState()
2559
+ try:
2560
+ try:
2561
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
2562
+ listener.bind((_LOOPBACK_HOST, 0))
2563
+ listener.listen(1)
2564
+ authentication_key = secrets.token_bytes(_AUTHENTICATION_BYTES)
2565
+ session_nonce = secrets.token_bytes(_NONCE_BYTES)
2566
+ child_environment = dict(os.environ if env is None else env)
2567
+ child_environment[_BOOTSTRAP_ENVIRONMENT_KEY] = _bootstrap_payload(
2568
+ listener,
2569
+ authentication_key=authentication_key,
2570
+ session_nonce=session_nonce,
2571
+ startup_timeout=timeout,
2572
+ database_path=database_path,
2573
+ )
2574
+ # Guards and SIGCHLD=SIG_DFL are installed before Popen, closing the
2575
+ # spawn race and ensuring the POSIX child cannot be auto-reaped.
2576
+ signal_guards = _install_launcher_signal_guards(signal_state)
2577
+ except BaseException as error:
2578
+ if _claim_launcher_cancellation(signal_state):
2579
+ return _SupervisionOutcome(
2580
+ _FORCED_SHUTDOWN_EXIT_CODE,
2581
+ forced=True,
2582
+ )
2583
+ diagnostic = _exact_error("shutdown launcher setup", error)
2584
+ launcher_signal = _claim_launcher_signal(signal_state)
2585
+ if launcher_signal is not None:
2586
+ return _SupervisionOutcome(
2587
+ -launcher_signal,
2588
+ signal_number=launcher_signal,
2589
+ diagnostics=(diagnostic,),
2590
+ )
2591
+ return _SupervisionOutcome(
2592
+ _FORCED_SHUTDOWN_EXIT_CODE,
2593
+ diagnostics=(diagnostic,),
2594
+ )
2595
+
2596
+ try:
2597
+ _raise_if_launcher_signalled(signal_state)
2598
+ if os.name == "nt" and popen_factory is None:
2599
+ from qplot._windows_shutdown_job import spawn_contained
2600
+
2601
+ child = spawn_contained(list(child_argv), child_environment)
2602
+ else:
2603
+ active_popen_factory: Callable[..., Any] = subprocess.Popen
2604
+ if popen_factory is not None:
2605
+ active_popen_factory = popen_factory
2606
+ popen_options: dict[str, Any] = {"env": child_environment}
2607
+ if os.name != "nt":
2608
+ process_group = _PosixProcessGroup()
2609
+ popen_options.update(process_group.popen_options())
2610
+ child = active_popen_factory(list(child_argv), **popen_options)
2611
+ if process_group is not None:
2612
+ # start_new_session established this identity before exec.
2613
+ # Retain it without a syscall so cancellation can make group
2614
+ # termination the next external action after Popen returns.
2615
+ process_group.pgid = int(child.pid)
2616
+ _raise_if_launcher_signalled(signal_state)
2617
+ if process_group is not None:
2618
+ process_group.assign(child)
2619
+ _raise_if_launcher_signalled(signal_state)
2620
+ except _LauncherCancellationReceived:
2621
+ if child is None:
2622
+ return _SupervisionOutcome(
2623
+ _FORCED_SHUTDOWN_EXIT_CODE,
2624
+ forced=True,
2625
+ )
2626
+ return _terminate_and_reap_armed_child(child, process_group)
2627
+ except _LauncherSignalReceived as event:
2628
+ if child is None:
2629
+ return _SupervisionOutcome(
2630
+ -event.signal_number,
2631
+ signal_number=event.signal_number,
2632
+ )
2633
+ return _terminate_and_reap_armed_child(
2634
+ child,
2635
+ process_group,
2636
+ launcher_signal=event.signal_number,
2637
+ )
2638
+ except BaseException as error:
2639
+ if _claim_launcher_cancellation(signal_state):
2640
+ if child is None:
2641
+ return _SupervisionOutcome(
2642
+ _FORCED_SHUTDOWN_EXIT_CODE,
2643
+ forced=True,
2644
+ )
2645
+ return _terminate_and_reap_armed_child(child, process_group)
2646
+ launcher_signal = _claim_launcher_signal(signal_state)
2647
+ if child is None:
2648
+ diagnostic = _exact_error("shutdown launcher GUI child launch", error)
2649
+ if launcher_signal is not None:
2650
+ return _SupervisionOutcome(
2651
+ -launcher_signal,
2652
+ signal_number=launcher_signal,
2653
+ diagnostics=(diagnostic,),
2654
+ )
2655
+ return _SupervisionOutcome(
2656
+ _FORCED_SHUTDOWN_EXIT_CODE,
2657
+ diagnostics=(diagnostic,),
2658
+ )
2659
+ return _terminate_and_reap_armed_child(
2660
+ child,
2661
+ process_group,
2662
+ launcher_signal=launcher_signal,
2663
+ deferred_diagnostic_context="shutdown launcher GUI child launch",
2664
+ deferred_diagnostic_error=error,
2665
+ )
2666
+
2667
+ try:
2668
+ channel, early_exit = _accept_child_channel(
2669
+ listener,
2670
+ child,
2671
+ deadline=startup_deadline,
2672
+ signal_state=signal_state,
2673
+ )
2674
+ if early_exit:
2675
+ return _terminate_and_reap_armed_child(
2676
+ child,
2677
+ process_group,
2678
+ child_exit_observed=True,
2679
+ )
2680
+ if channel is None:
2681
+ raise ShutdownSupervisorError(
2682
+ "shutdown launcher child channel is unavailable"
2683
+ )
2684
+ _authenticate_child_channel(
2685
+ channel,
2686
+ child,
2687
+ authentication_key=authentication_key,
2688
+ session_nonce=session_nonce,
2689
+ deadline=startup_deadline,
2690
+ signal_state=signal_state,
2691
+ ready_state=ready_state,
2692
+ )
2693
+ except _LauncherCancellationReceived:
2694
+ return _terminate_and_reap_armed_child(child, process_group)
2695
+ except _LauncherSignalReceived as event:
2696
+ return _terminate_and_reap_armed_child(
2697
+ child,
2698
+ process_group,
2699
+ launcher_signal=event.signal_number,
2700
+ )
2701
+ except BaseException as error:
2702
+ if _claim_launcher_cancellation(signal_state):
2703
+ return _terminate_and_reap_armed_child(child, process_group)
2704
+ if ready_state.committed:
2705
+ diagnostic = _exact_error("shutdown launcher child readiness", error)
2706
+ return _observe_and_reap(
2707
+ child,
2708
+ process_group,
2709
+ signal_state,
2710
+ initial_diagnostics=(diagnostic,),
2711
+ )
2712
+ # Before authenticated READY, startup failure has bounded authority
2713
+ # to terminate the whole contained tree. No diagnostic I/O occurs
2714
+ # before that termination attempt.
2715
+ return _terminate_and_reap_armed_child(
2716
+ child,
2717
+ process_group,
2718
+ deferred_diagnostic_context="shutdown launcher child readiness",
2719
+ deferred_diagnostic_error=error,
2720
+ )
2721
+ finally:
2722
+ if listener is not None:
2723
+ try:
2724
+ listener.close()
2725
+ except OSError:
2726
+ pass
2727
+ listener = None
2728
+
2729
+ try:
2730
+ child_exited, protocol_error = _observe_until_arm(
2731
+ channel,
2732
+ child,
2733
+ authentication_key=authentication_key,
2734
+ session_nonce=session_nonce,
2735
+ armed_state=armed_state,
2736
+ signal_state=signal_state,
2737
+ )
2738
+ except _LauncherCancellationReceived:
2739
+ return _terminate_and_reap_armed_child(child, process_group)
2740
+ except _LauncherSignalReceived as event:
2741
+ return _terminate_and_reap_armed_child(
2742
+ child,
2743
+ process_group,
2744
+ launcher_signal=event.signal_number,
2745
+ )
2746
+ except BaseException as error:
2747
+ if _claim_launcher_cancellation(signal_state):
2748
+ return _terminate_and_reap_armed_child(child, process_group)
2749
+ if armed_state.deadline is not None:
2750
+ outcome = _wait_for_armed_child(
2751
+ child,
2752
+ armed_state.deadline,
2753
+ process_group,
2754
+ signal_state,
2755
+ )
2756
+ return _outcome_with_diagnostics(
2757
+ outcome,
2758
+ _exact_error("shutdown launcher post-ARM failure", error),
2759
+ )
2760
+ return _observe_and_reap(
2761
+ child,
2762
+ process_group,
2763
+ signal_state,
2764
+ initial_diagnostics=(
2765
+ _exact_error("shutdown launcher pre-ARM observation", error),
2766
+ ),
2767
+ )
2768
+ if _claim_launcher_cancellation(signal_state):
2769
+ return _terminate_and_reap_armed_child(child, process_group)
2770
+ if child_exited:
2771
+ return _terminate_and_reap_armed_child(
2772
+ child,
2773
+ process_group,
2774
+ child_exit_observed=True,
2775
+ )
2776
+ if protocol_error is not None and armed_state.deadline is None:
2777
+ return _observe_and_reap(
2778
+ child,
2779
+ process_group,
2780
+ signal_state,
2781
+ initial_diagnostics=(protocol_error,),
2782
+ )
2783
+
2784
+ immutable_hard_deadline = armed_state.deadline
2785
+ if immutable_hard_deadline is None:
2786
+ return _observe_and_reap(
2787
+ child,
2788
+ process_group,
2789
+ signal_state,
2790
+ initial_diagnostics=("shutdown launcher did not receive ARM",),
2791
+ )
2792
+
2793
+ try:
2794
+ deadline_expired = time.monotonic() >= immutable_hard_deadline
2795
+ except BaseException as error:
2796
+ return _terminate_and_reap_armed_child(
2797
+ child,
2798
+ process_group,
2799
+ deferred_diagnostic_context=("shutdown launcher deadline observation"),
2800
+ deferred_diagnostic_error=error,
2801
+ )
2802
+ if deadline_expired:
2803
+ return _terminate_and_reap_armed_child(child, process_group)
2804
+
2805
+ # ACK construction begins only after the immutable ARMED transition.
2806
+ # Any construction/send failure continues waiting against that exact
2807
+ # deadline rather than falling back to pre-ARM observe-only behavior.
2808
+ acknowledgement_error: BaseException | None = None
2809
+ if _claim_launcher_cancellation(signal_state):
2810
+ return _terminate_and_reap_armed_child(child, process_group)
2811
+ try:
2812
+ arm_nonce = armed_state.arm_nonce
2813
+ deadline_payload = armed_state.deadline_payload
2814
+ if arm_nonce is None or deadline_payload is None:
2815
+ raise ShutdownSupervisorError(
2816
+ "shutdown launcher committed ARM metadata is incomplete"
2817
+ )
2818
+ acknowledgement = _encode_frame(
2819
+ _ARM_ACK,
2820
+ authentication_key=authentication_key,
2821
+ session_nonce=session_nonce,
2822
+ message_nonce=arm_nonce,
2823
+ payload=deadline_payload,
2824
+ )
2825
+ _send_arm_acknowledgement(channel, acknowledgement)
2826
+ except BaseException as error:
2827
+ acknowledgement_error = error
2828
+
2829
+ try:
2830
+ outcome = _wait_for_armed_child(
2831
+ child,
2832
+ immutable_hard_deadline,
2833
+ process_group,
2834
+ signal_state,
2835
+ )
2836
+ except BaseException as error:
2837
+ outcome = _terminate_and_reap_armed_child(
2838
+ child,
2839
+ process_group,
2840
+ deferred_diagnostic_context="shutdown launcher armed wait",
2841
+ deferred_diagnostic_error=error,
2842
+ )
2843
+ if acknowledgement_error is not None:
2844
+ return _outcome_with_diagnostics(
2845
+ outcome,
2846
+ _exact_error(
2847
+ "shutdown launcher ARM acknowledgement",
2848
+ acknowledgement_error,
2849
+ ),
2850
+ )
2851
+ return outcome
2852
+ except _LauncherCancellationReceived:
2853
+ if child is None:
2854
+ return _SupervisionOutcome(
2855
+ _FORCED_SHUTDOWN_EXIT_CODE,
2856
+ forced=True,
2857
+ )
2858
+ return _terminate_and_reap_armed_child(child, process_group)
2859
+ except _LauncherSignalReceived as event:
2860
+ if child is None:
2861
+ return _SupervisionOutcome(
2862
+ -event.signal_number,
2863
+ signal_number=event.signal_number,
2864
+ )
2865
+ return _terminate_and_reap_armed_child(
2866
+ child,
2867
+ process_group,
2868
+ launcher_signal=event.signal_number,
2869
+ )
2870
+ except BaseException as error:
2871
+ if _claim_launcher_cancellation(signal_state):
2872
+ if child is None:
2873
+ return _SupervisionOutcome(
2874
+ _FORCED_SHUTDOWN_EXIT_CODE,
2875
+ forced=True,
2876
+ )
2877
+ return _terminate_and_reap_armed_child(child, process_group)
2878
+ if child is None:
2879
+ diagnostic = _exact_error("shutdown launcher unexpected failure", error)
2880
+ return _SupervisionOutcome(
2881
+ _FORCED_SHUTDOWN_EXIT_CODE,
2882
+ diagnostics=(diagnostic,),
2883
+ )
2884
+ if armed_state.deadline is not None:
2885
+ return _terminate_and_reap_armed_child(
2886
+ child,
2887
+ process_group,
2888
+ deferred_diagnostic_context="shutdown launcher post-ARM failure",
2889
+ deferred_diagnostic_error=error,
2890
+ )
2891
+ if ready_state.committed:
2892
+ return _observe_and_reap(
2893
+ child,
2894
+ process_group,
2895
+ signal_state,
2896
+ initial_diagnostics=(
2897
+ _exact_error("shutdown launcher pre-ARM failure", error),
2898
+ ),
2899
+ )
2900
+ return _terminate_and_reap_armed_child(
2901
+ child,
2902
+ process_group,
2903
+ deferred_diagnostic_context="shutdown launcher startup failure",
2904
+ deferred_diagnostic_error=error,
2905
+ )
2906
+ finally:
2907
+ if listener is not None:
2908
+ try:
2909
+ listener.close()
2910
+ except OSError:
2911
+ pass
2912
+ if channel is not None:
2913
+ try:
2914
+ channel.close()
2915
+ except OSError:
2916
+ pass
2917
+ if process_group is not None:
2918
+ process_group.close()
2919
+ _restore_launcher_signal_guards(signal_guards)
2920
+ late_launcher_signal = _claim_launcher_signal(signal_state)
2921
+ if late_launcher_signal is not None:
2922
+ # Containment ownership is already resolved on every returning
2923
+ # path. A signal recorded during reap/epilogue must still retain
2924
+ # real signal semantics instead of disappearing with the guards.
2925
+ _terminate_launcher_with_signal(late_launcher_signal)
2926
+
2927
+
2928
+ def _supervise_child(
2929
+ child_argv: Sequence[str | os.PathLike[str]],
2930
+ *,
2931
+ env: Mapping[str, str] | None = None,
2932
+ startup_timeout: float = _DEFAULT_STARTUP_TIMEOUT_SECONDS,
2933
+ database_path: str | os.PathLike[str] | None = None,
2934
+ popen_factory: Callable[..., Any] | None = None,
2935
+ ) -> int:
2936
+ """Testable integer-returning wrapper around process-tree supervision."""
2937
+
2938
+ outcome = _supervise_child_outcome(
2939
+ child_argv,
2940
+ env=env,
2941
+ startup_timeout=startup_timeout,
2942
+ database_path=database_path,
2943
+ popen_factory=popen_factory,
2944
+ )
2945
+ diagnostics_published = _publish_outcome_diagnostics(outcome)
2946
+ if not diagnostics_published:
2947
+ signal_number = outcome.signal_number
2948
+ if signal_number is not None and os.name != "nt":
2949
+ _terminate_launcher_with_signal(signal_number)
2950
+ os._exit(outcome.return_code)
2951
+ return outcome.return_code
2952
+
2953
+
2954
+ def _publish_outcome_diagnostics(outcome: _SupervisionOutcome) -> bool:
2955
+ """Publish retained details without letting inherited stderr block exit."""
2956
+
2957
+ if not outcome.diagnostics:
2958
+ return True
2959
+ completed = threading.Event()
2960
+
2961
+ def publish() -> None:
2962
+ try:
2963
+ for diagnostic in outcome.diagnostics:
2964
+ _report_launcher_failure(diagnostic)
2965
+ finally:
2966
+ completed.set()
2967
+
2968
+ try:
2969
+ publisher = threading.Thread(
2970
+ target=publish,
2971
+ name="qplot-shutdown-diagnostic-publisher",
2972
+ daemon=True,
2973
+ )
2974
+ publisher.start()
2975
+ publisher.join(_DIAGNOSTIC_PUBLISH_BUDGET_SECONDS)
2976
+ except BaseException:
2977
+ return False
2978
+ return completed.is_set()
2979
+
2980
+
2981
+ def _terminate_launcher_with_signal(signal_number: int) -> None:
2982
+ """Reproduce a POSIX child/launcher signal status after tree ownership ends."""
2983
+
2984
+ if os.name == "nt":
2985
+ os._exit(128 + int(signal_number))
2986
+ try:
2987
+ signal.signal(signal_number, signal.SIG_DFL)
2988
+ except (OSError, RuntimeError, ValueError):
2989
+ pass
2990
+ pthread_sigmask = getattr(signal, "pthread_sigmask", None)
2991
+ if pthread_sigmask is not None:
2992
+ pthread_sigmask(signal.SIG_UNBLOCK, {signal_number})
2993
+ os.kill(os.getpid(), signal_number)
2994
+ # A signal that is unexpectedly masked/ignored must still terminate rather
2995
+ # than flowing through SystemExit(-N) as the misleading numeric code 256-N.
2996
+ os._exit(128 + int(signal_number))
2997
+
2998
+
2999
+ def launch_gui(
3000
+ original_argv: Sequence[str] | None = None,
3001
+ *,
3002
+ database_path: str | os.PathLike[str] | None = None,
3003
+ _force_launcher_exit: Callable[[int], Any] = os._exit,
3004
+ _force_launcher_signal: Callable[[int], Any] = _terminate_launcher_with_signal,
3005
+ ) -> int:
3006
+ """Start the production GUI inside the launcher's retained containment."""
3007
+
3008
+ preserved_argv = list(sys.argv if original_argv is None else original_argv)
3009
+ if not preserved_argv:
3010
+ preserved_argv = ["qplot"]
3011
+ child_argv = [
3012
+ sys.executable,
3013
+ "-m",
3014
+ "qplot._shutdown_supervisor",
3015
+ _GUI_CHILD_SENTINEL,
3016
+ *preserved_argv,
3017
+ ]
3018
+ outcome = _supervise_child_outcome(
3019
+ child_argv,
3020
+ env=os.environ,
3021
+ database_path=database_path,
3022
+ )
3023
+ diagnostics_published = _publish_outcome_diagnostics(outcome)
3024
+ signal_number = outcome.signal_number
3025
+ if (
3026
+ signal_number is None
3027
+ and os.name != "nt"
3028
+ and not outcome.forced
3029
+ and outcome.return_code < 0
3030
+ ):
3031
+ signal_number = -outcome.return_code
3032
+ if signal_number is not None:
3033
+ _force_launcher_signal(signal_number)
3034
+ return outcome.return_code
3035
+ if outcome.forced or not diagnostics_published:
3036
+ # Avoid Python atexit, imported-hook, or thread shutdown after the
3037
+ # exact GUI child has been killed and boundedly reaped. The injectable
3038
+ # callable keeps this production hard-exit behavior unit-testable.
3039
+ _force_launcher_exit(outcome.return_code)
3040
+ return outcome.return_code
3041
+
3042
+
3043
+ def _public_api_gui_child_argv(
3044
+ preserved_argv: Sequence[str],
3045
+ ) -> list[str]:
3046
+ """Build the GUI command in the caller (a private subprocess-test seam)."""
3047
+
3048
+ return [
3049
+ sys.executable,
3050
+ "-m",
3051
+ "qplot._shutdown_supervisor",
3052
+ _GUI_CHILD_SENTINEL,
3053
+ *preserved_argv,
3054
+ ]
3055
+
3056
+
3057
+ def _spawn_public_api_launcher(
3058
+ launcher_argv: Sequence[str],
3059
+ environment: Mapping[str, str],
3060
+ ) -> subprocess.Popen[Any]:
3061
+ """Spawn the sole GUI parent without inheriting private channel handles."""
3062
+
3063
+ return subprocess.Popen(
3064
+ list(launcher_argv),
3065
+ env=dict(environment),
3066
+ close_fds=True,
3067
+ )
3068
+
3069
+
3070
+ def _connect_public_api_result_channel(
3071
+ bootstrap: _ApiLauncherBootstrap,
3072
+ ) -> socket.socket:
3073
+ remaining = _timeout_within(bootstrap.startup_deadline)
3074
+ if remaining <= 0.0:
3075
+ raise TimeoutError(
3076
+ "public-API launcher startup deadline expired before connection"
3077
+ )
3078
+ channel = socket.create_connection(
3079
+ (bootstrap.host, bootstrap.port),
3080
+ timeout=remaining,
3081
+ )
3082
+ channel.set_inheritable(False)
3083
+ hello_nonce = secrets.token_bytes(_NONCE_BYTES)
3084
+ claimed_pid = _supervised_child_claimed_pid()
3085
+ hello = _encode_frame(
3086
+ _API_LAUNCHER_HELLO,
3087
+ authentication_key=bootstrap.authentication_key,
3088
+ session_nonce=bootstrap.session_nonce,
3089
+ message_nonce=hello_nonce,
3090
+ payload=_PID_PAYLOAD.pack(claimed_pid),
3091
+ )
3092
+ try:
3093
+ remaining = _timeout_within(bootstrap.startup_deadline)
3094
+ if remaining <= 0.0:
3095
+ raise TimeoutError(
3096
+ "public-API launcher startup deadline expired before HELLO"
3097
+ )
3098
+ channel.settimeout(remaining)
3099
+ channel.sendall(hello)
3100
+ ready = _receive_exact(
3101
+ channel,
3102
+ _FRAME_SIZE,
3103
+ deadline=bootstrap.startup_deadline,
3104
+ )
3105
+ frame_type, message_nonce, payload = _decode_frame(
3106
+ ready,
3107
+ authentication_key=bootstrap.authentication_key,
3108
+ session_nonce=bootstrap.session_nonce,
3109
+ )
3110
+ if frame_type != _API_LAUNCHER_READY:
3111
+ raise ShutdownSupervisorError(
3112
+ f"unexpected public-API launcher startup frame type {frame_type}"
3113
+ )
3114
+ if not hmac.compare_digest(message_nonce, hello_nonce):
3115
+ raise ShutdownSupervisorError(
3116
+ "public-API launcher startup acknowledgement nonce does not match"
3117
+ )
3118
+ if _PID_PAYLOAD.unpack(payload)[0] != claimed_pid:
3119
+ raise ShutdownSupervisorError(
3120
+ "public-API launcher startup acknowledgement PID does not match"
3121
+ )
3122
+ except BaseException:
3123
+ channel.close()
3124
+ raise
3125
+ channel.settimeout(None)
3126
+ return channel
3127
+
3128
+
3129
+ def _authenticate_public_api_launcher(
3130
+ listener: socket.socket,
3131
+ launcher: subprocess.Popen[Any],
3132
+ *,
3133
+ authentication_key: bytes,
3134
+ session_nonce: bytes,
3135
+ startup_deadline: float,
3136
+ ready_state: _ApiLauncherReadyState,
3137
+ ) -> socket.socket:
3138
+ remaining = _timeout_within(startup_deadline)
3139
+ if remaining <= 0.0:
3140
+ raise TimeoutError(
3141
+ "public-API launcher readiness deadline expired before accept"
3142
+ )
3143
+ listener.settimeout(remaining)
3144
+ channel, _address = listener.accept()
3145
+ channel.set_inheritable(False)
3146
+ ready_state.channel = channel
3147
+ try:
3148
+ hello = _receive_exact(channel, _FRAME_SIZE, deadline=startup_deadline)
3149
+ frame_type, hello_nonce, payload = _decode_frame(
3150
+ hello,
3151
+ authentication_key=authentication_key,
3152
+ session_nonce=session_nonce,
3153
+ )
3154
+ if frame_type != _API_LAUNCHER_HELLO:
3155
+ raise ShutdownSupervisorError(
3156
+ f"unexpected public-API launcher HELLO type {frame_type}"
3157
+ )
3158
+ claimed_pid = _PID_PAYLOAD.unpack(payload)[0]
3159
+ if claimed_pid != launcher.pid:
3160
+ raise ShutdownSupervisorError(
3161
+ "public-API launcher claimed PID "
3162
+ f"{claimed_pid} does not match {launcher.pid}"
3163
+ )
3164
+ ready = _encode_frame(
3165
+ _API_LAUNCHER_READY,
3166
+ authentication_key=authentication_key,
3167
+ session_nonce=session_nonce,
3168
+ message_nonce=hello_nonce,
3169
+ payload=_PID_PAYLOAD.pack(launcher.pid),
3170
+ )
3171
+ remaining = _timeout_within(startup_deadline)
3172
+ if remaining <= 0.0:
3173
+ raise TimeoutError(
3174
+ "public-API launcher readiness deadline expired before READY"
3175
+ )
3176
+ channel.settimeout(remaining)
3177
+ channel.sendall(ready)
3178
+ # This is the exact point after which the launcher may create a GUI.
3179
+ # The retained channel lets the caller cancel and finish the full
3180
+ # result/EOF handshake if control flow interrupts the next bytecode.
3181
+ ready_state.committed = True
3182
+ except BaseException:
3183
+ if not ready_state.committed:
3184
+ channel.close()
3185
+ ready_state.channel = None
3186
+ raise
3187
+ channel.settimeout(None)
3188
+ return channel
3189
+
3190
+
3191
+ def _receive_public_api_launcher_outcome(
3192
+ channel: socket.socket,
3193
+ *,
3194
+ authentication_key: bytes,
3195
+ session_nonce: bytes,
3196
+ ) -> _SupervisionOutcome:
3197
+ """Wait without a GUI runtime limit, then bound one authenticated result."""
3198
+
3199
+ channel.settimeout(None)
3200
+ first_byte = channel.recv(1)
3201
+ if not first_byte:
3202
+ raise ShutdownSupervisorError(
3203
+ "public-API launcher result channel closed before an outcome"
3204
+ )
3205
+ result_deadline = time.monotonic() + _API_RESULT_IO_TIMEOUT_SECONDS
3206
+ header = first_byte + _receive_exact(
3207
+ channel,
3208
+ _API_RESULT_HEADER.size - 1,
3209
+ deadline=result_deadline,
3210
+ )
3211
+ try:
3212
+ _magic, _version, _frame_type, _session, payload_size = (
3213
+ _API_RESULT_HEADER.unpack(header)
3214
+ )
3215
+ except struct.error as error:
3216
+ raise ShutdownSupervisorError(
3217
+ _exact_error("public-API launcher result header decoding", error)
3218
+ ) from error
3219
+ if payload_size > _API_RESULT_MAX_BYTES:
3220
+ raise ShutdownSupervisorError(
3221
+ "public-API launcher result exceeds the bounded result size"
3222
+ )
3223
+ remainder = _receive_exact(
3224
+ channel,
3225
+ payload_size + _AUTHENTICATION_BYTES,
3226
+ deadline=result_deadline,
3227
+ )
3228
+ outcome = _decode_api_launcher_outcome(
3229
+ header + remainder,
3230
+ authentication_key=authentication_key,
3231
+ session_nonce=session_nonce,
3232
+ )
3233
+ return outcome
3234
+
3235
+
3236
+ def _public_api_cancellation_boundary(_name: str) -> None:
3237
+ """Deterministic seam for cancellation boundary regressions."""
3238
+
3239
+
3240
+ def _public_api_interrupt_guard_boundary(_name: str) -> None:
3241
+ """Deterministic seam for transactional SIGINT-guard regressions."""
3242
+
3243
+
3244
+ def _public_api_caller_cleanup_boundary(_name: str) -> None:
3245
+ """Deterministic seam for post-READY caller cleanup regressions."""
3246
+
3247
+
3248
+ def _new_public_api_cancellation_worker(
3249
+ sender: _ApiLauncherCancellationSender,
3250
+ ) -> threading.Thread:
3251
+ """Create the one committed cancellation worker incarnation."""
3252
+
3253
+ return threading.Thread(
3254
+ target=sender._run,
3255
+ name="qplot-public-api-cancellation-sender",
3256
+ daemon=True,
3257
+ )
3258
+
3259
+
3260
+ def _public_api_cancellation_send(
3261
+ channel: socket.socket,
3262
+ data: bytes,
3263
+ ) -> int:
3264
+ """Send cancellation bytes through a deterministic partial-write seam."""
3265
+
3266
+ return channel.send(data)
3267
+
3268
+
3269
+ def _public_api_cancellation_shutdown(channel: socket.socket) -> None:
3270
+ """Irreversibly close the sole caller-to-launcher write direction."""
3271
+
3272
+ channel.shutdown(socket.SHUT_WR)
3273
+
3274
+
3275
+ def _send_public_api_cancellation_record(
3276
+ sender: _ApiLauncherCancellationSender,
3277
+ ) -> None:
3278
+ """Resolve the sole cancellation write without leaking ``BaseException``."""
3279
+
3280
+ while sender._deadline is None:
3281
+ try:
3282
+ _public_api_cancellation_boundary("lock_acquisition")
3283
+ with sender._lock:
3284
+ if sender._started_at is None:
3285
+ _public_api_cancellation_boundary("deadline_time_monotonic")
3286
+ sender._started_at = time.monotonic()
3287
+ started_at = sender._started_at
3288
+ _public_api_cancellation_boundary("deadline_setup")
3289
+ deadline = started_at + _API_RESULT_IO_TIMEOUT_SECONDS
3290
+ _public_api_cancellation_boundary("lock_acquisition")
3291
+ with sender._lock:
3292
+ if sender._deadline is None:
3293
+ sender._deadline = deadline
3294
+ except BaseException as error:
3295
+ sender._remember_error(error)
3296
+
3297
+ deadline = sender._deadline
3298
+ assert deadline is not None
3299
+ sent = sender._offset
3300
+ fail_closed = False
3301
+ while sent < len(sender.frame):
3302
+ try:
3303
+ _public_api_cancellation_boundary("timeout_within")
3304
+ remaining = _timeout_within(deadline)
3305
+ except BaseException as error:
3306
+ sender._remember_error(error)
3307
+ continue
3308
+ if remaining <= 0.0:
3309
+ break
3310
+ try:
3311
+ _public_api_cancellation_boundary("select")
3312
+ _readable, writable, _exceptional = select.select(
3313
+ [],
3314
+ [sender.channel],
3315
+ [],
3316
+ min(_OBSERVATION_INTERVAL_SECONDS, remaining),
3317
+ )
3318
+ except BaseException as error:
3319
+ sender._remember_error(error)
3320
+ continue
3321
+ if not writable:
3322
+ continue
3323
+ try:
3324
+ _public_api_cancellation_boundary("send_before_progress")
3325
+ except BaseException as error:
3326
+ sender._remember_error(error)
3327
+ continue
3328
+ try:
3329
+ written = _public_api_cancellation_send(
3330
+ sender.channel,
3331
+ sender.frame[sent:],
3332
+ )
3333
+ except BaseException as error:
3334
+ # A raising socket implementation does not provide a trustworthy
3335
+ # byte count. Never guess and risk duplicating part of the HMAC
3336
+ # frame; EOF is the only safe continuation.
3337
+ sender._remember_error(error)
3338
+ fail_closed = True
3339
+ break
3340
+ if written <= 0:
3341
+ sender._remember_error(
3342
+ ShutdownSupervisorError(
3343
+ "public-API launcher cancellation send made no progress"
3344
+ )
3345
+ )
3346
+ fail_closed = True
3347
+ break
3348
+
3349
+ # Persist progress before any injectable boundary. Only this worker
3350
+ # writes bytes, so its local offset remains authoritative even if the
3351
+ # shared observation below is interrupted.
3352
+ sent += written
3353
+ if sent < len(sender.frame):
3354
+ try:
3355
+ _public_api_cancellation_boundary("after_partial_send")
3356
+ except BaseException as error:
3357
+ sender._remember_error(error)
3358
+ while sender._offset != sent:
3359
+ try:
3360
+ _public_api_cancellation_boundary("send_offset_accounting")
3361
+ _public_api_cancellation_boundary("lock_acquisition")
3362
+ with sender._lock:
3363
+ sender._offset = sent
3364
+ except BaseException as error:
3365
+ sender._remember_error(error)
3366
+
3367
+ if sent == len(sender.frame) and not fail_closed:
3368
+ sender._finish()
3369
+ return
3370
+
3371
+ if sender._first_error is None:
3372
+ sender._remember_error(
3373
+ TimeoutError("public-API launcher cancellation send deadline expired")
3374
+ )
3375
+ while True:
3376
+ try:
3377
+ # Authenticated READY established the peer identity. If the fixed
3378
+ # cancellation record cannot be completed, write-side EOF is the
3379
+ # irreversible ownership-loss request read by that same peer.
3380
+ _public_api_cancellation_boundary("write_side_shutdown")
3381
+ _public_api_cancellation_shutdown(sender.channel)
3382
+ break
3383
+ except OSError:
3384
+ break
3385
+ except BaseException as error:
3386
+ sender._remember_error(error)
3387
+ sender._finish()
3388
+
3389
+
3390
+ def _observe_public_api_launcher_result(
3391
+ channel: socket.socket,
3392
+ observation: _ApiLauncherResultObservation,
3393
+ cancellation_sender: _ApiLauncherCancellationSender,
3394
+ *,
3395
+ authentication_key: bytes,
3396
+ session_nonce: bytes,
3397
+ ) -> None:
3398
+ """Sole caller-side reader for the launcher-to-caller direction."""
3399
+
3400
+ try:
3401
+ try:
3402
+ observation.outcome = _receive_public_api_launcher_outcome(
3403
+ channel,
3404
+ authentication_key=authentication_key,
3405
+ session_nonce=session_nonce,
3406
+ )
3407
+ except BaseException as error:
3408
+ observation.error = error
3409
+ cancellation_sender.request()
3410
+
3411
+ while True:
3412
+ try:
3413
+ trailing = channel.recv(4096)
3414
+ except InterruptedError:
3415
+ continue
3416
+ except BaseException as error:
3417
+ if observation.error is None:
3418
+ observation.error = error
3419
+ cancellation_sender.request()
3420
+ return
3421
+ if not trailing:
3422
+ observation.eof_observed = True
3423
+ return
3424
+ if observation.error is None:
3425
+ observation.error = ShutdownSupervisorError(
3426
+ "public-API launcher sent trailing data after its outcome"
3427
+ )
3428
+ cancellation_sender.request()
3429
+ finally:
3430
+ observation.completed.set()
3431
+
3432
+
3433
+ def _monitor_public_api_caller(
3434
+ channel: socket.socket,
3435
+ bootstrap: _ApiLauncherBootstrap,
3436
+ control_state: _ApiCallerControlState,
3437
+ ) -> None:
3438
+ """Sole launcher-side reader for cancellation or caller disappearance."""
3439
+
3440
+ try:
3441
+ frame = _receive_exact(channel, _FRAME_SIZE)
3442
+ except BaseException as error:
3443
+ if isinstance(error, ShutdownSupervisorError) and "closed" in str(error):
3444
+ diagnostic = "public-API caller control channel closed before outcome"
3445
+ else:
3446
+ diagnostic = _exact_error("public-API caller control receive", error)
3447
+ control_state.commit_cancellation(diagnostic)
3448
+ return
3449
+
3450
+ try:
3451
+ frame_type, _message_nonce, payload = _decode_frame(
3452
+ frame,
3453
+ authentication_key=bootstrap.authentication_key,
3454
+ session_nonce=bootstrap.session_nonce,
3455
+ )
3456
+ if frame_type != _API_LAUNCHER_CANCEL:
3457
+ raise ShutdownSupervisorError(
3458
+ f"unexpected public-API caller control type {frame_type}"
3459
+ )
3460
+ claimed_pid = _PID_PAYLOAD.unpack(payload)[0]
3461
+ if claimed_pid != bootstrap.caller_pid:
3462
+ raise ShutdownSupervisorError(
3463
+ "public-API caller cancellation PID "
3464
+ f"{claimed_pid} does not match {bootstrap.caller_pid}"
3465
+ )
3466
+ except BaseException as error:
3467
+ control_state.commit_cancellation(
3468
+ _exact_error("public-API caller cancellation protocol", error)
3469
+ )
3470
+ return
3471
+ control_state.commit_cancellation("public-API caller cancellation authenticated")
3472
+
3473
+
3474
+ def _wait_for_public_api_result_completion(completed: threading.Event) -> None:
3475
+ """Interruptible caller wait seam; the result reader owns all framing."""
3476
+
3477
+ completed.wait(_OBSERVATION_INTERVAL_SECONDS)
3478
+
3479
+
3480
+ def _remember_caller_control_flow(
3481
+ pending: _PendingCallerControlFlow | None,
3482
+ error: BaseException,
3483
+ ) -> _PendingCallerControlFlow:
3484
+ if pending is not None:
3485
+ return pending
3486
+ return _PendingCallerControlFlow(error, error.__traceback__)
3487
+
3488
+
3489
+ def _remember_and_guard_caller_control_flow(
3490
+ pending: _PendingCallerControlFlow | None,
3491
+ error: BaseException,
3492
+ interrupt_guard: _CallerCleanupInterruptGuard,
3493
+ ) -> _PendingCallerControlFlow:
3494
+ """Retain the first object, then make later real SIGINTs non-escaping."""
3495
+
3496
+ first_error = error
3497
+ while pending is None:
3498
+ try:
3499
+ pending = _PendingCallerControlFlow(
3500
+ first_error,
3501
+ first_error.__traceback__,
3502
+ )
3503
+ except BaseException:
3504
+ # The original object remains in ``first_error`` even if another
3505
+ # caller exception lands during traceback capture/allocation.
3506
+ continue
3507
+ while not interrupt_guard.engagement_complete:
3508
+ try:
3509
+ interrupt_guard.engage()
3510
+ break
3511
+ except BaseException:
3512
+ # Injected control flow and rapid later SIGINTs are deliberately
3513
+ # absorbed only for the bounded containment-cleanup interval.
3514
+ continue
3515
+ return pending
3516
+
3517
+
3518
+ def _wait_for_public_api_launcher_exit(
3519
+ launcher: subprocess.Popen[Any],
3520
+ ) -> None:
3521
+ """Reap if possible; a foreign POSIX reaper may already own the status."""
3522
+
3523
+ try:
3524
+ launcher.wait(timeout=_API_RESULT_IO_TIMEOUT_SECONDS)
3525
+ except ChildProcessError:
3526
+ # The authenticated channel and EOF prove identity and process exit;
3527
+ # wait status is deliberately not part of the result protocol.
3528
+ launcher.returncode = 0
3529
+
3530
+
3531
+ def _stop_unready_public_api_launcher(
3532
+ launcher: subprocess.Popen[Any],
3533
+ *,
3534
+ startup_deadline: float,
3535
+ ) -> tuple[str, ...]:
3536
+ """Close pre-READY ownership without signalling a stale POSIX PID."""
3537
+
3538
+ diagnostics: list[str] = []
3539
+ if os.name == "nt":
3540
+ # subprocess retains the exact Windows process handle; these methods
3541
+ # do not reopen or target an unverified numeric PID.
3542
+ try:
3543
+ launcher.terminate()
3544
+ except (ChildProcessError, OSError):
3545
+ pass
3546
+ except BaseException as error:
3547
+ diagnostics.append(
3548
+ _exact_error("public-API unready launcher termination", error)
3549
+ )
3550
+
3551
+ observation_deadline = max(startup_deadline, time.monotonic()) + (
3552
+ _API_RESULT_IO_TIMEOUT_SECONDS
3553
+ )
3554
+ while True:
3555
+ remaining = _timeout_within(observation_deadline)
3556
+ if remaining <= 0.0:
3557
+ diagnostics.append(
3558
+ "public-API unready launcher did not exit within its "
3559
+ "startup/self-exit interval"
3560
+ )
3561
+ return tuple(diagnostics)
3562
+ try:
3563
+ launcher.wait(timeout=min(_OBSERVATION_INTERVAL_SECONDS, remaining))
3564
+ return tuple(diagnostics)
3565
+ except ChildProcessError:
3566
+ launcher.returncode = 0
3567
+ return tuple(diagnostics)
3568
+ except subprocess.TimeoutExpired:
3569
+ continue
3570
+ except BaseException as error:
3571
+ diagnostic = _exact_error(
3572
+ "public-API unready launcher exit observation",
3573
+ error,
3574
+ )
3575
+ if diagnostic not in diagnostics:
3576
+ diagnostics.append(diagnostic)
3577
+
3578
+
3579
+ def _public_api_failure(context: str, error: BaseException) -> _SupervisionOutcome:
3580
+ return _SupervisionOutcome(
3581
+ _FORCED_SHUTDOWN_EXIT_CODE,
3582
+ diagnostics=(_exact_error(context, error),),
3583
+ )
3584
+
3585
+
3586
+ def launch_gui_for_api(
3587
+ original_argv: Sequence[str] | None = None,
3588
+ *,
3589
+ database_path: str | os.PathLike[str] | None = None,
3590
+ startup_timeout: float = _DEFAULT_STARTUP_TIMEOUT_SECONDS,
3591
+ ) -> int:
3592
+ """Return a contained GUI result without terminating the API caller."""
3593
+
3594
+ timeout = float(startup_timeout)
3595
+ if not math.isfinite(timeout) or timeout <= 0.0:
3596
+ raise ValueError("startup_timeout must be finite and positive")
3597
+ startup_deadline = time.monotonic() + timeout
3598
+ preserved_argv = list(sys.argv if original_argv is None else original_argv)
3599
+ if not preserved_argv:
3600
+ preserved_argv = ["qplot"]
3601
+ listener: socket.socket | None = None
3602
+ channel: socket.socket | None = None
3603
+ launcher: subprocess.Popen[Any] | None = None
3604
+ ready = False
3605
+ unready_stopped = False
3606
+ deferred_readiness_error: BaseException | None = None
3607
+ try:
3608
+ try:
3609
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
3610
+ listener.set_inheritable(False)
3611
+ listener.bind((_LOOPBACK_HOST, 0))
3612
+ listener.listen(1)
3613
+ authentication_key = secrets.token_bytes(_AUTHENTICATION_BYTES)
3614
+ session_nonce = secrets.token_bytes(_NONCE_BYTES)
3615
+ cancellation_frame = _encode_frame(
3616
+ _API_LAUNCHER_CANCEL,
3617
+ authentication_key=authentication_key,
3618
+ session_nonce=session_nonce,
3619
+ message_nonce=secrets.token_bytes(_NONCE_BYTES),
3620
+ payload=_PID_PAYLOAD.pack(os.getpid()),
3621
+ )
3622
+ child_argv = _public_api_gui_child_argv(preserved_argv)
3623
+ environment = dict(os.environ)
3624
+ environment[_API_LAUNCHER_BOOTSTRAP_ENVIRONMENT_KEY] = (
3625
+ _api_launcher_bootstrap_payload(
3626
+ listener,
3627
+ authentication_key=authentication_key,
3628
+ session_nonce=session_nonce,
3629
+ startup_deadline=startup_deadline,
3630
+ caller_pid=os.getpid(),
3631
+ child_argv=child_argv,
3632
+ database_path=database_path,
3633
+ )
3634
+ )
3635
+ except BaseException as error:
3636
+ if not isinstance(error, Exception):
3637
+ raise
3638
+ outcome = _public_api_failure("public-API launcher setup", error)
3639
+ _publish_outcome_diagnostics(outcome)
3640
+ return outcome.return_code
3641
+
3642
+ try:
3643
+ launcher = _spawn_public_api_launcher(
3644
+ [
3645
+ sys.executable,
3646
+ "-m",
3647
+ "qplot._shutdown_supervisor",
3648
+ _API_LAUNCHER_SENTINEL,
3649
+ ],
3650
+ environment,
3651
+ )
3652
+ except BaseException as error:
3653
+ if not isinstance(error, Exception):
3654
+ raise
3655
+ outcome = _public_api_failure("public-API launcher launch", error)
3656
+ _publish_outcome_diagnostics(outcome)
3657
+ return outcome.return_code
3658
+
3659
+ api_ready_state = _ApiLauncherReadyState()
3660
+ try:
3661
+ channel = _authenticate_public_api_launcher(
3662
+ listener,
3663
+ launcher,
3664
+ authentication_key=authentication_key,
3665
+ session_nonce=session_nonce,
3666
+ startup_deadline=startup_deadline,
3667
+ ready_state=api_ready_state,
3668
+ )
3669
+ ready = True
3670
+ except BaseException as error:
3671
+ if api_ready_state.committed and api_ready_state.channel is not None:
3672
+ channel = api_ready_state.channel
3673
+ ready = True
3674
+ deferred_readiness_error = error
3675
+ while True:
3676
+ try:
3677
+ channel.settimeout(None)
3678
+ break
3679
+ except BaseException:
3680
+ # READY already committed. Preserve the original
3681
+ # exception and absorb every later interruption until
3682
+ # the channel is restored to blocking mode.
3683
+ continue
3684
+ else:
3685
+ if listener is not None:
3686
+ try:
3687
+ listener.close()
3688
+ except OSError:
3689
+ pass
3690
+ listener = None
3691
+ cleanup_diagnostics = _stop_unready_public_api_launcher(
3692
+ launcher,
3693
+ startup_deadline=startup_deadline,
3694
+ )
3695
+ unready_stopped = True
3696
+ if not isinstance(error, Exception):
3697
+ raise
3698
+ outcome = _public_api_failure(
3699
+ "public-API launcher readiness",
3700
+ error,
3701
+ )
3702
+ if cleanup_diagnostics:
3703
+ outcome = _outcome_with_diagnostics(
3704
+ outcome,
3705
+ *cleanup_diagnostics,
3706
+ )
3707
+ _publish_outcome_diagnostics(outcome)
3708
+ return outcome.return_code
3709
+ finally:
3710
+ if listener is not None:
3711
+ while True:
3712
+ try:
3713
+ listener.close()
3714
+ break
3715
+ except OSError:
3716
+ break
3717
+ except BaseException as error:
3718
+ if ready and deferred_readiness_error is None:
3719
+ deferred_readiness_error = error
3720
+ if not ready:
3721
+ raise
3722
+ listener = None
3723
+
3724
+ cancellation_sender = _ApiLauncherCancellationSender(
3725
+ channel,
3726
+ cancellation_frame,
3727
+ )
3728
+ observation = _ApiLauncherResultObservation()
3729
+ result_reader = threading.Thread(
3730
+ target=_observe_public_api_launcher_result,
3731
+ args=(channel, observation, cancellation_sender),
3732
+ kwargs={
3733
+ "authentication_key": authentication_key,
3734
+ "session_nonce": session_nonce,
3735
+ },
3736
+ name="qplot-public-api-result-reader",
3737
+ daemon=True,
3738
+ )
3739
+ pending_control_flow: _PendingCallerControlFlow | None = None
3740
+ interrupt_guard = _CallerCleanupInterruptGuard()
3741
+
3742
+ def retain_post_ready_error(error: BaseException) -> None:
3743
+ nonlocal pending_control_flow
3744
+ if not isinstance(error, Exception):
3745
+ pending_control_flow = _remember_and_guard_caller_control_flow(
3746
+ pending_control_flow,
3747
+ error,
3748
+ interrupt_guard,
3749
+ )
3750
+ elif observation.error is None:
3751
+ observation.error = error
3752
+ cancellation_sender.request()
3753
+
3754
+ if deferred_readiness_error is not None:
3755
+ retain_post_ready_error(deferred_readiness_error)
3756
+
3757
+ while result_reader.ident is None:
3758
+ try:
3759
+ result_reader.start()
3760
+ break
3761
+ except BaseException as error:
3762
+ retain_post_ready_error(error)
3763
+ if result_reader.ident is not None:
3764
+ break
3765
+ result_reader = threading.Thread(
3766
+ target=_observe_public_api_launcher_result,
3767
+ args=(channel, observation, cancellation_sender),
3768
+ kwargs={
3769
+ "authentication_key": authentication_key,
3770
+ "session_nonce": session_nonce,
3771
+ },
3772
+ name="qplot-public-api-result-reader",
3773
+ daemon=True,
3774
+ )
3775
+
3776
+ while not observation.completed.is_set():
3777
+ try:
3778
+ _public_api_caller_cleanup_boundary("result_eof_processing")
3779
+ _wait_for_public_api_result_completion(observation.completed)
3780
+ except BaseException as error:
3781
+ retain_post_ready_error(error)
3782
+
3783
+ while (
3784
+ cancellation_sender.requested and not cancellation_sender.completed.is_set()
3785
+ ):
3786
+ try:
3787
+ cancellation_sender.completed.wait(_OBSERVATION_INTERVAL_SECONDS)
3788
+ except BaseException as error:
3789
+ retain_post_ready_error(error)
3790
+
3791
+ exit_observation_diagnostics: list[str] = []
3792
+
3793
+ def retain_exit_observation_error(error: BaseException) -> None:
3794
+ if not isinstance(error, Exception):
3795
+ retain_post_ready_error(error)
3796
+ return
3797
+ while True:
3798
+ try:
3799
+ diagnostic = _exact_error(
3800
+ "public-API launcher exit observation",
3801
+ error,
3802
+ )
3803
+ if diagnostic not in exit_observation_diagnostics:
3804
+ exit_observation_diagnostics.append(diagnostic)
3805
+ cancellation_sender.request()
3806
+ return
3807
+ except BaseException as later_error:
3808
+ retain_post_ready_error(later_error)
3809
+
3810
+ launcher_exit_observed = False
3811
+ while True:
3812
+ try:
3813
+ _public_api_caller_cleanup_boundary("launcher_exit_wait")
3814
+ _wait_for_public_api_launcher_exit(launcher)
3815
+ launcher_exit_observed = True
3816
+ break
3817
+ except subprocess.TimeoutExpired as error:
3818
+ retain_exit_observation_error(error)
3819
+ continue
3820
+ except BaseException as error:
3821
+ # Retain caller control flow before cancellation. This order
3822
+ # is essential when a second exception lands in request().
3823
+ retain_exit_observation_error(error)
3824
+ continue
3825
+
3826
+ while (
3827
+ cancellation_sender.requested and not cancellation_sender.completed.is_set()
3828
+ ):
3829
+ try:
3830
+ cancellation_sender.completed.wait(_OBSERVATION_INTERVAL_SECONDS)
3831
+ except BaseException as error:
3832
+ retain_post_ready_error(error)
3833
+
3834
+ final_outcome: _SupervisionOutcome | None = None
3835
+ while final_outcome is None:
3836
+ try:
3837
+ _public_api_caller_cleanup_boundary("result_eof_processing")
3838
+ if observation.error is not None:
3839
+ final_outcome = _public_api_failure(
3840
+ "public-API launcher result",
3841
+ observation.error,
3842
+ )
3843
+ elif observation.outcome is None:
3844
+ final_outcome = _SupervisionOutcome(
3845
+ _FORCED_SHUTDOWN_EXIT_CODE,
3846
+ diagnostics=("public-API launcher produced no outcome",),
3847
+ )
3848
+ else:
3849
+ final_outcome = observation.outcome
3850
+ retained_diagnostics = list(exit_observation_diagnostics)
3851
+ if cancellation_sender.diagnostic is not None:
3852
+ retained_diagnostics.append(cancellation_sender.diagnostic)
3853
+ if interrupt_guard.absorbed_sigints:
3854
+ retained_diagnostics.append(
3855
+ "public-API caller cleanup absorbed "
3856
+ f"{interrupt_guard.absorbed_sigints} later SIGINT signal(s)"
3857
+ )
3858
+ if retained_diagnostics:
3859
+ final_outcome = _outcome_with_diagnostics(
3860
+ final_outcome,
3861
+ *retained_diagnostics,
3862
+ )
3863
+ except BaseException as error:
3864
+ final_outcome = None
3865
+ retain_post_ready_error(error)
3866
+
3867
+ while channel is not None:
3868
+ try:
3869
+ _public_api_caller_cleanup_boundary("result_channel_close")
3870
+ channel.close()
3871
+ channel = None
3872
+ except OSError:
3873
+ channel = None
3874
+ except BaseException as error:
3875
+ retain_post_ready_error(error)
3876
+
3877
+ published = False
3878
+ while not published:
3879
+ try:
3880
+ _public_api_caller_cleanup_boundary("final_diagnostic_publication")
3881
+ _publish_outcome_diagnostics(final_outcome)
3882
+ published = True
3883
+ except BaseException as error:
3884
+ retain_post_ready_error(error)
3885
+
3886
+ if pending_control_flow is not None:
3887
+ # Authenticated outcome, launcher-direction EOF, and exact
3888
+ # exit/reap ownership are mandatory before the original caller
3889
+ # exception can cross this boundary.
3890
+ if (
3891
+ observation.outcome is None
3892
+ or not observation.eof_observed
3893
+ or not launcher_exit_observed
3894
+ ):
3895
+ while True:
3896
+ try:
3897
+ time.sleep(_OBSERVATION_INTERVAL_SECONDS)
3898
+ except BaseException:
3899
+ continue
3900
+ while interrupt_guard.active:
3901
+ try:
3902
+ interrupt_guard.restore()
3903
+ except BaseException:
3904
+ continue
3905
+ raise pending_control_flow.error.with_traceback(
3906
+ pending_control_flow.traceback
3907
+ )
3908
+ return final_outcome.return_code
3909
+ finally:
3910
+ if listener is not None:
3911
+ try:
3912
+ listener.close()
3913
+ except OSError:
3914
+ pass
3915
+ if channel is not None:
3916
+ try:
3917
+ channel.close()
3918
+ except OSError:
3919
+ pass
3920
+ if launcher is not None and not ready and not unready_stopped:
3921
+ _stop_unready_public_api_launcher(
3922
+ launcher,
3923
+ startup_deadline=startup_deadline,
3924
+ )
3925
+
3926
+
3927
+ def _run_public_api_launcher() -> None:
3928
+ """Own the GUI tree, publish one result, and exit without Python teardown."""
3929
+
3930
+ try:
3931
+ bootstrap = _api_launcher_bootstrap_from_environment()
3932
+ channel = _connect_public_api_result_channel(bootstrap)
3933
+ except BaseException as error:
3934
+ _report_launcher_failure(
3935
+ _exact_error("public-API launcher bootstrap/readiness", error)
3936
+ )
3937
+ os._exit(_FORCED_SHUTDOWN_EXIT_CODE)
3938
+
3939
+ control_state = _ApiCallerControlState()
3940
+ try:
3941
+ control_reader = threading.Thread(
3942
+ target=_monitor_public_api_caller,
3943
+ args=(channel, bootstrap, control_state),
3944
+ name="qplot-public-api-control-reader",
3945
+ daemon=True,
3946
+ )
3947
+ control_reader.start()
3948
+ except BaseException as error:
3949
+ _report_launcher_failure(
3950
+ _exact_error("public-API caller control monitor startup", error)
3951
+ )
3952
+ os._exit(_FORCED_SHUTDOWN_EXIT_CODE)
3953
+
3954
+ try:
3955
+ try:
3956
+ outcome = _supervise_child_outcome(
3957
+ bootstrap.child_argv,
3958
+ env=os.environ,
3959
+ database_path=bootstrap.database_path,
3960
+ _cancellation_event=control_state.cancellation_event,
3961
+ )
3962
+ except BaseException as error:
3963
+ outcome = _public_api_failure(
3964
+ "public-API launcher supervision",
3965
+ error,
3966
+ )
3967
+ # Returning from supervision proves the retained GUI/helper tree is
3968
+ # gone. Seal the control direction before any result I/O so a late or
3969
+ # duplicate record cannot alter an already-resolved outcome.
3970
+ control_state.commit_final_outcome()
3971
+ cancellation_diagnostic = control_state.cancellation_diagnostic
3972
+ if cancellation_diagnostic is not None:
3973
+ outcome = _outcome_with_diagnostics(
3974
+ outcome,
3975
+ cancellation_diagnostic,
3976
+ )
3977
+ try:
3978
+ encoded_outcome = _encode_api_launcher_outcome(
3979
+ outcome,
3980
+ authentication_key=bootstrap.authentication_key,
3981
+ session_nonce=bootstrap.session_nonce,
3982
+ )
3983
+ except BaseException as error:
3984
+ fallback = _public_api_failure(
3985
+ "public-API launcher outcome encoding",
3986
+ error,
3987
+ )
3988
+ encoded_outcome = _encode_api_launcher_outcome(
3989
+ fallback,
3990
+ authentication_key=bootstrap.authentication_key,
3991
+ session_nonce=bootstrap.session_nonce,
3992
+ )
3993
+ channel.settimeout(_API_RESULT_IO_TIMEOUT_SECONDS)
3994
+ channel.sendall(encoded_outcome)
3995
+ # Commit the complete result and FIN to Winsock before the immediate
3996
+ # hard exit. Otherwise ExitProcess can reset the TCP connection and
3997
+ # discard a frame that ``sendall`` had only queued locally. The
3998
+ # caller still waits for both this EOF and the retained launcher
3999
+ # process handle, so EOF is not treated as process termination alone.
4000
+ channel.shutdown(socket.SHUT_WR)
4001
+ except BaseException as error:
4002
+ _report_launcher_failure(
4003
+ _exact_error("public-API launcher result publication", error)
4004
+ )
4005
+ os._exit(_FORCED_SHUTDOWN_EXIT_CODE)
4006
+
4007
+ # Keep the sole result endpoint live until process death. The caller can
4008
+ # therefore treat EOF as proof that the dedicated launcher is gone even
4009
+ # when an unrelated waitpid(-1) thread has already collected its status.
4010
+ os._exit(0)
4011
+
4012
+
4013
+ def _run_gui_child() -> int:
4014
+ if len(sys.argv) < 3 or sys.argv[1] != _GUI_CHILD_SENTINEL:
4015
+ _report_launcher_failure("invalid private GUI child invocation")
4016
+ return _FORCED_SHUTDOWN_EXIT_CODE
4017
+ preserved_argv = list(sys.argv[2:])
4018
+ try:
4019
+ client = ShutdownSupervisorClient.from_environment().connect()
4020
+ except BaseException as error:
4021
+ _report_launcher_failure(
4022
+ _exact_error("shutdown launcher GUI child bootstrap", error)
4023
+ )
4024
+ return _FORCED_SHUTDOWN_EXIT_CODE
4025
+
4026
+ sys.argv[:] = preserved_argv
4027
+ try:
4028
+ from qplot.__main__ import _run_gui
4029
+
4030
+ return int(
4031
+ _run_gui(
4032
+ return_objects=False,
4033
+ database_path=client.database_path,
4034
+ shutdown_supervisor_client=client,
4035
+ )
4036
+ )
4037
+ finally:
4038
+ client.close()
4039
+
4040
+
4041
+ def _module_main() -> int:
4042
+ if len(sys.argv) >= 2 and sys.argv[1] == _API_LAUNCHER_SENTINEL:
4043
+ _run_public_api_launcher()
4044
+ raise AssertionError("public-API launcher hard exit returned")
4045
+ return _run_gui_child()
4046
+
4047
+
4048
+ if __name__ == "__main__":
4049
+ raise SystemExit(_module_main())