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,812 @@
|
|
|
1
|
+
"""Spawn-safe child entry point for isolated trusted live-database reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import struct
|
|
8
|
+
import threading
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from multiprocessing.connection import Connection
|
|
11
|
+
from typing import Any, NoReturn
|
|
12
|
+
|
|
13
|
+
from qplot.datahandling._trusted_live_protocol import (
|
|
14
|
+
MAX_CONTROL_BYTES,
|
|
15
|
+
MAX_REPLY_BYTES,
|
|
16
|
+
MAX_REQUEST_BYTES,
|
|
17
|
+
PROTOCOL_VERSION,
|
|
18
|
+
TrustedLiveProtocolValidationError,
|
|
19
|
+
decode_control_frame,
|
|
20
|
+
decode_job_request,
|
|
21
|
+
decode_request_frame,
|
|
22
|
+
decode_startup_request,
|
|
23
|
+
encode_error_reply,
|
|
24
|
+
encode_query_results,
|
|
25
|
+
encode_source_identity,
|
|
26
|
+
encode_success_reply,
|
|
27
|
+
error_code_is_terminal,
|
|
28
|
+
validate_cancel,
|
|
29
|
+
validate_shutdown,
|
|
30
|
+
)
|
|
31
|
+
from qplot.datahandling.trusted_live import (
|
|
32
|
+
TrustedLiveBusyTimeoutError,
|
|
33
|
+
TrustedLiveCancelledError,
|
|
34
|
+
TrustedLiveCleanupError,
|
|
35
|
+
TrustedLiveDeadlineExceededError,
|
|
36
|
+
TrustedLiveInvalidDatabaseError,
|
|
37
|
+
TrustedLiveQueryError,
|
|
38
|
+
TrustedLiveReader,
|
|
39
|
+
TrustedLiveReaderClosedError,
|
|
40
|
+
TrustedLiveReaderError,
|
|
41
|
+
TrustedLiveReaderThreadError,
|
|
42
|
+
TrustedLiveReaderUnavailableError,
|
|
43
|
+
TrustedLiveResultLimitError,
|
|
44
|
+
TrustedLiveSourceChangedError,
|
|
45
|
+
TrustedLiveSourceIOError,
|
|
46
|
+
TrustedLiveSqlRejectedError,
|
|
47
|
+
TrustedLiveTransactionError,
|
|
48
|
+
TrustedLiveUnsupportedSourceError,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
_CONTROL_POLL_SECONDS = 0.05
|
|
52
|
+
_COMMAND_POLL_SECONDS = 0.05
|
|
53
|
+
_EXIT_PARENT_ENDPOINT_LOST = 86
|
|
54
|
+
_EXIT_TEST_CRASH = 87
|
|
55
|
+
_TEST_FAULTS = frozenset(
|
|
56
|
+
{
|
|
57
|
+
"crash_before_reply",
|
|
58
|
+
"hang_before_operation",
|
|
59
|
+
"hang_startup",
|
|
60
|
+
"hang_close",
|
|
61
|
+
"cleanup_quarantine",
|
|
62
|
+
"malformed_reply",
|
|
63
|
+
"oversized_reply",
|
|
64
|
+
"partial_job_body",
|
|
65
|
+
"partial_job_header",
|
|
66
|
+
"partial_shutdown_body",
|
|
67
|
+
"partial_shutdown_header",
|
|
68
|
+
"partial_startup_body",
|
|
69
|
+
"partial_startup_header",
|
|
70
|
+
"statement_limit_install",
|
|
71
|
+
"statement_limit_restore",
|
|
72
|
+
"statement_limit_verify",
|
|
73
|
+
"stale_generation_reply",
|
|
74
|
+
"stale_session_reply",
|
|
75
|
+
"wrong_version_reply",
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(slots=True)
|
|
81
|
+
class _ChildControlState:
|
|
82
|
+
session: str
|
|
83
|
+
orphan_grace_seconds: float
|
|
84
|
+
expected_generation: int
|
|
85
|
+
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
86
|
+
stop_event: threading.Event = field(default_factory=threading.Event)
|
|
87
|
+
main_done: threading.Event = field(default_factory=threading.Event)
|
|
88
|
+
parent_lost: bool = False
|
|
89
|
+
fatal_protocol_error: str | None = None
|
|
90
|
+
active_generation: int | None = None
|
|
91
|
+
last_completed_generation: int | None = None
|
|
92
|
+
last_cancelled_generation: int | None = None
|
|
93
|
+
pending_cancel_generation: int | None = None
|
|
94
|
+
cancel_event: threading.Event | None = None
|
|
95
|
+
reader: TrustedLiveReader | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _notify_test(test_notify: Connection | None, value: bytes) -> None:
|
|
99
|
+
if test_notify is None:
|
|
100
|
+
return
|
|
101
|
+
try:
|
|
102
|
+
test_notify.send_bytes(value)
|
|
103
|
+
except (BrokenPipeError, EOFError, OSError):
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _hard_exit_unless_main_finishes(
|
|
108
|
+
state: _ChildControlState,
|
|
109
|
+
exit_code: int,
|
|
110
|
+
) -> NoReturn | None:
|
|
111
|
+
if state.main_done.wait(state.orphan_grace_seconds):
|
|
112
|
+
return None
|
|
113
|
+
os._exit(exit_code)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _interrupt_active_locked(state: _ChildControlState) -> None:
|
|
117
|
+
cancel_event = state.cancel_event
|
|
118
|
+
reader = state.reader
|
|
119
|
+
if cancel_event is not None:
|
|
120
|
+
cancel_event.set()
|
|
121
|
+
if reader is not None and state.active_generation is not None:
|
|
122
|
+
# TrustedLiveReader.interrupt() is the sole Stage-2 method documented
|
|
123
|
+
# as cross-thread safe. Keeping the state lock held prevents this
|
|
124
|
+
# generation from being cleared and replaced before the call.
|
|
125
|
+
reader.interrupt()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _mark_parent_lost(state: _ChildControlState) -> None:
|
|
129
|
+
with state.lock:
|
|
130
|
+
state.parent_lost = True
|
|
131
|
+
_interrupt_active_locked(state)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _mark_protocol_failure(state: _ChildControlState, message: str) -> None:
|
|
135
|
+
with state.lock:
|
|
136
|
+
if state.fatal_protocol_error is None:
|
|
137
|
+
state.fatal_protocol_error = message
|
|
138
|
+
_interrupt_active_locked(state)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _control_loop(control_receive: Connection, state: _ChildControlState) -> None:
|
|
142
|
+
"""Own the cancellation endpoint and only interrupt the reader."""
|
|
143
|
+
|
|
144
|
+
hard_exit_code: int | None = None
|
|
145
|
+
try:
|
|
146
|
+
while not state.stop_event.is_set():
|
|
147
|
+
try:
|
|
148
|
+
ready = control_receive.poll(_CONTROL_POLL_SECONDS)
|
|
149
|
+
except (OSError, ValueError):
|
|
150
|
+
_mark_parent_lost(state)
|
|
151
|
+
hard_exit_code = _EXIT_PARENT_ENDPOINT_LOST
|
|
152
|
+
break
|
|
153
|
+
if not ready:
|
|
154
|
+
continue
|
|
155
|
+
try:
|
|
156
|
+
frame = control_receive.recv_bytes(MAX_CONTROL_BYTES)
|
|
157
|
+
except EOFError:
|
|
158
|
+
_mark_parent_lost(state)
|
|
159
|
+
hard_exit_code = _EXIT_PARENT_ENDPOINT_LOST
|
|
160
|
+
break
|
|
161
|
+
except (OSError, ValueError):
|
|
162
|
+
_mark_protocol_failure(
|
|
163
|
+
state,
|
|
164
|
+
"The helper received malformed or oversized control IPC.",
|
|
165
|
+
)
|
|
166
|
+
hard_exit_code = _EXIT_PARENT_ENDPOINT_LOST
|
|
167
|
+
break
|
|
168
|
+
try:
|
|
169
|
+
envelope = decode_control_frame(frame)
|
|
170
|
+
validate_cancel(envelope)
|
|
171
|
+
except TrustedLiveProtocolValidationError as error:
|
|
172
|
+
_mark_protocol_failure(state, str(error))
|
|
173
|
+
hard_exit_code = _EXIT_PARENT_ENDPOINT_LOST
|
|
174
|
+
break
|
|
175
|
+
with state.lock:
|
|
176
|
+
if envelope.session != state.session:
|
|
177
|
+
state.fatal_protocol_error = (
|
|
178
|
+
"A cancellation targeted the wrong process incarnation."
|
|
179
|
+
)
|
|
180
|
+
_interrupt_active_locked(state)
|
|
181
|
+
hard_exit_code = _EXIT_PARENT_ENDPOINT_LOST
|
|
182
|
+
break
|
|
183
|
+
cancellation_is_new = (
|
|
184
|
+
state.last_cancelled_generation is None
|
|
185
|
+
or envelope.generation > state.last_cancelled_generation
|
|
186
|
+
)
|
|
187
|
+
if (
|
|
188
|
+
state.active_generation == envelope.generation
|
|
189
|
+
and state.cancel_event is not None
|
|
190
|
+
and cancellation_is_new
|
|
191
|
+
):
|
|
192
|
+
state.last_cancelled_generation = envelope.generation
|
|
193
|
+
_interrupt_active_locked(state)
|
|
194
|
+
continue
|
|
195
|
+
if (
|
|
196
|
+
state.last_completed_generation == envelope.generation
|
|
197
|
+
and cancellation_is_new
|
|
198
|
+
):
|
|
199
|
+
# The operation may finish after the parent decides to
|
|
200
|
+
# cancel but before this control thread receives that
|
|
201
|
+
# exact-generation frame. It may even arrive after the
|
|
202
|
+
# following operation starts because commands and control
|
|
203
|
+
# use separate pipes. Accept it once as a no-op; never
|
|
204
|
+
# interrupt the newer generation.
|
|
205
|
+
state.last_cancelled_generation = envelope.generation
|
|
206
|
+
continue
|
|
207
|
+
if (
|
|
208
|
+
state.active_generation is None
|
|
209
|
+
and envelope.generation == state.expected_generation
|
|
210
|
+
and state.pending_cancel_generation is None
|
|
211
|
+
and cancellation_is_new
|
|
212
|
+
):
|
|
213
|
+
# Command and control pipes are intentionally separate. A
|
|
214
|
+
# cancel sent immediately after submit may reach this
|
|
215
|
+
# thread before the main thread receives that exact job.
|
|
216
|
+
state.pending_cancel_generation = envelope.generation
|
|
217
|
+
state.last_cancelled_generation = envelope.generation
|
|
218
|
+
continue
|
|
219
|
+
else:
|
|
220
|
+
state.fatal_protocol_error = (
|
|
221
|
+
"A stale, duplicate, or out-of-order cancellation was rejected."
|
|
222
|
+
)
|
|
223
|
+
_interrupt_active_locked(state)
|
|
224
|
+
hard_exit_code = _EXIT_PARENT_ENDPOINT_LOST
|
|
225
|
+
break
|
|
226
|
+
finally:
|
|
227
|
+
try:
|
|
228
|
+
control_receive.close()
|
|
229
|
+
except OSError:
|
|
230
|
+
pass
|
|
231
|
+
if hard_exit_code is not None:
|
|
232
|
+
_hard_exit_unless_main_finishes(state, hard_exit_code)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _error_code(error: BaseException) -> str:
|
|
236
|
+
if isinstance(error, TrustedLiveUnsupportedSourceError):
|
|
237
|
+
return "unsupported_source"
|
|
238
|
+
if isinstance(error, TrustedLiveSourceChangedError):
|
|
239
|
+
return "source_changed"
|
|
240
|
+
if isinstance(error, TrustedLiveSourceIOError):
|
|
241
|
+
return "source_io"
|
|
242
|
+
if isinstance(error, TrustedLiveSqlRejectedError):
|
|
243
|
+
return "sql_rejected"
|
|
244
|
+
if isinstance(error, TrustedLiveResultLimitError):
|
|
245
|
+
return "result_limit"
|
|
246
|
+
if isinstance(error, TrustedLiveBusyTimeoutError):
|
|
247
|
+
return "busy_timeout"
|
|
248
|
+
if isinstance(error, TrustedLiveCancelledError):
|
|
249
|
+
return "cancelled"
|
|
250
|
+
if isinstance(error, TrustedLiveDeadlineExceededError):
|
|
251
|
+
return "operation_deadline"
|
|
252
|
+
if isinstance(error, TrustedLiveInvalidDatabaseError):
|
|
253
|
+
return "invalid_database"
|
|
254
|
+
if isinstance(error, TrustedLiveCleanupError):
|
|
255
|
+
return "cleanup_quarantine"
|
|
256
|
+
if isinstance(error, TrustedLiveReaderClosedError):
|
|
257
|
+
return "reader_closed"
|
|
258
|
+
if isinstance(error, TrustedLiveReaderThreadError):
|
|
259
|
+
return "reader_thread"
|
|
260
|
+
if isinstance(error, TrustedLiveTransactionError):
|
|
261
|
+
return "transaction"
|
|
262
|
+
if isinstance(error, TrustedLiveReaderUnavailableError):
|
|
263
|
+
return "reader_unavailable"
|
|
264
|
+
if isinstance(error, TrustedLiveQueryError):
|
|
265
|
+
return "query_failed"
|
|
266
|
+
if isinstance(error, TrustedLiveReaderError):
|
|
267
|
+
return "reader_error"
|
|
268
|
+
if isinstance(error, TrustedLiveProtocolValidationError):
|
|
269
|
+
return "protocol_error"
|
|
270
|
+
return "internal_error"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _error_message(error: BaseException) -> str:
|
|
274
|
+
if isinstance(error, (TrustedLiveReaderError, TrustedLiveProtocolValidationError)):
|
|
275
|
+
return str(error) or type(error).__name__
|
|
276
|
+
return f"The trusted helper failed unexpectedly ({type(error).__name__})."
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _send_reply(connection: Connection, frame: bytes) -> bool:
|
|
280
|
+
try:
|
|
281
|
+
connection.send_bytes(frame)
|
|
282
|
+
except (BrokenPipeError, EOFError, OSError, ValueError):
|
|
283
|
+
return False
|
|
284
|
+
return True
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _send_partial_reply_and_hang(
|
|
288
|
+
connection: Connection,
|
|
289
|
+
frame: bytes,
|
|
290
|
+
*,
|
|
291
|
+
partial_body: bool,
|
|
292
|
+
test_notify: Connection | None,
|
|
293
|
+
marker: bytes,
|
|
294
|
+
) -> NoReturn:
|
|
295
|
+
"""Private deterministic stream-frame fault used only by regressions.
|
|
296
|
+
|
|
297
|
+
POSIX ``multiprocessing.Connection`` pipes use a four-byte length header,
|
|
298
|
+
so writing below that layer can strand ``recv_bytes`` in either its header
|
|
299
|
+
or body read. Windows anonymous multiprocessing pipes are message based
|
|
300
|
+
and expose no separate length header; holding their reply endpoint open
|
|
301
|
+
without sending supplies the equivalent permanently incomplete receive for
|
|
302
|
+
the production lifecycle test.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
if os.name != "nt":
|
|
306
|
+
header = struct.pack("!i", len(frame))
|
|
307
|
+
fragment = (
|
|
308
|
+
header + frame[: max(1, len(frame) // 2)] if partial_body else header[:2]
|
|
309
|
+
)
|
|
310
|
+
raw_send = getattr(connection, "_send", None)
|
|
311
|
+
if not callable(raw_send):
|
|
312
|
+
raise RuntimeError("The private partial-frame injector is unavailable.")
|
|
313
|
+
raw_send(fragment)
|
|
314
|
+
_hang_forever(test_notify, marker)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _faulted_reply(
|
|
318
|
+
frame: bytes,
|
|
319
|
+
*,
|
|
320
|
+
fault: str | None,
|
|
321
|
+
session: str,
|
|
322
|
+
generation: int,
|
|
323
|
+
) -> bytes:
|
|
324
|
+
if fault == "malformed_reply":
|
|
325
|
+
return b"{"
|
|
326
|
+
if fault == "oversized_reply":
|
|
327
|
+
return b"x" * (MAX_REPLY_BYTES + 1)
|
|
328
|
+
if fault in {
|
|
329
|
+
"stale_generation_reply",
|
|
330
|
+
"stale_session_reply",
|
|
331
|
+
"wrong_version_reply",
|
|
332
|
+
}:
|
|
333
|
+
parsed = json.loads(frame)
|
|
334
|
+
if fault == "stale_generation_reply":
|
|
335
|
+
parsed["generation"] = max(0, generation - 1)
|
|
336
|
+
elif fault == "stale_session_reply":
|
|
337
|
+
parsed["session"] = "0" * 32 if session != "0" * 32 else "1" * 32
|
|
338
|
+
else:
|
|
339
|
+
parsed["protocol_version"] = PROTOCOL_VERSION + 1
|
|
340
|
+
return json.dumps(
|
|
341
|
+
parsed,
|
|
342
|
+
ensure_ascii=False,
|
|
343
|
+
separators=(",", ":"),
|
|
344
|
+
sort_keys=True,
|
|
345
|
+
).encode("utf-8")
|
|
346
|
+
return frame
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _fatal_state(state: _ChildControlState) -> tuple[bool, str | None]:
|
|
350
|
+
with state.lock:
|
|
351
|
+
return state.parent_lost, state.fatal_protocol_error
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _send_protocol_failure(
|
|
355
|
+
reply_send: Connection,
|
|
356
|
+
state: _ChildControlState,
|
|
357
|
+
message: str,
|
|
358
|
+
generation: int,
|
|
359
|
+
) -> None:
|
|
360
|
+
frame = encode_error_reply(
|
|
361
|
+
state.session,
|
|
362
|
+
max(0, generation),
|
|
363
|
+
"protocol",
|
|
364
|
+
"protocol_error",
|
|
365
|
+
message,
|
|
366
|
+
)
|
|
367
|
+
_send_reply(reply_send, frame)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _hang_forever(test_notify: Connection | None, marker: bytes) -> NoReturn:
|
|
371
|
+
_notify_test(test_notify, marker)
|
|
372
|
+
blocker = threading.Event()
|
|
373
|
+
while True:
|
|
374
|
+
blocker.wait(3_600.0)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def trusted_live_helper_main(
|
|
378
|
+
command_receive: Connection,
|
|
379
|
+
reply_send: Connection,
|
|
380
|
+
control_receive: Connection,
|
|
381
|
+
startup_frame: bytes,
|
|
382
|
+
test_fault: str | None = None,
|
|
383
|
+
test_notify: Connection | None = None,
|
|
384
|
+
) -> None:
|
|
385
|
+
"""Own one Stage-2 reader entirely on this spawned process' main thread."""
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
startup_request = decode_request_frame(startup_frame)
|
|
389
|
+
except TrustedLiveProtocolValidationError:
|
|
390
|
+
for connection in (
|
|
391
|
+
command_receive,
|
|
392
|
+
reply_send,
|
|
393
|
+
control_receive,
|
|
394
|
+
test_notify,
|
|
395
|
+
):
|
|
396
|
+
if connection is not None:
|
|
397
|
+
try:
|
|
398
|
+
connection.close()
|
|
399
|
+
except OSError:
|
|
400
|
+
pass
|
|
401
|
+
return
|
|
402
|
+
try:
|
|
403
|
+
(
|
|
404
|
+
first_generation,
|
|
405
|
+
database_path,
|
|
406
|
+
expected_instance,
|
|
407
|
+
busy_timeout_ms,
|
|
408
|
+
operation_timeout_ms,
|
|
409
|
+
orphan_grace_ms,
|
|
410
|
+
) = decode_startup_request(startup_request)
|
|
411
|
+
except TrustedLiveProtocolValidationError as error:
|
|
412
|
+
frame = encode_error_reply(
|
|
413
|
+
startup_request.session,
|
|
414
|
+
0,
|
|
415
|
+
"protocol",
|
|
416
|
+
"protocol_error",
|
|
417
|
+
str(error),
|
|
418
|
+
)
|
|
419
|
+
_send_reply(reply_send, frame)
|
|
420
|
+
for connection in (
|
|
421
|
+
command_receive,
|
|
422
|
+
reply_send,
|
|
423
|
+
control_receive,
|
|
424
|
+
test_notify,
|
|
425
|
+
):
|
|
426
|
+
if connection is not None:
|
|
427
|
+
try:
|
|
428
|
+
connection.close()
|
|
429
|
+
except OSError:
|
|
430
|
+
pass
|
|
431
|
+
return
|
|
432
|
+
session = startup_request.session
|
|
433
|
+
state = _ChildControlState(
|
|
434
|
+
session,
|
|
435
|
+
orphan_grace_ms / 1_000.0,
|
|
436
|
+
first_generation,
|
|
437
|
+
)
|
|
438
|
+
reader: TrustedLiveReader | None = None
|
|
439
|
+
control_thread = threading.Thread(
|
|
440
|
+
target=_control_loop,
|
|
441
|
+
args=(control_receive, state),
|
|
442
|
+
name="qplot-trusted-reader-control",
|
|
443
|
+
daemon=True,
|
|
444
|
+
)
|
|
445
|
+
control_thread.start()
|
|
446
|
+
next_generation = first_generation
|
|
447
|
+
last_generation = max(0, first_generation - 1)
|
|
448
|
+
try:
|
|
449
|
+
if test_fault is not None and test_fault not in _TEST_FAULTS:
|
|
450
|
+
raise TrustedLiveProtocolValidationError(
|
|
451
|
+
"The private helper test bootstrap option is invalid."
|
|
452
|
+
)
|
|
453
|
+
if test_fault == "hang_startup":
|
|
454
|
+
_hang_forever(test_notify, b"startup_hang")
|
|
455
|
+
cleanup_fault = "base_close" if test_fault == "cleanup_quarantine" else None
|
|
456
|
+
statement_limit_fault = (
|
|
457
|
+
test_fault
|
|
458
|
+
if test_fault is not None and test_fault.startswith("statement_limit_")
|
|
459
|
+
else None
|
|
460
|
+
)
|
|
461
|
+
try:
|
|
462
|
+
reader = TrustedLiveReader.open(
|
|
463
|
+
database_path,
|
|
464
|
+
expected_database_instance=expected_instance,
|
|
465
|
+
busy_timeout_ms=busy_timeout_ms,
|
|
466
|
+
operation_timeout_seconds=operation_timeout_ms / 1_000.0,
|
|
467
|
+
_test_cleanup_fault=cleanup_fault,
|
|
468
|
+
_test_statement_limit_fault=statement_limit_fault,
|
|
469
|
+
)
|
|
470
|
+
except BaseException as error:
|
|
471
|
+
code = _error_code(error)
|
|
472
|
+
frame = encode_error_reply(
|
|
473
|
+
session,
|
|
474
|
+
0,
|
|
475
|
+
"startup",
|
|
476
|
+
code,
|
|
477
|
+
_error_message(error),
|
|
478
|
+
)
|
|
479
|
+
_send_reply(reply_send, frame)
|
|
480
|
+
return
|
|
481
|
+
with state.lock:
|
|
482
|
+
state.reader = reader
|
|
483
|
+
parent_lost, fatal_error = _fatal_state(state)
|
|
484
|
+
if parent_lost:
|
|
485
|
+
return
|
|
486
|
+
if fatal_error is not None:
|
|
487
|
+
_send_protocol_failure(reply_send, state, fatal_error, 0)
|
|
488
|
+
return
|
|
489
|
+
startup_frame = encode_success_reply(
|
|
490
|
+
session,
|
|
491
|
+
0,
|
|
492
|
+
"startup",
|
|
493
|
+
{"source": encode_source_identity(reader.source_identity)},
|
|
494
|
+
)
|
|
495
|
+
if test_fault in {"partial_startup_header", "partial_startup_body"}:
|
|
496
|
+
_send_partial_reply_and_hang(
|
|
497
|
+
reply_send,
|
|
498
|
+
startup_frame,
|
|
499
|
+
partial_body=test_fault == "partial_startup_body",
|
|
500
|
+
test_notify=test_notify,
|
|
501
|
+
marker=(
|
|
502
|
+
b"partial_startup_body_sent"
|
|
503
|
+
if test_fault == "partial_startup_body"
|
|
504
|
+
else b"partial_startup_header_sent"
|
|
505
|
+
),
|
|
506
|
+
)
|
|
507
|
+
if not _send_reply(reply_send, startup_frame):
|
|
508
|
+
_mark_parent_lost(state)
|
|
509
|
+
return
|
|
510
|
+
|
|
511
|
+
while True:
|
|
512
|
+
parent_lost, fatal_error = _fatal_state(state)
|
|
513
|
+
if parent_lost:
|
|
514
|
+
break
|
|
515
|
+
if fatal_error is not None:
|
|
516
|
+
_send_protocol_failure(
|
|
517
|
+
reply_send,
|
|
518
|
+
state,
|
|
519
|
+
fatal_error,
|
|
520
|
+
last_generation,
|
|
521
|
+
)
|
|
522
|
+
break
|
|
523
|
+
try:
|
|
524
|
+
ready = command_receive.poll(_COMMAND_POLL_SECONDS)
|
|
525
|
+
except (OSError, ValueError):
|
|
526
|
+
_mark_parent_lost(state)
|
|
527
|
+
break
|
|
528
|
+
if not ready:
|
|
529
|
+
continue
|
|
530
|
+
try:
|
|
531
|
+
request_frame = command_receive.recv_bytes(MAX_REQUEST_BYTES)
|
|
532
|
+
except EOFError:
|
|
533
|
+
_mark_parent_lost(state)
|
|
534
|
+
break
|
|
535
|
+
except (OSError, ValueError):
|
|
536
|
+
_send_protocol_failure(
|
|
537
|
+
reply_send,
|
|
538
|
+
state,
|
|
539
|
+
"The helper received malformed or oversized command IPC.",
|
|
540
|
+
last_generation,
|
|
541
|
+
)
|
|
542
|
+
break
|
|
543
|
+
try:
|
|
544
|
+
request = decode_request_frame(request_frame)
|
|
545
|
+
if request.session != session:
|
|
546
|
+
raise TrustedLiveProtocolValidationError(
|
|
547
|
+
"A command targeted the wrong process incarnation."
|
|
548
|
+
)
|
|
549
|
+
if request.generation != next_generation:
|
|
550
|
+
raise TrustedLiveProtocolValidationError(
|
|
551
|
+
"A duplicate or out-of-order job generation was rejected."
|
|
552
|
+
)
|
|
553
|
+
with state.lock:
|
|
554
|
+
if state.expected_generation != request.generation:
|
|
555
|
+
raise TrustedLiveProtocolValidationError(
|
|
556
|
+
"Command and control generations became inconsistent."
|
|
557
|
+
)
|
|
558
|
+
next_generation += 1
|
|
559
|
+
last_generation = request.generation
|
|
560
|
+
except TrustedLiveProtocolValidationError as error:
|
|
561
|
+
_send_protocol_failure(reply_send, state, str(error), last_generation)
|
|
562
|
+
break
|
|
563
|
+
|
|
564
|
+
if request.operation == "shutdown":
|
|
565
|
+
try:
|
|
566
|
+
validate_shutdown(request)
|
|
567
|
+
with state.lock:
|
|
568
|
+
if state.pending_cancel_generation == request.generation:
|
|
569
|
+
raise TrustedLiveProtocolValidationError(
|
|
570
|
+
"Cancellation cannot target a shutdown command."
|
|
571
|
+
)
|
|
572
|
+
state.expected_generation = request.generation + 1
|
|
573
|
+
if test_fault == "hang_close":
|
|
574
|
+
_hang_forever(test_notify, b"shutdown_hang")
|
|
575
|
+
closing_reader = reader
|
|
576
|
+
if closing_reader is None:
|
|
577
|
+
raise TrustedLiveReaderClosedError(
|
|
578
|
+
"The helper reader was already closed before shutdown."
|
|
579
|
+
)
|
|
580
|
+
with state.lock:
|
|
581
|
+
state.reader = None
|
|
582
|
+
closing_reader.close()
|
|
583
|
+
reader = None
|
|
584
|
+
shutdown_frame = encode_success_reply(
|
|
585
|
+
session,
|
|
586
|
+
request.generation,
|
|
587
|
+
"shutdown",
|
|
588
|
+
)
|
|
589
|
+
except BaseException as error:
|
|
590
|
+
code = _error_code(error)
|
|
591
|
+
shutdown_frame = encode_error_reply(
|
|
592
|
+
session,
|
|
593
|
+
request.generation,
|
|
594
|
+
"shutdown",
|
|
595
|
+
code,
|
|
596
|
+
_error_message(error),
|
|
597
|
+
)
|
|
598
|
+
reader = None
|
|
599
|
+
if test_fault in {
|
|
600
|
+
"partial_shutdown_header",
|
|
601
|
+
"partial_shutdown_body",
|
|
602
|
+
}:
|
|
603
|
+
_send_partial_reply_and_hang(
|
|
604
|
+
reply_send,
|
|
605
|
+
shutdown_frame,
|
|
606
|
+
partial_body=test_fault == "partial_shutdown_body",
|
|
607
|
+
test_notify=test_notify,
|
|
608
|
+
marker=(
|
|
609
|
+
b"partial_shutdown_body_sent"
|
|
610
|
+
if test_fault == "partial_shutdown_body"
|
|
611
|
+
else b"partial_shutdown_header_sent"
|
|
612
|
+
),
|
|
613
|
+
)
|
|
614
|
+
_send_reply(reply_send, shutdown_frame)
|
|
615
|
+
break
|
|
616
|
+
|
|
617
|
+
try:
|
|
618
|
+
queries, timeout_ms = decode_job_request(request)
|
|
619
|
+
except TrustedLiveProtocolValidationError as error:
|
|
620
|
+
_send_protocol_failure(reply_send, state, str(error), last_generation)
|
|
621
|
+
break
|
|
622
|
+
cancel_event = threading.Event()
|
|
623
|
+
with state.lock:
|
|
624
|
+
if state.parent_lost or state.fatal_protocol_error is not None:
|
|
625
|
+
continue
|
|
626
|
+
if state.expected_generation != request.generation:
|
|
627
|
+
state.fatal_protocol_error = (
|
|
628
|
+
"The active command generation changed before publication."
|
|
629
|
+
)
|
|
630
|
+
continue
|
|
631
|
+
state.expected_generation = request.generation + 1
|
|
632
|
+
state.active_generation = request.generation
|
|
633
|
+
if state.pending_cancel_generation == request.generation:
|
|
634
|
+
state.pending_cancel_generation = None
|
|
635
|
+
cancel_event.set()
|
|
636
|
+
state.cancel_event = cancel_event
|
|
637
|
+
_notify_test(test_notify, b"operation_started")
|
|
638
|
+
if test_fault == "crash_before_reply":
|
|
639
|
+
os._exit(_EXIT_TEST_CRASH)
|
|
640
|
+
if test_fault == "hang_before_operation":
|
|
641
|
+
_hang_forever(test_notify, b"operation_hang")
|
|
642
|
+
|
|
643
|
+
result: Any = None
|
|
644
|
+
operation_error: BaseException | None = None
|
|
645
|
+
response_payload: dict[str, Any] | None = None
|
|
646
|
+
try:
|
|
647
|
+
active_reader = reader
|
|
648
|
+
if active_reader is None:
|
|
649
|
+
raise TrustedLiveReaderClosedError(
|
|
650
|
+
"The helper reader closed before its database job."
|
|
651
|
+
)
|
|
652
|
+
timeout_seconds = timeout_ms / 1_000.0
|
|
653
|
+
if request.operation == "query":
|
|
654
|
+
if queries is None:
|
|
655
|
+
raise TrustedLiveProtocolValidationError(
|
|
656
|
+
"A query request omitted its statement."
|
|
657
|
+
)
|
|
658
|
+
result = active_reader.query(
|
|
659
|
+
queries[0].sql,
|
|
660
|
+
queries[0].bindings,
|
|
661
|
+
timeout=timeout_seconds,
|
|
662
|
+
cancel_event=cancel_event,
|
|
663
|
+
)
|
|
664
|
+
elif request.operation == "query_batch":
|
|
665
|
+
if queries is None:
|
|
666
|
+
raise TrustedLiveProtocolValidationError(
|
|
667
|
+
"A query batch omitted its statements."
|
|
668
|
+
)
|
|
669
|
+
result = active_reader.query_batch(
|
|
670
|
+
queries,
|
|
671
|
+
timeout=timeout_seconds,
|
|
672
|
+
cancel_event=cancel_event,
|
|
673
|
+
)
|
|
674
|
+
elif request.operation == "data_version":
|
|
675
|
+
result = active_reader.data_version(
|
|
676
|
+
timeout=timeout_seconds,
|
|
677
|
+
cancel_event=cancel_event,
|
|
678
|
+
)
|
|
679
|
+
else:
|
|
680
|
+
raise TrustedLiveProtocolValidationError(
|
|
681
|
+
"The helper received an unknown database operation."
|
|
682
|
+
)
|
|
683
|
+
if test_fault == "cleanup_quarantine":
|
|
684
|
+
with state.lock:
|
|
685
|
+
state.reader = None
|
|
686
|
+
active_reader.close()
|
|
687
|
+
reader = None
|
|
688
|
+
except BaseException as error:
|
|
689
|
+
operation_error = error
|
|
690
|
+
finally:
|
|
691
|
+
with state.lock:
|
|
692
|
+
# Publish completion under the same lock before clearing
|
|
693
|
+
# the active generation so a racing exact-generation
|
|
694
|
+
# cancellation can be recognised as a harmless late
|
|
695
|
+
# control frame instead of poisoning the incarnation.
|
|
696
|
+
state.last_completed_generation = request.generation
|
|
697
|
+
state.active_generation = None
|
|
698
|
+
state.cancel_event = None
|
|
699
|
+
|
|
700
|
+
parent_lost, fatal_error = _fatal_state(state)
|
|
701
|
+
if parent_lost:
|
|
702
|
+
break
|
|
703
|
+
if fatal_error is not None:
|
|
704
|
+
_send_protocol_failure(
|
|
705
|
+
reply_send,
|
|
706
|
+
state,
|
|
707
|
+
fatal_error,
|
|
708
|
+
request.generation,
|
|
709
|
+
)
|
|
710
|
+
break
|
|
711
|
+
if operation_error is not None:
|
|
712
|
+
code = _error_code(operation_error)
|
|
713
|
+
response_frame = encode_error_reply(
|
|
714
|
+
session,
|
|
715
|
+
request.generation,
|
|
716
|
+
request.operation,
|
|
717
|
+
code,
|
|
718
|
+
_error_message(operation_error),
|
|
719
|
+
)
|
|
720
|
+
else:
|
|
721
|
+
try:
|
|
722
|
+
if request.operation == "data_version":
|
|
723
|
+
response_payload = {"value": result}
|
|
724
|
+
elif request.operation == "query":
|
|
725
|
+
response_payload = {"results": encode_query_results((result,))}
|
|
726
|
+
else:
|
|
727
|
+
response_payload = {"results": encode_query_results(result)}
|
|
728
|
+
response_frame = encode_success_reply(
|
|
729
|
+
session,
|
|
730
|
+
request.generation,
|
|
731
|
+
request.operation,
|
|
732
|
+
response_payload,
|
|
733
|
+
)
|
|
734
|
+
except BaseException as error:
|
|
735
|
+
code = _error_code(error)
|
|
736
|
+
response_frame = encode_error_reply(
|
|
737
|
+
session,
|
|
738
|
+
request.generation,
|
|
739
|
+
request.operation,
|
|
740
|
+
code,
|
|
741
|
+
_error_message(error),
|
|
742
|
+
)
|
|
743
|
+
response_frame = _faulted_reply(
|
|
744
|
+
response_frame,
|
|
745
|
+
fault=test_fault,
|
|
746
|
+
session=session,
|
|
747
|
+
generation=request.generation,
|
|
748
|
+
)
|
|
749
|
+
if test_fault in {"partial_job_header", "partial_job_body"}:
|
|
750
|
+
_send_partial_reply_and_hang(
|
|
751
|
+
reply_send,
|
|
752
|
+
response_frame,
|
|
753
|
+
partial_body=test_fault == "partial_job_body",
|
|
754
|
+
test_notify=test_notify,
|
|
755
|
+
marker=(
|
|
756
|
+
b"partial_job_body_sent"
|
|
757
|
+
if test_fault == "partial_job_body"
|
|
758
|
+
else b"partial_job_header_sent"
|
|
759
|
+
),
|
|
760
|
+
)
|
|
761
|
+
if not _send_reply(reply_send, response_frame):
|
|
762
|
+
_mark_parent_lost(state)
|
|
763
|
+
break
|
|
764
|
+
fault_is_terminal = test_fault in {
|
|
765
|
+
"malformed_reply",
|
|
766
|
+
"oversized_reply",
|
|
767
|
+
"stale_generation_reply",
|
|
768
|
+
"stale_session_reply",
|
|
769
|
+
"wrong_version_reply",
|
|
770
|
+
}
|
|
771
|
+
error_is_terminal = operation_error is not None and error_code_is_terminal(
|
|
772
|
+
_error_code(operation_error)
|
|
773
|
+
)
|
|
774
|
+
# A persistent helper must not retain the materialised result, its
|
|
775
|
+
# amplified tagged/base64 tree, or the encoded reply while idle.
|
|
776
|
+
result = None
|
|
777
|
+
response_payload = None
|
|
778
|
+
response_frame = b""
|
|
779
|
+
operation_error = None
|
|
780
|
+
if fault_is_terminal:
|
|
781
|
+
break
|
|
782
|
+
if error_is_terminal:
|
|
783
|
+
break
|
|
784
|
+
except BaseException as error:
|
|
785
|
+
parent_lost, _fatal_error = _fatal_state(state)
|
|
786
|
+
if not parent_lost:
|
|
787
|
+
try:
|
|
788
|
+
_send_protocol_failure(
|
|
789
|
+
reply_send,
|
|
790
|
+
state,
|
|
791
|
+
_error_message(error),
|
|
792
|
+
last_generation,
|
|
793
|
+
)
|
|
794
|
+
except BaseException:
|
|
795
|
+
pass
|
|
796
|
+
finally:
|
|
797
|
+
if reader is not None:
|
|
798
|
+
try:
|
|
799
|
+
with state.lock:
|
|
800
|
+
state.reader = None
|
|
801
|
+
reader.close()
|
|
802
|
+
except BaseException:
|
|
803
|
+
pass
|
|
804
|
+
state.stop_event.set()
|
|
805
|
+
state.main_done.set()
|
|
806
|
+
for connection in (command_receive, reply_send, test_notify):
|
|
807
|
+
if connection is not None:
|
|
808
|
+
try:
|
|
809
|
+
connection.close()
|
|
810
|
+
except OSError:
|
|
811
|
+
pass
|
|
812
|
+
control_thread.join(timeout=min(0.25, state.orphan_grace_seconds))
|