qplotter 1.6.0__py3-none-any.whl

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