qplotter 1.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +67 -0
- qplot/_build_info.json +1 -0
- qplot/_metadata.py +75 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +578 -0
- qplot/configuration/config_schema.json +357 -0
- qplot/configuration/fonts.py +3 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +592 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2340 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2566 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +148 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +876 -0
- qplot/datahandling/trusted_heatmap.py +246 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1919 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1804 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +123 -0
- qplot/tools/general.py +139 -0
- qplot/tools/heatmap_geometry.py +391 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +650 -0
- qplot/tools/sample_statistics.py +111 -0
- qplot/tools/worker.py +2838 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4933 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_display_precision.py +48 -0
- qplot/windows/_dragdrop.py +70 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +210 -0
- qplot/windows/_mixed_appearance.py +303 -0
- qplot/windows/_mixed_plots.py +461 -0
- qplot/windows/_native_averaging.py +323 -0
- qplot/windows/_native_transforms.py +551 -0
- qplot/windows/_plot1d_snap.py +612 -0
- qplot/windows/_plot1d_traces.py +2236 -0
- qplot/windows/_plot2d_colorbar.py +1127 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2291 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +2082 -0
- qplot/windows/_plot_actions.py +2703 -0
- qplot/windows/_plot_annotation.py +186 -0
- qplot/windows/_plot_appearance.py +283 -0
- qplot/windows/_plot_axis_scaling.py +1521 -0
- qplot/windows/_plot_data_viewbox.py +94 -0
- qplot/windows/_plot_drop_axes.py +224 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +112 -0
- qplot/windows/_plot_marquee.py +659 -0
- qplot/windows/_plot_refresh.py +950 -0
- qplot/windows/_plot_source_promotion.py +178 -0
- qplot/windows/_plot_state.py +237 -0
- qplot/windows/_plot_status.py +115 -0
- qplot/windows/_plot_tiling.py +35 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +724 -0
- qplot/windows/_refresh_interval.py +172 -0
- qplot/windows/_run_controls.py +789 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +532 -0
- qplot/windows/_subplots/subplot2d.py +919 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +414 -0
- qplot/windows/main.py +1632 -0
- qplot/windows/plot1d.py +313 -0
- qplot/windows/plot2d.py +1935 -0
- qplotter-1.6.0.dist-info/METADATA +322 -0
- qplotter-1.6.0.dist-info/RECORD +130 -0
- qplotter-1.6.0.dist-info/WHEEL +5 -0
- qplotter-1.6.0.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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())
|