qplotter 1.6.0b3__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 (119) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +63 -0
  4. qplot/_metadata.py +12 -0
  5. qplot/_repair.py +14 -0
  6. qplot/_shutdown_supervisor.py +4049 -0
  7. qplot/_version.py +12 -0
  8. qplot/_windows_shutdown_job.py +1166 -0
  9. qplot/configuration/__init__.py +3 -0
  10. qplot/configuration/config.py +573 -0
  11. qplot/configuration/config_schema.json +319 -0
  12. qplot/configuration/scripts.py +255 -0
  13. qplot/configuration/themes/__init__.py +13 -0
  14. qplot/configuration/themes/_base.py +579 -0
  15. qplot/configuration/themes/blank.py +17 -0
  16. qplot/configuration/themes/dark.py +54 -0
  17. qplot/configuration/themes/light.py +54 -0
  18. qplot/datahandling/LoadFromDB.py +224 -0
  19. qplot/datahandling/__init__.py +47 -0
  20. qplot/datahandling/_trusted_live_helper.py +812 -0
  21. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  22. qplot/datahandling/database.py +2330 -0
  23. qplot/datahandling/dimensions.py +43 -0
  24. qplot/datahandling/file_identity.py +461 -0
  25. qplot/datahandling/parameter_data.py +149 -0
  26. qplot/datahandling/plot_progress.py +17 -0
  27. qplot/datahandling/qcodes_cache.py +277 -0
  28. qplot/datahandling/qcodes_compat.py +14 -0
  29. qplot/datahandling/readSQL.py +2442 -0
  30. qplot/datahandling/readonly.py +2561 -0
  31. qplot/datahandling/source_activity.py +21 -0
  32. qplot/datahandling/trusted_array_preview.py +127 -0
  33. qplot/datahandling/trusted_derived_cache.py +1605 -0
  34. qplot/datahandling/trusted_derived_rendering.py +868 -0
  35. qplot/datahandling/trusted_heatmap.py +241 -0
  36. qplot/datahandling/trusted_live.py +2808 -0
  37. qplot/datahandling/trusted_live_queries.py +5907 -0
  38. qplot/datahandling/trusted_live_service.py +1894 -0
  39. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  40. qplot/datahandling/trusted_plot.py +283 -0
  41. qplot/datahandling/trusted_presentation.py +1065 -0
  42. qplot/datahandling/trusted_snapshot.py +1132 -0
  43. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  44. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  45. qplot/diagnostics.py +241 -0
  46. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  47. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  48. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  49. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  50. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  51. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  52. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  53. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  54. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  55. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  56. qplot/testdata.py +1733 -0
  57. qplot/tools/__init__.py +13 -0
  58. qplot/tools/finite_means.py +114 -0
  59. qplot/tools/general.py +134 -0
  60. qplot/tools/heatmap_geometry.py +381 -0
  61. qplot/tools/operation_registry.py +179 -0
  62. qplot/tools/plot_tools.py +625 -0
  63. qplot/tools/sample_statistics.py +56 -0
  64. qplot/tools/worker.py +2787 -0
  65. qplot/windows/__init__.py +9 -0
  66. qplot/windows/_colorbar.py +422 -0
  67. qplot/windows/_commands.py +523 -0
  68. qplot/windows/_config_persistence.py +123 -0
  69. qplot/windows/_database_actions.py +4904 -0
  70. qplot/windows/_dataset_handle.py +221 -0
  71. qplot/windows/_dragdrop.py +71 -0
  72. qplot/windows/_export_paths.py +1167 -0
  73. qplot/windows/_help.py +169 -0
  74. qplot/windows/_native_averaging.py +173 -0
  75. qplot/windows/_native_transforms.py +389 -0
  76. qplot/windows/_plot1d_snap.py +608 -0
  77. qplot/windows/_plot1d_traces.py +2183 -0
  78. qplot/windows/_plot2d_colorbar.py +1126 -0
  79. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  80. qplot/windows/_plot2d_layers.py +2197 -0
  81. qplot/windows/_plot2d_sweeps.py +712 -0
  82. qplot/windows/_plotWin.py +1993 -0
  83. qplot/windows/_plot_actions.py +2695 -0
  84. qplot/windows/_plot_appearance.py +276 -0
  85. qplot/windows/_plot_axis_scaling.py +1485 -0
  86. qplot/windows/_plot_export.py +1445 -0
  87. qplot/windows/_plot_feedback.py +110 -0
  88. qplot/windows/_plot_marquee.py +656 -0
  89. qplot/windows/_plot_refresh.py +942 -0
  90. qplot/windows/_plot_state.py +239 -0
  91. qplot/windows/_plot_transform_labels.py +60 -0
  92. qplot/windows/_preferences.py +635 -0
  93. qplot/windows/_refresh_interval.py +70 -0
  94. qplot/windows/_run_controls.py +828 -0
  95. qplot/windows/_shortcuts.py +47 -0
  96. qplot/windows/_subplots/__init__.py +8 -0
  97. qplot/windows/_subplots/subplot1d.py +531 -0
  98. qplot/windows/_subplots/subplot2d.py +918 -0
  99. qplot/windows/_svg_export.py +209 -0
  100. qplot/windows/_trusted_derived_qt.py +1759 -0
  101. qplot/windows/_widgets/__init__.py +23 -0
  102. qplot/windows/_widgets/_run_formatting.py +387 -0
  103. qplot/windows/_widgets/details_tables.py +1119 -0
  104. qplot/windows/_widgets/dropbox.py +290 -0
  105. qplot/windows/_widgets/operations.py +460 -0
  106. qplot/windows/_widgets/preview.py +2997 -0
  107. qplot/windows/_widgets/run_list_items.py +488 -0
  108. qplot/windows/_widgets/toolbar.py +111 -0
  109. qplot/windows/_widgets/treeWidgets.py +2572 -0
  110. qplot/windows/_window_controls.py +413 -0
  111. qplot/windows/main.py +1635 -0
  112. qplot/windows/plot1d.py +311 -0
  113. qplot/windows/plot2d.py +1901 -0
  114. qplotter-1.6.0b3.dist-info/METADATA +318 -0
  115. qplotter-1.6.0b3.dist-info/RECORD +119 -0
  116. qplotter-1.6.0b3.dist-info/WHEEL +5 -0
  117. qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
  118. qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
  119. qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2808 @@
1
+ """Physically read-only access to a trusted live QCoDeS database.
2
+
3
+ This finite backend is owned by the isolated helper process. Application
4
+ metadata, derived work, and explicit plots access it through the read broker.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ import json
11
+ import math
12
+ import os
13
+ import secrets
14
+ import stat
15
+ import tempfile
16
+ import threading
17
+ import time
18
+ import weakref
19
+ from collections.abc import Callable, Mapping, Sequence
20
+ from dataclasses import dataclass
21
+ from importlib.metadata import PackageNotFoundError, version
22
+ from pathlib import Path
23
+ from types import MappingProxyType, TracebackType
24
+ from typing import Any, TypeAlias
25
+ from urllib.parse import quote
26
+
27
+ import apsw
28
+ import apsw.ext
29
+
30
+ from qplot.datahandling.file_identity import (
31
+ DatabaseFileIdentity,
32
+ DatabaseInstance,
33
+ FileIdentityHandleCloseError,
34
+ canonical_database_path,
35
+ checked_path_bound_file_identity,
36
+ database_instances_differ,
37
+ database_sidecar_identities,
38
+ logical_database_path,
39
+ )
40
+
41
+ TRUSTED_READER_APSW_VERSION = "3.53.4.0"
42
+ TRUSTED_READER_NATIVE_VERSION = "1.0.0"
43
+ TRUSTED_READER_SQLITE_VERSION = "3.53.4"
44
+ TRUSTED_READER_SQLITE_SOURCE_ID = (
45
+ "2026-07-24 19:02:57 "
46
+ "bf7c7f30031888f4e796e429ab3978879485813aaca6f641c7b33e4e09459bcc"
47
+ )
48
+ TRUSTED_READER_VFS_NAME = "qplot-trusted-live-v2"
49
+ _NATIVE_ENTRYPOINT = "sqlite3_qplot_trusted_vfs_init"
50
+ _SQLITE_HEADER_READ_VERSION_OFFSET = 18
51
+ _SQLITE_HEADER_WRITE_VERSION_OFFSET = 19
52
+ _SQLITE_HEADER_PREFIX_BYTES = 100
53
+ _SQLITE_ROLLBACK_FORMAT = 1
54
+ _SQLITE_WAL_FORMAT = 2
55
+ DEFAULT_TRUSTED_OPERATION_TIMEOUT_SECONDS = 5.0
56
+ TRUSTED_LIVE_MAX_REPLY_BYTES = 32 * 1024 * 1024
57
+ TRUSTED_LIVE_MAX_BATCH_QUERIES = 128
58
+ TRUSTED_LIVE_MAX_COLUMNS_PER_RESULT = 4_096
59
+ TRUSTED_LIVE_MAX_ROWS_PER_RESULT = 250_000
60
+ TRUSTED_LIVE_MAX_CELLS_PER_REPLY = 1_000_000
61
+ TRUSTED_LIVE_MAX_SCALAR_BYTES = 4 * 1024 * 1024
62
+ TRUSTED_LIVE_MAX_COLUMN_NAME_BYTES = 1_024
63
+ TRUSTED_LIVE_MAX_TRANSIENT_RAW_ROW_BYTES = 8 * 1024 * 1024
64
+ # APSW returns ordinary SQLite values in a Python tuple. Four is the maximum
65
+ # UTF-8-to-PEP-393 payload expansion. The per-column allowance covers the
66
+ # scalar object/header/terminator, a tuple reference, and fixed-size SQLite
67
+ # integer/float/NULL objects on supported 64-bit CPython; the fixed allowance
68
+ # covers the tuple's logical object size. This is a conservative logical
69
+ # Python-object/payload envelope, not a bound on allocator-reserved bytes,
70
+ # size-class rounding, process RSS, arenas, fragmentation, or SQLite VM memory.
71
+ # The statement limit also satisfies the independent raw-payload bound.
72
+ _TRUSTED_LIVE_PYTHON_TEXT_EXPANSION = 4
73
+ _TRUSTED_LIVE_PYTHON_FIXED_BYTES_PER_COLUMN = 512
74
+ _TRUSTED_LIVE_PYTHON_ROW_FIXED_BYTES = 4 * 1024
75
+ TRUSTED_LIVE_MAX_TRANSIENT_PYTHON_ROW_BYTES = 32 * 1024 * 1024
76
+ _BUSY_RETRY_QUANTUM_SECONDS = 0.01
77
+ _PROGRESS_HANDLER_STEPS = 1_000
78
+ _RESULT_FRAME_ENVELOPE_RESERVE_BYTES = 512
79
+ _RESULT_WIRE_CONTAINER_BUDGET_BYTES = 32
80
+ _RESULT_WIRE_FIELD_BUDGET_BYTES = 16
81
+ _SQLITE_INTEGER_MIN = -(1 << 63)
82
+ _SQLITE_INTEGER_MAX = (1 << 63) - 1
83
+ _PROCESS_SESSION_MUTEX = threading.Lock()
84
+ _PROCESS_SESSION_OWNER: object | None = None
85
+ _PROCESS_SESSION_QUARANTINE_REASON: str | None = None
86
+ SqliteBinding: TypeAlias = Any
87
+ SqliteBindings: TypeAlias = Sequence[SqliteBinding] | Mapping[str, SqliteBinding] | None
88
+
89
+
90
+ class TrustedLiveReaderError(RuntimeError):
91
+ """Base error for the trusted live-reader backend."""
92
+
93
+
94
+ class TrustedLiveReaderUnavailableError(TrustedLiveReaderError):
95
+ """Raised when the pinned native reader boundary is unavailable."""
96
+
97
+
98
+ class TrustedLiveSourceError(TrustedLiveReaderError):
99
+ """Raised when the selected source is not a safe direct-read target."""
100
+
101
+
102
+ class TrustedLiveUnsupportedSourceError(TrustedLiveSourceError):
103
+ """Raised for an unsupported platform, path, filesystem, or file type."""
104
+
105
+
106
+ class TrustedLiveSourceChangedError(TrustedLiveSourceError):
107
+ """Raised only when checked OS identities prove a source change."""
108
+
109
+
110
+ class TrustedLiveSourceIOError(TrustedLiveSourceError):
111
+ """Raised for source I/O failures not proven to be identity changes."""
112
+
113
+
114
+ class TrustedLiveSqlRejectedError(TrustedLiveReaderError):
115
+ """Raised when SQL is outside the backend's read-only query surface."""
116
+
117
+
118
+ class TrustedLiveQueryError(TrustedLiveReaderError):
119
+ """Raised when an allowed query cannot be prepared or executed."""
120
+
121
+
122
+ class TrustedLiveResultLimitError(TrustedLiveReaderError):
123
+ """Raised before a query result can exceed its materialisation budget."""
124
+
125
+
126
+ class TrustedLiveBusyTimeoutError(TrustedLiveReaderError):
127
+ """Raised when the reader's bounded SQLite busy budget expires."""
128
+
129
+
130
+ class TrustedLiveCancelledError(InterruptedError, TrustedLiveReaderError):
131
+ """Raised when an external event or :meth:`interrupt` cancels an operation."""
132
+
133
+
134
+ class TrustedLiveDeadlineExceededError(TimeoutError, TrustedLiveReaderError):
135
+ """Raised when a finite reader operation reaches its monotonic deadline."""
136
+
137
+
138
+ class TrustedLiveInvalidDatabaseError(TrustedLiveReaderError):
139
+ """Raised for a corrupt, malformed, or non-SQLite database."""
140
+
141
+
142
+ class TrustedLiveCleanupError(TrustedLiveReaderError):
143
+ """Raised when rollback or handle release cannot be verified cleanly."""
144
+
145
+
146
+ class TrustedLiveReaderClosedError(TrustedLiveReaderError):
147
+ """Raised when an operation targets a closed reader."""
148
+
149
+
150
+ class TrustedLiveReaderThreadError(TrustedLiveReaderError):
151
+ """Raised when a reader is used from a thread other than its owner."""
152
+
153
+
154
+ class TrustedLiveTransactionError(TrustedLiveReaderError):
155
+ """Raised when an operation is re-entered on the same reader."""
156
+
157
+
158
+ class _PreflightFileDescriptorCloseError(OSError):
159
+ """An internal signal that preflight descriptor cleanup is uncertain."""
160
+
161
+
162
+ @dataclass(frozen=True, slots=True)
163
+ class TrustedLiveSourceIdentity:
164
+ """Main binding plus source sidecars observed before the native open."""
165
+
166
+ database_instance: DatabaseInstance
167
+ wal_identity: DatabaseFileIdentity | None
168
+ shm_identity: DatabaseFileIdentity | None
169
+ journal_identity: DatabaseFileIdentity | None
170
+ journal_mode: str
171
+
172
+
173
+ @dataclass(frozen=True, slots=True)
174
+ class TrustedLiveReaderToken:
175
+ """Opaque incarnation token used to reject stale Stage 2 callbacks."""
176
+
177
+ source: TrustedLiveSourceIdentity
178
+ nonce: str
179
+
180
+
181
+ @dataclass(frozen=True, slots=True)
182
+ class TrustedQuery:
183
+ """One immutable statement specification for a finite reader operation.
184
+
185
+ Sequence and mapping bindings are shallow-copied when the specification is
186
+ made. A caller therefore cannot replace bindings while a batch transaction
187
+ is running.
188
+ """
189
+
190
+ sql: str
191
+ bindings: SqliteBindings = None
192
+
193
+ def __post_init__(self) -> None:
194
+ bindings = self.bindings
195
+ if isinstance(bindings, Mapping):
196
+ frozen_bindings: SqliteBindings = MappingProxyType(dict(bindings))
197
+ elif bindings is None:
198
+ frozen_bindings = None
199
+ elif isinstance(bindings, bytearray):
200
+ frozen_bindings = (bytes(bindings),)
201
+ elif isinstance(bindings, (str, bytes)):
202
+ frozen_bindings = (bindings,)
203
+ else:
204
+ frozen_bindings = tuple(bindings)
205
+ object.__setattr__(self, "bindings", frozen_bindings)
206
+
207
+
208
+ @dataclass(frozen=True, slots=True)
209
+ class TrustedQueryResult:
210
+ """One fully materialised result with no cursor or connection escape."""
211
+
212
+ columns: tuple[str, ...]
213
+ rows: tuple[tuple[Any, ...], ...]
214
+
215
+
216
+ @dataclass(frozen=True, slots=True)
217
+ class TrustedVfsAudit:
218
+ """Native VFS counters attributed only to this reader incarnation."""
219
+
220
+ counters: Mapping[str, int]
221
+
222
+ def __getitem__(self, name: str) -> int:
223
+ return self.counters[name]
224
+
225
+
226
+ @dataclass(frozen=True, slots=True)
227
+ class _TrustedNativeStatus:
228
+ sequence: int
229
+ kind: str
230
+ artifact: str
231
+ operation: str
232
+ sqlite_code: int
233
+
234
+
235
+ @dataclass(slots=True)
236
+ class _TrustedOperationControl:
237
+ generation: int
238
+ started_at: float
239
+ deadline: float
240
+ busy_deadline: float
241
+ cancel_event: threading.Event | None
242
+ native_sequence: int = 0
243
+ abort_reason: str | None = None
244
+ policy_denied: bool = False
245
+ operation_result_length_limit: int | None = None
246
+ result_length_limit: int | None = None
247
+ blob_cleanup_error: BaseException | None = None
248
+
249
+
250
+ @dataclass(frozen=True, slots=True)
251
+ class _TrustedSqliteLengthLimit:
252
+ """One temporary, verified SQLite length-limit installation."""
253
+
254
+ previous: int
255
+ effective: int
256
+
257
+
258
+ def _trusted_statement_length_limit(
259
+ operation_baseline: int,
260
+ column_count: int,
261
+ ) -> int:
262
+ """Return the per-value cap for the raw and logical Python-row envelopes."""
263
+
264
+ width = max(1, column_count)
265
+ raw_payload_limit = TRUSTED_LIVE_MAX_TRANSIENT_RAW_ROW_BYTES // width
266
+ python_payload_room = (
267
+ TRUSTED_LIVE_MAX_TRANSIENT_PYTHON_ROW_BYTES
268
+ - _TRUSTED_LIVE_PYTHON_ROW_FIXED_BYTES
269
+ - (_TRUSTED_LIVE_PYTHON_FIXED_BYTES_PER_COLUMN * width)
270
+ )
271
+ if python_payload_room <= 0:
272
+ raise TrustedLiveResultLimitError(
273
+ "The trusted query result is too wide for the logical Python-row "
274
+ "materialisation budget."
275
+ )
276
+ python_payload_limit = python_payload_room // (
277
+ _TRUSTED_LIVE_PYTHON_TEXT_EXPANSION * width
278
+ )
279
+ effective = min(
280
+ operation_baseline,
281
+ TRUSTED_LIVE_MAX_SCALAR_BYTES,
282
+ raw_payload_limit,
283
+ python_payload_limit,
284
+ )
285
+ if effective < 1:
286
+ raise TrustedLiveResultLimitError(
287
+ "The trusted query result is too wide for a positive SQLite "
288
+ "result-length limit."
289
+ )
290
+ return effective
291
+
292
+
293
+ @dataclass(slots=True)
294
+ class _TrustedResultBudget:
295
+ """Incrementally bound one batch before retaining each SQLite row."""
296
+
297
+ maximum_wire_bytes: int
298
+ used_wire_bytes: int
299
+ total_cells: int = 0
300
+ abort_check: Callable[[], None] | None = None
301
+
302
+ @classmethod
303
+ def for_query_batch(cls) -> _TrustedResultBudget:
304
+ budget = cls(
305
+ maximum_wire_bytes=TRUSTED_LIVE_MAX_REPLY_BYTES,
306
+ used_wire_bytes=0,
307
+ )
308
+ budget.consume(
309
+ _RESULT_FRAME_ENVELOPE_RESERVE_BYTES,
310
+ "The protocol envelope",
311
+ )
312
+ budget.consume(
313
+ _RESULT_WIRE_CONTAINER_BUDGET_BYTES,
314
+ "Query results",
315
+ )
316
+ return budget
317
+
318
+ def consume(self, amount: int, description: str) -> None:
319
+ if amount < 0 or amount > self.maximum_wire_bytes - self.used_wire_bytes:
320
+ raise TrustedLiveResultLimitError(
321
+ f"{description} exceeds the aggregate "
322
+ f"{self.maximum_wire_bytes}-byte result wire budget."
323
+ )
324
+ self.used_wire_bytes += amount
325
+
326
+ def start_result(self, description: Sequence[Sequence[Any]]) -> tuple[str, ...]:
327
+ if len(description) > TRUSTED_LIVE_MAX_COLUMNS_PER_RESULT:
328
+ raise TrustedLiveResultLimitError(
329
+ "A trusted query result has more than "
330
+ f"{TRUSTED_LIVE_MAX_COLUMNS_PER_RESULT} columns."
331
+ )
332
+ self.consume(_RESULT_WIRE_CONTAINER_BUDGET_BYTES, "Query results")
333
+ columns: list[str] = []
334
+ for column_description in description:
335
+ if self.abort_check is not None:
336
+ self.abort_check()
337
+ if not column_description or not isinstance(column_description[0], str):
338
+ raise TrustedLiveQueryError(
339
+ "SQLite returned a result column without a valid text name."
340
+ )
341
+ column = column_description[0]
342
+ _utf8_bytes, json_bytes = _trusted_text_sizes(
343
+ column,
344
+ description="A trusted query result column name",
345
+ maximum_utf8_bytes=TRUSTED_LIVE_MAX_COLUMN_NAME_BYTES,
346
+ abort_check=self.abort_check,
347
+ )
348
+ self.consume(
349
+ _RESULT_WIRE_FIELD_BUDGET_BYTES + json_bytes,
350
+ "Query result columns",
351
+ )
352
+ columns.append(column)
353
+ return tuple(columns)
354
+
355
+ def retain_row(
356
+ self,
357
+ row: Sequence[Any],
358
+ *,
359
+ column_count: int,
360
+ retained_row_count: int,
361
+ ) -> tuple[Any, ...]:
362
+ if retained_row_count >= TRUSTED_LIVE_MAX_ROWS_PER_RESULT:
363
+ raise TrustedLiveResultLimitError(
364
+ "A trusted query result has more than "
365
+ f"{TRUSTED_LIVE_MAX_ROWS_PER_RESULT} rows."
366
+ )
367
+ if len(row) != column_count:
368
+ raise TrustedLiveQueryError(
369
+ "SQLite returned a result row with the wrong number of columns."
370
+ )
371
+ if column_count > TRUSTED_LIVE_MAX_CELLS_PER_REPLY - self.total_cells:
372
+ raise TrustedLiveResultLimitError(
373
+ "A trusted query batch has more than "
374
+ f"{TRUSTED_LIVE_MAX_CELLS_PER_REPLY} result cells."
375
+ )
376
+
377
+ self.consume(
378
+ _RESULT_WIRE_CONTAINER_BUDGET_BYTES
379
+ + (_RESULT_WIRE_FIELD_BUDGET_BYTES * column_count),
380
+ "Query result rows",
381
+ )
382
+ retained_values: list[Any] = []
383
+ for value in row:
384
+ if self.abort_check is not None:
385
+ self.abort_check()
386
+ scalar, wire_bytes = _trusted_result_scalar(
387
+ value,
388
+ abort_check=self.abort_check,
389
+ )
390
+ self.consume(wire_bytes, "Query result values")
391
+ retained_values.append(scalar)
392
+ self.total_cells += column_count
393
+ return tuple(retained_values)
394
+
395
+
396
+ def _trusted_text_sizes(
397
+ value: str,
398
+ *,
399
+ description: str,
400
+ maximum_utf8_bytes: int,
401
+ abort_check: Callable[[], None] | None = None,
402
+ ) -> tuple[int, int]:
403
+ """Size UTF-8 and canonical JSON text without allocating either form."""
404
+
405
+ utf8_bytes = 0
406
+ json_bytes = 2 # Opening and closing JSON quotes.
407
+ for character_index, character in enumerate(value):
408
+ if character_index % 4_096 == 0 and abort_check is not None:
409
+ abort_check()
410
+ codepoint = ord(character)
411
+ if 0xD800 <= codepoint <= 0xDFFF:
412
+ raise TrustedLiveQueryError(
413
+ f"{description} contains text that is not valid Unicode."
414
+ )
415
+ if codepoint <= 0x7F:
416
+ character_bytes = 1
417
+ elif codepoint <= 0x7FF:
418
+ character_bytes = 2
419
+ elif codepoint <= 0xFFFF:
420
+ character_bytes = 3
421
+ else:
422
+ character_bytes = 4
423
+ utf8_bytes += character_bytes
424
+ if utf8_bytes > maximum_utf8_bytes:
425
+ raise TrustedLiveResultLimitError(
426
+ f"{description} exceeds the {maximum_utf8_bytes}-byte limit."
427
+ )
428
+ if character in {'"', "\\"}:
429
+ json_bytes += 2
430
+ elif codepoint <= 0x1F:
431
+ json_bytes += 2 if character in "\b\t\n\f\r" else 6
432
+ else:
433
+ json_bytes += character_bytes
434
+ return utf8_bytes, json_bytes
435
+
436
+
437
+ def _trusted_result_scalar(
438
+ value: Any,
439
+ *,
440
+ abort_check: Callable[[], None] | None = None,
441
+ ) -> tuple[Any, int]:
442
+ """Validate, normalise, and conservatively size one live SQLite scalar."""
443
+
444
+ if value is None:
445
+ return None, 8
446
+ if isinstance(value, bool):
447
+ return value, 16
448
+ if isinstance(value, int):
449
+ if value < _SQLITE_INTEGER_MIN or value > _SQLITE_INTEGER_MAX:
450
+ raise TrustedLiveQueryError(
451
+ "SQLite returned an integer outside its signed 64-bit range."
452
+ )
453
+ return value, 16 + len(str(value))
454
+ if isinstance(value, float):
455
+ # SQLite can deliberately return infinities. They are represented by
456
+ # a canonical tagged hexadecimal string at the IPC layer rather than
457
+ # as a non-finite JSON number.
458
+ return value, 16 + len(value.hex())
459
+ if isinstance(value, str):
460
+ _utf8_bytes, json_bytes = _trusted_text_sizes(
461
+ value,
462
+ description="A SQLite text result",
463
+ maximum_utf8_bytes=TRUSTED_LIVE_MAX_SCALAR_BYTES,
464
+ abort_check=abort_check,
465
+ )
466
+ return value, 16 + json_bytes
467
+ if isinstance(value, memoryview):
468
+ blob_size = value.nbytes
469
+ elif isinstance(value, (bytes, bytearray)):
470
+ blob_size = len(value)
471
+ else:
472
+ raise TrustedLiveQueryError(
473
+ "SQLite returned a value outside its null, integer, real, text, "
474
+ "and blob scalar types."
475
+ )
476
+ if blob_size > TRUSTED_LIVE_MAX_SCALAR_BYTES:
477
+ raise TrustedLiveResultLimitError(
478
+ "A SQLite blob result exceeds the "
479
+ f"{TRUSTED_LIVE_MAX_SCALAR_BYTES}-byte limit."
480
+ )
481
+ blob = value if isinstance(value, bytes) else bytes(value)
482
+ base64_bytes = 4 * ((blob_size + 2) // 3)
483
+ return blob, 16 + base64_bytes
484
+
485
+
486
+ def preflight_trusted_query_results(
487
+ results: Sequence[TrustedQueryResult],
488
+ ) -> None:
489
+ """Apply the live reader's exact aggregate result budget to existing rows.
490
+
491
+ The helper protocol calls this as a defensive second check. Normal live
492
+ queries use the same budget incrementally, before each row is retained.
493
+ """
494
+
495
+ if isinstance(results, (str, bytes, bytearray, memoryview)):
496
+ raise TrustedLiveQueryError("Trusted query results must be a sequence.")
497
+ result_count = len(results)
498
+ if not 1 <= result_count <= TRUSTED_LIVE_MAX_BATCH_QUERIES:
499
+ raise TrustedLiveResultLimitError(
500
+ "A trusted query batch must contain between 1 and "
501
+ f"{TRUSTED_LIVE_MAX_BATCH_QUERIES} results."
502
+ )
503
+ budget = _TrustedResultBudget.for_query_batch()
504
+ for index in range(result_count):
505
+ result = results[index]
506
+ if not isinstance(result, TrustedQueryResult):
507
+ raise TrustedLiveQueryError(
508
+ "A trusted query batch contains a non-result object."
509
+ )
510
+ description = tuple((column,) for column in result.columns)
511
+ columns = budget.start_result(description)
512
+ if columns != result.columns:
513
+ raise TrustedLiveQueryError(
514
+ "A trusted query result has invalid column metadata."
515
+ )
516
+ for row_index, row in enumerate(result.rows):
517
+ budget.retain_row(
518
+ row,
519
+ column_count=len(columns),
520
+ retained_row_count=row_index,
521
+ )
522
+
523
+
524
+ def _claim_process_session(owner: object) -> None:
525
+ global _PROCESS_SESSION_OWNER
526
+ with _PROCESS_SESSION_MUTEX:
527
+ if _PROCESS_SESSION_QUARANTINE_REASON is not None:
528
+ raise TrustedLiveReaderUnavailableError(
529
+ "A prior trusted-reader cleanup failure quarantined this process. "
530
+ "Terminate the process before opening another trusted reader. "
531
+ "Recorded cleanup uncertainty: "
532
+ f"{_PROCESS_SESSION_QUARANTINE_REASON}"
533
+ )
534
+ if _PROCESS_SESSION_OWNER is not None:
535
+ raise TrustedLiveReaderUnavailableError(
536
+ "Close the existing trusted reader in this process before "
537
+ "opening another one."
538
+ )
539
+ _PROCESS_SESSION_OWNER = owner
540
+
541
+
542
+ def _quarantine_process_session(owner: object, error: BaseException) -> None:
543
+ global _PROCESS_SESSION_QUARANTINE_REASON
544
+ with _PROCESS_SESSION_MUTEX:
545
+ if _PROCESS_SESSION_OWNER is owner:
546
+ _PROCESS_SESSION_QUARANTINE_REASON = f"{type(error).__name__}: {error}"
547
+
548
+
549
+ def _release_process_session(owner: object) -> None:
550
+ global _PROCESS_SESSION_OWNER, _PROCESS_SESSION_QUARANTINE_REASON
551
+ with _PROCESS_SESSION_MUTEX:
552
+ # Identity ownership makes constructor/close cleanup idempotent and
553
+ # prevents one interrupted reader from clearing another reader's claim.
554
+ if _PROCESS_SESSION_OWNER is owner:
555
+ _PROCESS_SESSION_OWNER = None
556
+ _PROCESS_SESSION_QUARANTINE_REASON = None
557
+
558
+
559
+ def _native_extension_path() -> Path:
560
+ try:
561
+ native_version = version("qplotter-native")
562
+ except PackageNotFoundError as error:
563
+ raise TrustedLiveReaderUnavailableError(
564
+ "qPlot requires qplotter-native=="
565
+ f"{TRUSTED_READER_NATIVE_VERSION}; install its platform wheel."
566
+ ) from error
567
+ if native_version != TRUSTED_READER_NATIVE_VERSION:
568
+ raise TrustedLiveReaderUnavailableError(
569
+ "qPlot requires qplotter-native=="
570
+ f"{TRUSTED_READER_NATIVE_VERSION}; found {native_version}."
571
+ )
572
+ try:
573
+ module = importlib.import_module("qplot_native._trusted_vfs_native")
574
+ except ImportError as error:
575
+ raise TrustedLiveReaderUnavailableError(
576
+ "qPlot's trusted live-reader native VFS is not installed."
577
+ ) from error
578
+ if (
579
+ getattr(module, "sqlite_version", None) != TRUSTED_READER_SQLITE_VERSION
580
+ or getattr(module, "vfs_name", None) != TRUSTED_READER_VFS_NAME
581
+ ):
582
+ raise TrustedLiveReaderUnavailableError(
583
+ "qPlot's native VFS is incompatible with the pinned trusted reader."
584
+ )
585
+ module_file = getattr(module, "__file__", None)
586
+ if not module_file:
587
+ raise TrustedLiveReaderUnavailableError(
588
+ "qPlot could not locate its trusted live-reader native VFS."
589
+ )
590
+ return Path(module_file).resolve()
591
+
592
+
593
+ def _check_pinned_sqlite_runtime() -> None:
594
+ apsw_version = apsw.apsw_version()
595
+ sqlite_version = apsw.sqlite_lib_version()
596
+ sqlite_source_id = apsw.sqlite3_sourceid()
597
+ if (
598
+ apsw_version != TRUSTED_READER_APSW_VERSION
599
+ or sqlite_version != TRUSTED_READER_SQLITE_VERSION
600
+ or sqlite_source_id != TRUSTED_READER_SQLITE_SOURCE_ID
601
+ ):
602
+ raise TrustedLiveReaderUnavailableError(
603
+ "The trusted live reader requires APSW "
604
+ f"{TRUSTED_READER_APSW_VERSION} with SQLite "
605
+ f"{TRUSTED_READER_SQLITE_VERSION}; found APSW {apsw_version} "
606
+ f"with SQLite {sqlite_version} ({sqlite_source_id}). qPlot did "
607
+ "not open the source."
608
+ )
609
+ get_effective_user_id = getattr(os, "geteuid", None)
610
+ if get_effective_user_id is not None and get_effective_user_id() == 0:
611
+ raise TrustedLiveReaderUnavailableError(
612
+ "The trusted live reader refuses to run as the POSIX root user. "
613
+ "SQLite's Unix VFS may issue fchown() while opening a WAL "
614
+ "shared-memory file as root."
615
+ )
616
+
617
+
618
+ def _reject_symlink_path_components(path: Path) -> None:
619
+ """Reject a selected path containing any currently visible symlink.
620
+
621
+ This records only the observed trusted hierarchy at preflight. The native
622
+ VFS separately compares expected identities with SQLite's actual handles;
623
+ neither check claims to defend a hostile parent namespace.
624
+ """
625
+
626
+ absolute_path = Path(os.path.abspath(os.fspath(path)))
627
+ current = Path(absolute_path.anchor)
628
+ components = (
629
+ absolute_path.parts[1:] if absolute_path.anchor else absolute_path.parts
630
+ )
631
+ for index, component in enumerate(components):
632
+ current /= component
633
+ try:
634
+ status = current.lstat()
635
+ except FileNotFoundError:
636
+ if index == len(components) - 1:
637
+ return
638
+ raise TrustedLiveUnsupportedSourceError(
639
+ f"A parent of the selected trusted database does not exist: {current}"
640
+ ) from None
641
+ except OSError as error:
642
+ raise TrustedLiveSourceIOError(
643
+ f"Could not inspect a selected database path component "
644
+ f"{current}: {error}"
645
+ ) from error
646
+ file_attributes = int(getattr(status, "st_file_attributes", 0))
647
+ reparse_point = int(getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400))
648
+ if stat.S_ISLNK(status.st_mode) or (
649
+ os.name == "nt" and file_attributes & reparse_point
650
+ ):
651
+ raise TrustedLiveUnsupportedSourceError(
652
+ "Trusted live reading does not accept symbolic-link or "
653
+ f"reparse-point path components: {current}"
654
+ )
655
+ if index != len(components) - 1 and not stat.S_ISDIR(status.st_mode):
656
+ raise TrustedLiveUnsupportedSourceError(
657
+ f"A parent of the selected trusted database is not a directory: {current}"
658
+ )
659
+
660
+
661
+ def _reject_windows_alternate_data_stream(path: Path) -> None:
662
+ """Reject NTFS stream syntax before opening any source-family handle."""
663
+
664
+ if os.name != "nt":
665
+ return
666
+ _drive, tail = os.path.splitdrive(os.fspath(path))
667
+ if ":" in tail:
668
+ raise TrustedLiveUnsupportedSourceError(
669
+ "Trusted live reading does not accept Windows alternate data "
670
+ f"stream paths: {path}"
671
+ )
672
+
673
+
674
+ def _require_regular_file(path: Path, description: str) -> os.stat_result:
675
+ try:
676
+ status = path.lstat()
677
+ except FileNotFoundError:
678
+ raise TrustedLiveUnsupportedSourceError(
679
+ f"The trusted database {description} does not exist: {path}"
680
+ ) from None
681
+ except OSError as error:
682
+ raise TrustedLiveSourceIOError(
683
+ f"Could not inspect the trusted database {description} {path}: {error}"
684
+ ) from error
685
+ file_attributes = int(getattr(status, "st_file_attributes", 0))
686
+ reparse_point = int(getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400))
687
+ if stat.S_ISLNK(status.st_mode) or (
688
+ os.name == "nt" and file_attributes & reparse_point
689
+ ):
690
+ raise TrustedLiveUnsupportedSourceError(
691
+ f"The trusted database {description} must not be a symbolic link "
692
+ f"or reparse point: {path}"
693
+ )
694
+ if not stat.S_ISREG(status.st_mode):
695
+ raise TrustedLiveUnsupportedSourceError(
696
+ f"The trusted database {description} is not a regular file: {path}"
697
+ )
698
+ return status
699
+
700
+
701
+ def _required_file_identity(
702
+ path: Path,
703
+ description: str,
704
+ ) -> DatabaseFileIdentity:
705
+ _require_regular_file(path, description)
706
+ try:
707
+ identity = checked_path_bound_file_identity(path)
708
+ except FileIdentityHandleCloseError:
709
+ # The constructor handles this separately: an uncertain Windows HANDLE
710
+ # close must quarantine the process session, not look like ordinary I/O.
711
+ raise
712
+ except OSError as error:
713
+ raise TrustedLiveSourceIOError(
714
+ f"Could not identify the trusted database {description} {path}: {error}"
715
+ ) from error
716
+ if identity is None:
717
+ raise TrustedLiveUnsupportedSourceError(
718
+ f"Could not establish a stable identity for the trusted database "
719
+ f"{description} {path}."
720
+ )
721
+ return identity
722
+
723
+
724
+ def _optional_file_identity(
725
+ path: Path,
726
+ description: str,
727
+ ) -> DatabaseFileIdentity | None:
728
+ try:
729
+ path.lstat()
730
+ except FileNotFoundError:
731
+ return None
732
+ except OSError as error:
733
+ raise TrustedLiveSourceIOError(
734
+ f"Could not inspect the trusted database {description} {path}: {error}"
735
+ ) from error
736
+ return _required_file_identity(path, description)
737
+
738
+
739
+ def _capture_database_instance_for_trusted_open(
740
+ database_path: str | os.PathLike[str],
741
+ ) -> tuple[DatabaseInstance, DatabaseFileIdentity]:
742
+ """Capture UI-comparable and native identities without unchecked handles."""
743
+
744
+ selected_path = Path(os.path.abspath(os.fspath(database_path)))
745
+ _reject_windows_alternate_data_stream(selected_path)
746
+ _reject_symlink_path_components(selected_path)
747
+ logical_path = logical_database_path(database_path)
748
+ resolved_path = canonical_database_path(logical_path)
749
+ status = _require_regular_file(Path(resolved_path), "main file")
750
+ native_identity = _required_file_identity(Path(resolved_path), "main file")
751
+
752
+ inode = int(getattr(status, "st_ino", 0) or 0)
753
+ if inode:
754
+ comparable_identity: DatabaseFileIdentity = (
755
+ int(getattr(status, "st_dev", 0) or 0),
756
+ inode,
757
+ )
758
+ else:
759
+ # On Windows the ordinary UI identity helper falls back to the same
760
+ # volume/file-index identity. Using the checked observation here keeps
761
+ # expected-instance comparisons compatible without an unchecked HANDLE.
762
+ comparable_identity = native_identity
763
+ return (
764
+ DatabaseInstance(
765
+ logical_path=logical_path,
766
+ resolved_path=resolved_path,
767
+ identity=comparable_identity,
768
+ ),
769
+ native_identity,
770
+ )
771
+
772
+
773
+ def _database_header_journal_mode(database_path: Path) -> str:
774
+ open_flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
775
+ open_flags |= getattr(os, "O_CLOEXEC", 0)
776
+ open_flags |= getattr(os, "O_NOINHERIT", 0)
777
+ open_flags |= getattr(os, "O_NOFOLLOW", 0)
778
+ try:
779
+ file_descriptor = os.open(database_path, open_flags)
780
+ try:
781
+ header_parts: list[bytes] = []
782
+ remaining = _SQLITE_HEADER_PREFIX_BYTES
783
+ while remaining:
784
+ part = os.read(file_descriptor, remaining)
785
+ if not part:
786
+ break
787
+ header_parts.append(part)
788
+ remaining -= len(part)
789
+ header = b"".join(header_parts)
790
+ finally:
791
+ _close_preflight_file_descriptor(file_descriptor)
792
+ except _PreflightFileDescriptorCloseError:
793
+ raise
794
+ except OSError as error:
795
+ raise TrustedLiveSourceIOError(
796
+ f"Could not read the SQLite header of {database_path}: {error}"
797
+ ) from error
798
+ if len(header) < _SQLITE_HEADER_PREFIX_BYTES or not header.startswith(
799
+ b"SQLite format 3\x00"
800
+ ):
801
+ raise TrustedLiveInvalidDatabaseError(
802
+ f"The selected file is not a complete SQLite 3 database: {database_path}"
803
+ )
804
+ write_version = header[_SQLITE_HEADER_WRITE_VERSION_OFFSET]
805
+ read_version = header[_SQLITE_HEADER_READ_VERSION_OFFSET]
806
+ if (write_version, read_version) == (
807
+ _SQLITE_WAL_FORMAT,
808
+ _SQLITE_WAL_FORMAT,
809
+ ):
810
+ return "wal"
811
+ if (write_version, read_version) == (
812
+ _SQLITE_ROLLBACK_FORMAT,
813
+ _SQLITE_ROLLBACK_FORMAT,
814
+ ):
815
+ return "rollback"
816
+ raise TrustedLiveUnsupportedSourceError(
817
+ "The selected database uses unsupported SQLite header read/write "
818
+ f"versions {read_version}/{write_version}."
819
+ )
820
+
821
+
822
+ def _close_preflight_file_descriptor(file_descriptor: int) -> None:
823
+ """Close one preflight fd exactly once and surface uncertain cleanup."""
824
+
825
+ try:
826
+ os.close(file_descriptor)
827
+ except OSError as error:
828
+ # In particular, never retry close(2) after EINTR: supported kernels
829
+ # may already have consumed and reassigned the descriptor number.
830
+ raise _PreflightFileDescriptorCloseError(*error.args) from error
831
+
832
+
833
+ def _capture_source_identity(
834
+ database_path: str | os.PathLike[str],
835
+ ) -> tuple[TrustedLiveSourceIdentity, DatabaseFileIdentity]:
836
+ instance, main_identity = _capture_database_instance_for_trusted_open(database_path)
837
+ resolved = Path(instance.resolved_path)
838
+ # This is the sole source-content read before SQLite opens the database.
839
+ # All validation after xOpen goes through the token-gated native proof.
840
+ journal_mode = _database_header_journal_mode(resolved)
841
+ wal_path = Path(f"{resolved}-wal")
842
+ shm_path = Path(f"{resolved}-shm")
843
+ journal_path = Path(f"{resolved}-journal")
844
+ wal_identity = _optional_file_identity(wal_path, "WAL file")
845
+ shm_identity = _optional_file_identity(shm_path, "SHM file")
846
+ journal_identity = _optional_file_identity(journal_path, "rollback journal")
847
+ instance = DatabaseInstance(
848
+ logical_path=instance.logical_path,
849
+ resolved_path=instance.resolved_path,
850
+ identity=instance.identity,
851
+ # Keep this UI-facing instance comparable with database_instance().
852
+ # On Windows, the checked native identities above deliberately use
853
+ # volume/file-index tuples while ordinary UI observations prefer the
854
+ # non-zero device/inode values exposed by Python. The native values
855
+ # remain on TrustedLiveSourceIdentity for the VFS proof below.
856
+ sidecar_identities=database_sidecar_identities(
857
+ instance.logical_path,
858
+ instance.resolved_path,
859
+ ),
860
+ )
861
+
862
+ if journal_mode == "wal":
863
+ if journal_identity is not None:
864
+ raise TrustedLiveUnsupportedSourceError(
865
+ "The trusted WAL database also has a rollback journal; the "
866
+ "ambiguous source family was rejected."
867
+ )
868
+ else:
869
+ if any(
870
+ identity is not None
871
+ for identity in (wal_identity, shm_identity, journal_identity)
872
+ ):
873
+ raise TrustedLiveUnsupportedSourceError(
874
+ "Direct rollback-mode reads require a sidecar-free database. "
875
+ "Use qPlot's existing private-snapshot path for retained or "
876
+ "hot journals."
877
+ )
878
+
879
+ return (
880
+ TrustedLiveSourceIdentity(
881
+ database_instance=instance,
882
+ wal_identity=wal_identity,
883
+ shm_identity=shm_identity,
884
+ journal_identity=journal_identity,
885
+ journal_mode=journal_mode,
886
+ ),
887
+ main_identity,
888
+ )
889
+
890
+
891
+ def _native_expected_identity_parameters(
892
+ parameter_prefix: str,
893
+ identity: DatabaseFileIdentity | None,
894
+ *,
895
+ allow_absent: bool,
896
+ ) -> str:
897
+ """Encode one expected source identity for the pinned native VFS."""
898
+
899
+ if identity is None:
900
+ if not allow_absent:
901
+ raise TrustedLiveUnsupportedSourceError(
902
+ "The selected database has no stable main-file identity."
903
+ )
904
+ return f"{parameter_prefix}_kind=absent"
905
+
906
+ if len(identity) == 2 and type(identity[0]) is int and type(identity[1]) is int:
907
+ identity_kind = "posix"
908
+ identity_a, identity_b = identity
909
+ elif (
910
+ len(identity) == 3
911
+ and identity[0] == "windows-file-id"
912
+ and type(identity[1]) is int
913
+ and type(identity[2]) is int
914
+ ):
915
+ identity_kind = "windows"
916
+ identity_a, identity_b = identity[1:]
917
+ else:
918
+ raise TrustedLiveUnsupportedSourceError(
919
+ "The selected database filesystem does not expose an identity "
920
+ "supported by qPlot's native trusted reader."
921
+ )
922
+ if identity_a < 0 or identity_b <= 0:
923
+ raise TrustedLiveUnsupportedSourceError(
924
+ "The selected database returned an invalid operating-system identity."
925
+ )
926
+ return (
927
+ f"{parameter_prefix}_kind={identity_kind}"
928
+ f"&{parameter_prefix}_a={identity_a:x}"
929
+ f"&{parameter_prefix}_b={identity_b:x}"
930
+ )
931
+
932
+
933
+ def _sqlite_uri_path(path: str) -> str:
934
+ # Preserve separators and the Windows drive colon; quote URI delimiters,
935
+ # whitespace, and non-ASCII bytes so native URI parameters stay distinct.
936
+ return quote(path, safe="/:\\")
937
+
938
+
939
+ class TrustedLiveReader:
940
+ """One owner-thread-bound connection offering only finite read jobs.
941
+
942
+ ``query`` and ``query_batch`` own their complete transaction lifetime and
943
+ always materialise results before returning. Their default timeout is
944
+ finite. Callers may supply a shorter or longer timeout, an absolute
945
+ monotonic deadline, and a :class:`threading.Event` cancellation signal.
946
+
947
+ Python/SQLite progress and busy handlers provide cooperative in-process
948
+ interruption. :meth:`interrupt` additionally calls ``sqlite3_interrupt``
949
+ and is safe from a different thread. These mechanisms do not forcibly
950
+ pre-empt arbitrary stalled Python or operating-system code; the later
951
+ helper-process stage provides that hard failure boundary.
952
+ """
953
+
954
+ def __init__(
955
+ self,
956
+ database_path: str | os.PathLike[str],
957
+ *,
958
+ expected_database_instance: DatabaseInstance | None = None,
959
+ busy_timeout_ms: int = 5_000,
960
+ operation_timeout_seconds: float = (DEFAULT_TRUSTED_OPERATION_TIMEOUT_SECONDS),
961
+ _test_race_artifact: str | None = None,
962
+ _test_cleanup_fault: str | None = None,
963
+ _test_statement_limit_fault: str | None = None,
964
+ _test_pre_open_callback: (
965
+ Callable[[TrustedLiveReaderToken, Path], None] | None
966
+ ) = None,
967
+ ) -> None:
968
+ self._owner_thread = threading.get_ident()
969
+ self._closed = False
970
+ self._invalidated = False
971
+ self._internal_transaction_control = False
972
+ self._connection: apsw.Connection | None = None
973
+ self._bootstrap: apsw.Connection | None = None
974
+ self._final_audit: TrustedVfsAudit | None = None
975
+ self._temporary_directory: tempfile.TemporaryDirectory[str] | None = None
976
+ self._native_temporary_path: Path | None = None
977
+ self._token_string = secrets.token_hex(16)
978
+ self._process_session_owner = object()
979
+ self._operation_mutex = threading.Lock()
980
+ self._active_operation: _TrustedOperationControl | None = None
981
+ self._next_operation_generation = 1
982
+ self._native_extension_loaded = False
983
+ self._native_session_attempted = False
984
+ self._native_release_confirmed = False
985
+ self._resource_cleanup_complete = False
986
+ self._resource_cleanup_error: TrustedLiveCleanupError | None = None
987
+ self._preflight_cleanup_error: OSError | None = None
988
+ # Arm the private statement-limit fault only after construction's
989
+ # bootstrap operations have completed.
990
+ self._test_statement_limit_fault: str | None = None
991
+ self._test_statement_limit_fault_consumed = False
992
+ self._default_operation_timeout_seconds = self._validated_duration(
993
+ operation_timeout_seconds,
994
+ description="default trusted-reader operation timeout",
995
+ allow_zero=False,
996
+ )
997
+ if (
998
+ type(busy_timeout_ms) is not int
999
+ or busy_timeout_ms < 0
1000
+ or busy_timeout_ms > 2_147_483_647
1001
+ ):
1002
+ raise ValueError(
1003
+ "busy_timeout_ms must be an integer from 0 through 2147483647."
1004
+ )
1005
+ self._busy_timeout_seconds = busy_timeout_ms / 1_000
1006
+ if _test_race_artifact not in {None, "main", "wal", "shm"}:
1007
+ raise ValueError(
1008
+ "_test_race_artifact must be None, 'main', 'wal', or 'shm'."
1009
+ )
1010
+ if _test_cleanup_fault not in {
1011
+ None,
1012
+ "proof_close",
1013
+ "shm_unmap",
1014
+ "base_close",
1015
+ }:
1016
+ raise ValueError(
1017
+ "_test_cleanup_fault must be None, 'proof_close', "
1018
+ "'shm_unmap', or 'base_close'."
1019
+ )
1020
+ if _test_statement_limit_fault not in {
1021
+ None,
1022
+ "statement_limit_install",
1023
+ "statement_limit_verify",
1024
+ "statement_limit_restore",
1025
+ }:
1026
+ raise ValueError(
1027
+ "_test_statement_limit_fault must be None, "
1028
+ "'statement_limit_install', 'statement_limit_verify', or "
1029
+ "'statement_limit_restore'."
1030
+ )
1031
+ if _test_pre_open_callback is not None and not callable(
1032
+ _test_pre_open_callback
1033
+ ):
1034
+ raise TypeError("_test_pre_open_callback must be callable or None.")
1035
+
1036
+ try:
1037
+ # Claim before any source stat/open. On POSIX, even opening and
1038
+ # closing a second fd for a database inode would release fcntl
1039
+ # locks held by an existing reader in this process.
1040
+ _claim_process_session(self._process_session_owner)
1041
+ _check_pinned_sqlite_runtime()
1042
+ source, main_identity = _capture_source_identity(database_path)
1043
+ if expected_database_instance is not None and database_instances_differ(
1044
+ expected_database_instance,
1045
+ source.database_instance,
1046
+ ):
1047
+ raise TrustedLiveSourceChangedError(
1048
+ "A checked identity observation proved that the selected "
1049
+ "database differs from the instance approved before preflight."
1050
+ )
1051
+ self._source = source
1052
+ self._token = TrustedLiveReaderToken(source, self._token_string)
1053
+
1054
+ self._temporary_directory = tempfile.TemporaryDirectory(
1055
+ prefix="qplot-trusted-live-"
1056
+ )
1057
+ temporary_path = Path(self._temporary_directory.name)
1058
+ # macOS commonly spells its private temporary root through the
1059
+ # `/var -> /private/var` compatibility symlink. Canonicalise only
1060
+ # this application-created directory so native proof handles never
1061
+ # need to weaken source-path symlink rejection.
1062
+ if os.name != "nt":
1063
+ temporary_path = Path(os.path.realpath(temporary_path))
1064
+ self._native_temporary_path = temporary_path
1065
+ try:
1066
+ self._bootstrap = apsw.Connection(":memory:")
1067
+ self._bootstrap.config(
1068
+ apsw.SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION,
1069
+ 1,
1070
+ )
1071
+ try:
1072
+ self._bootstrap.load_extension(
1073
+ str(_native_extension_path()),
1074
+ _NATIVE_ENTRYPOINT,
1075
+ )
1076
+ self._native_extension_loaded = True
1077
+ finally:
1078
+ self._bootstrap.config(
1079
+ apsw.SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION,
1080
+ 0,
1081
+ )
1082
+ except apsw.Error as error:
1083
+ raise TrustedLiveReaderUnavailableError(
1084
+ "qPlot's pinned native trusted-reader boundary could not "
1085
+ "be initialised."
1086
+ ) from error
1087
+
1088
+ resolved_path = source.database_instance.resolved_path
1089
+ final_preopen_instance, final_main_identity = (
1090
+ _capture_database_instance_for_trusted_open(
1091
+ source.database_instance.logical_path
1092
+ )
1093
+ )
1094
+ if database_instances_differ(
1095
+ source.database_instance,
1096
+ final_preopen_instance,
1097
+ ) or (
1098
+ expected_database_instance is not None
1099
+ and database_instances_differ(
1100
+ expected_database_instance,
1101
+ final_preopen_instance,
1102
+ )
1103
+ ):
1104
+ raise TrustedLiveSourceChangedError(
1105
+ "Checked identity observations proved that the selected "
1106
+ "database changed during trusted-reader preflight."
1107
+ )
1108
+ if final_main_identity != main_identity:
1109
+ raise TrustedLiveSourceChangedError(
1110
+ "Checked native identities proved that the selected database "
1111
+ "changed during trusted-reader preflight."
1112
+ )
1113
+ expected_identity_query = "&".join(
1114
+ (
1115
+ _native_expected_identity_parameters(
1116
+ "qplot_expected",
1117
+ main_identity,
1118
+ allow_absent=False,
1119
+ ),
1120
+ _native_expected_identity_parameters(
1121
+ "qplot_expected_wal",
1122
+ source.wal_identity,
1123
+ allow_absent=True,
1124
+ ),
1125
+ _native_expected_identity_parameters(
1126
+ "qplot_expected_shm",
1127
+ source.shm_identity,
1128
+ allow_absent=True,
1129
+ ),
1130
+ )
1131
+ )
1132
+ query = (
1133
+ f"qplot_token={quote(self._token_string, safe='')}"
1134
+ "&qplot_temp="
1135
+ f"{_sqlite_uri_path(os.fspath(temporary_path))}"
1136
+ f"&{expected_identity_query}"
1137
+ )
1138
+ if _test_race_artifact is not None:
1139
+ query += f"&qplot_test_race={_test_race_artifact}"
1140
+ if _test_cleanup_fault is not None:
1141
+ query += f"&qplot_test_cleanup_fault={_test_cleanup_fault}"
1142
+ uri = f"{Path(resolved_path).as_uri()}?{query}"
1143
+ if _test_pre_open_callback is not None:
1144
+ _test_pre_open_callback(
1145
+ self._token,
1146
+ temporary_path,
1147
+ )
1148
+ if (
1149
+ _optional_file_identity(
1150
+ Path(f"{resolved_path}-journal"),
1151
+ "rollback journal",
1152
+ )
1153
+ is not None
1154
+ ):
1155
+ raise TrustedLiveUnsupportedSourceError(
1156
+ "A rollback journal appeared during trusted-reader preflight; "
1157
+ "the ambiguous source family was rejected."
1158
+ )
1159
+ self._native_session_attempted = True
1160
+ self._connection = apsw.Connection(
1161
+ uri,
1162
+ flags=apsw.SQLITE_OPEN_READONLY | apsw.SQLITE_OPEN_URI,
1163
+ vfs=TRUSTED_READER_VFS_NAME,
1164
+ statementcachesize=0,
1165
+ )
1166
+ self._connection.config(
1167
+ apsw.SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE,
1168
+ 1,
1169
+ )
1170
+ self._connection.config(apsw.SQLITE_DBCONFIG_DEFENSIVE, 1)
1171
+ self._connection.config(
1172
+ apsw.SQLITE_DBCONFIG_TRUSTED_SCHEMA,
1173
+ 0,
1174
+ )
1175
+ # The raw/logical pre-yield row proof owns APSW's standard
1176
+ # tuple/scalar conversion path. A row trace or JSONB converter
1177
+ # could replace a bounded SQLite value with an arbitrary object.
1178
+ self._connection.row_trace = None
1179
+ self._connection.convert_jsonb = None
1180
+ # Each finite operation installs its own deadline-aware busy
1181
+ # handler. There must be no independent unbounded SQLite wait.
1182
+ self._connection.set_busy_timeout(0)
1183
+ self._connection.pragma("query_only", True)
1184
+ self._connection.pragma("temp_store", "MEMORY")
1185
+ self._connection.pragma("mmap_size", 0)
1186
+ # A bounded page cache lets repeated summary/axis queries over a
1187
+ # small plot interval reuse pages without repeated source I/O.
1188
+ self._connection.pragma("cache_size", -16_384)
1189
+ reader_reference = weakref.ref(self)
1190
+
1191
+ def read_blob(*arguments):
1192
+ reader = reader_reference()
1193
+ if reader is None:
1194
+ raise TrustedLiveReaderClosedError("The BLOB reader is closed.")
1195
+ return reader._read_blob_chunk(*arguments)
1196
+
1197
+ self._connection.create_scalar_function(
1198
+ "qplot_read_blob", read_blob, 5,
1199
+ flags=apsw.SQLITE_DIRECTONLY,
1200
+ )
1201
+ self._connection.authorizer = self._authorize
1202
+ if not self._connection.readonly("main"):
1203
+ raise TrustedLiveReaderUnavailableError(
1204
+ "The native VFS did not report a read-only main database."
1205
+ )
1206
+ self._validate_native_source()
1207
+ # Force schema and any currently present WAL/SHM opening while the
1208
+ # native expected-main proof is still part of construction.
1209
+ self.data_version()
1210
+ self.query("SELECT name FROM sqlite_schema LIMIT 1")
1211
+ self._test_statement_limit_fault = _test_statement_limit_fault
1212
+ except apsw.Error as error:
1213
+ translated = self._translate_sqlite_error(error, native_sequence=None)
1214
+ self._closed = True
1215
+ cleanup_error = self._close_resources(force=True)
1216
+ if cleanup_error is not None:
1217
+ translated.__cause__ = error
1218
+ raise cleanup_error from translated
1219
+ raise translated from error
1220
+ except (
1221
+ FileIdentityHandleCloseError,
1222
+ _PreflightFileDescriptorCloseError,
1223
+ ) as error:
1224
+ self._preflight_cleanup_error = error
1225
+ self._closed = True
1226
+ cleanup_error = self._close_resources(force=True)
1227
+ if cleanup_error is None:
1228
+ cleanup_error = TrustedLiveCleanupError(
1229
+ "The trusted reader could not prove release of a preflight "
1230
+ "source handle; this process is quarantined."
1231
+ )
1232
+ raise cleanup_error from error
1233
+ except BaseException as error:
1234
+ self._closed = True
1235
+ cleanup_error = self._close_resources(force=True)
1236
+ if cleanup_error is not None:
1237
+ if cleanup_error is error:
1238
+ raise
1239
+ raise cleanup_error from error
1240
+ raise
1241
+
1242
+ @classmethod
1243
+ def open(
1244
+ cls,
1245
+ database_path: str | os.PathLike[str],
1246
+ *,
1247
+ expected_database_instance: DatabaseInstance | None = None,
1248
+ busy_timeout_ms: int = 5_000,
1249
+ operation_timeout_seconds: float = (DEFAULT_TRUSTED_OPERATION_TIMEOUT_SECONDS),
1250
+ _test_race_artifact: str | None = None,
1251
+ _test_cleanup_fault: str | None = None,
1252
+ _test_statement_limit_fault: str | None = None,
1253
+ _test_pre_open_callback: (
1254
+ Callable[[TrustedLiveReaderToken, Path], None] | None
1255
+ ) = None,
1256
+ ) -> TrustedLiveReader:
1257
+ """Open the selected source directly, with no snapshot fallback.
1258
+
1259
+ ``operation_timeout_seconds`` is the finite default for later calls
1260
+ which do not provide their own ``timeout`` or ``deadline``.
1261
+ """
1262
+ return cls(
1263
+ database_path,
1264
+ expected_database_instance=expected_database_instance,
1265
+ busy_timeout_ms=busy_timeout_ms,
1266
+ operation_timeout_seconds=operation_timeout_seconds,
1267
+ _test_race_artifact=_test_race_artifact,
1268
+ _test_cleanup_fault=_test_cleanup_fault,
1269
+ _test_statement_limit_fault=_test_statement_limit_fault,
1270
+ _test_pre_open_callback=_test_pre_open_callback,
1271
+ )
1272
+
1273
+ @property
1274
+ def source_identity(self) -> TrustedLiveSourceIdentity:
1275
+ return self._source
1276
+
1277
+ @property
1278
+ def token(self) -> TrustedLiveReaderToken:
1279
+ return self._token
1280
+
1281
+ @property
1282
+ def closed(self) -> bool:
1283
+ return self._closed
1284
+
1285
+ @property
1286
+ def temporary_directory(self) -> Path | None:
1287
+ temporary_directory = self._temporary_directory
1288
+ if temporary_directory is None:
1289
+ return None
1290
+ return self._native_temporary_path
1291
+
1292
+ def _check_thread(self) -> None:
1293
+ if threading.get_ident() != self._owner_thread:
1294
+ raise TrustedLiveReaderThreadError(
1295
+ "Trusted live readers may only be used by their owner thread."
1296
+ )
1297
+
1298
+ def _require_open(self) -> apsw.Connection:
1299
+ self._check_thread()
1300
+ if self._invalidated:
1301
+ raise TrustedLiveSourceChangedError(
1302
+ "The trusted database instance changed; close this reader and "
1303
+ "open a new one explicitly."
1304
+ )
1305
+ if self._closed or self._connection is None:
1306
+ raise TrustedLiveReaderClosedError("The trusted live reader is closed.")
1307
+ return self._connection
1308
+
1309
+ @staticmethod
1310
+ def _validated_duration(
1311
+ value: float,
1312
+ *,
1313
+ description: str,
1314
+ allow_zero: bool,
1315
+ ) -> float:
1316
+ if isinstance(value, bool):
1317
+ raise ValueError(f"{description} must be a finite number of seconds.")
1318
+ try:
1319
+ duration = float(value)
1320
+ except (TypeError, ValueError) as error:
1321
+ raise ValueError(
1322
+ f"{description} must be a finite number of seconds."
1323
+ ) from error
1324
+ if (
1325
+ not math.isfinite(duration)
1326
+ or duration < 0
1327
+ or (duration == 0 and not allow_zero)
1328
+ ):
1329
+ qualifier = "non-negative" if allow_zero else "positive"
1330
+ raise ValueError(
1331
+ f"{description} must be a finite {qualifier} number of seconds."
1332
+ )
1333
+ return duration
1334
+
1335
+ def _operation_deadline(
1336
+ self,
1337
+ *,
1338
+ timeout: float | None,
1339
+ deadline: float | None,
1340
+ ) -> tuple[float, float]:
1341
+ started_at = time.monotonic()
1342
+ candidates: list[float] = []
1343
+ if timeout is not None:
1344
+ candidates.append(
1345
+ started_at
1346
+ + self._validated_duration(
1347
+ timeout,
1348
+ description="trusted-reader operation timeout",
1349
+ allow_zero=True,
1350
+ )
1351
+ )
1352
+ if deadline is not None:
1353
+ if isinstance(deadline, bool):
1354
+ raise ValueError("deadline must be a finite monotonic timestamp.")
1355
+ try:
1356
+ absolute_deadline = float(deadline)
1357
+ except (TypeError, ValueError) as error:
1358
+ raise ValueError(
1359
+ "deadline must be a finite monotonic timestamp."
1360
+ ) from error
1361
+ if not math.isfinite(absolute_deadline):
1362
+ raise ValueError("deadline must be a finite monotonic timestamp.")
1363
+ candidates.append(absolute_deadline)
1364
+ if not candidates:
1365
+ candidates.append(started_at + self._default_operation_timeout_seconds)
1366
+ return started_at, min(candidates)
1367
+
1368
+ def interrupt(self) -> bool:
1369
+ """Cancel the currently active finite operation from any thread.
1370
+
1371
+ The request is generation-bound: calling this method while the reader
1372
+ is idle does not poison the next operation. ``True`` means an active
1373
+ operation was marked and ``sqlite3_interrupt`` was invoked.
1374
+ """
1375
+
1376
+ with self._operation_mutex:
1377
+ control = self._active_operation
1378
+ connection = self._connection
1379
+ if control is None or connection is None:
1380
+ return False
1381
+ if control.abort_reason is None:
1382
+ control.abort_reason = "cancelled"
1383
+ # Keep publication and sqlite3_interrupt() in one generation lock.
1384
+ # Otherwise the old operation could finish and a new one could be
1385
+ # published between these two actions, receiving a stale interrupt.
1386
+ connection.interrupt()
1387
+ return True
1388
+
1389
+ def _mark_operation_abort(
1390
+ self,
1391
+ control: _TrustedOperationControl,
1392
+ reason: str,
1393
+ ) -> None:
1394
+ with self._operation_mutex:
1395
+ # Cleanup clears the published active generation before consulting
1396
+ # the native control channel. It may still need to record a
1397
+ # deadline/cancellation against that same, now unpublished control.
1398
+ if (
1399
+ self._active_operation is None or self._active_operation is control
1400
+ ) and control.abort_reason is None:
1401
+ control.abort_reason = reason
1402
+
1403
+ def _operation_abort_reason(
1404
+ self,
1405
+ control: _TrustedOperationControl,
1406
+ ) -> str | None:
1407
+ with self._operation_mutex:
1408
+ return control.abort_reason
1409
+
1410
+ def _raise_if_operation_aborted(
1411
+ self,
1412
+ control: _TrustedOperationControl,
1413
+ ) -> None:
1414
+ cancel_event = control.cancel_event
1415
+ if cancel_event is not None and cancel_event.is_set():
1416
+ self._mark_operation_abort(control, "cancelled")
1417
+ if time.monotonic() >= control.deadline:
1418
+ self._mark_operation_abort(control, "deadline")
1419
+ reason = self._operation_abort_reason(control)
1420
+ if reason == "cancelled":
1421
+ raise TrustedLiveCancelledError("The trusted database read was cancelled.")
1422
+ if reason == "deadline":
1423
+ raise TrustedLiveDeadlineExceededError(
1424
+ "The trusted database read deadline was exceeded."
1425
+ )
1426
+
1427
+ def _progress_handler(self, control: _TrustedOperationControl) -> bool:
1428
+ try:
1429
+ self._raise_if_operation_aborted(control)
1430
+ except (TrustedLiveCancelledError, TrustedLiveDeadlineExceededError):
1431
+ return True
1432
+ return False
1433
+
1434
+ def _busy_handler(
1435
+ self,
1436
+ control: _TrustedOperationControl,
1437
+ prior_calls: int,
1438
+ ) -> bool:
1439
+ del prior_calls
1440
+ try:
1441
+ self._raise_if_operation_aborted(control)
1442
+ except (TrustedLiveCancelledError, TrustedLiveDeadlineExceededError):
1443
+ return False
1444
+ now = time.monotonic()
1445
+ if now >= control.busy_deadline:
1446
+ return False
1447
+ delay = min(_BUSY_RETRY_QUANTUM_SECONDS, control.busy_deadline - now)
1448
+ cancel_event = control.cancel_event
1449
+ if cancel_event is None:
1450
+ time.sleep(delay)
1451
+ else:
1452
+ cancel_event.wait(delay)
1453
+ try:
1454
+ self._raise_if_operation_aborted(control)
1455
+ except (TrustedLiveCancelledError, TrustedLiveDeadlineExceededError):
1456
+ return False
1457
+ return time.monotonic() < control.busy_deadline
1458
+
1459
+ def _start_operation(
1460
+ self,
1461
+ *,
1462
+ timeout: float | None,
1463
+ deadline: float | None,
1464
+ cancel_event: threading.Event | None,
1465
+ ) -> tuple[apsw.Connection, _TrustedOperationControl]:
1466
+ connection = self._require_open()
1467
+ if cancel_event is not None and not isinstance(cancel_event, threading.Event):
1468
+ raise TypeError("cancel_event must be a threading.Event or None.")
1469
+ started_at, operation_deadline = self._operation_deadline(
1470
+ timeout=timeout,
1471
+ deadline=deadline,
1472
+ )
1473
+ with self._operation_mutex:
1474
+ if self._active_operation is not None:
1475
+ raise TrustedLiveTransactionError(
1476
+ "Nested trusted-reader operations are not supported."
1477
+ )
1478
+ generation = self._next_operation_generation
1479
+ self._next_operation_generation += 1
1480
+ control = _TrustedOperationControl(
1481
+ generation=generation,
1482
+ started_at=started_at,
1483
+ deadline=operation_deadline,
1484
+ busy_deadline=min(
1485
+ operation_deadline,
1486
+ started_at + self._busy_timeout_seconds,
1487
+ ),
1488
+ cancel_event=cancel_event,
1489
+ )
1490
+ self._active_operation = control
1491
+ return connection, control
1492
+
1493
+ @staticmethod
1494
+ def _progress_handler_id(control: _TrustedOperationControl) -> tuple[str, int]:
1495
+ return ("qplot-trusted-operation", control.generation)
1496
+
1497
+ def _install_operation_handlers(
1498
+ self,
1499
+ connection: apsw.Connection,
1500
+ control: _TrustedOperationControl,
1501
+ ) -> None:
1502
+ connection.set_progress_handler(
1503
+ lambda: self._progress_handler(control),
1504
+ _PROGRESS_HANDLER_STEPS,
1505
+ id=self._progress_handler_id(control),
1506
+ )
1507
+ connection.set_busy_handler(
1508
+ lambda prior_calls: self._busy_handler(control, prior_calls)
1509
+ )
1510
+
1511
+ def _remove_operation_handlers(
1512
+ self,
1513
+ connection: apsw.Connection,
1514
+ control: _TrustedOperationControl,
1515
+ ) -> list[BaseException]:
1516
+ errors: list[BaseException] = []
1517
+ try:
1518
+ connection.set_progress_handler(
1519
+ None,
1520
+ id=self._progress_handler_id(control),
1521
+ )
1522
+ except BaseException as error:
1523
+ errors.append(error)
1524
+ try:
1525
+ connection.set_busy_handler(None)
1526
+ except BaseException as error:
1527
+ errors.append(error)
1528
+ return errors
1529
+
1530
+ def _read_blob_chunk(self, *arguments: Any) -> bytes:
1531
+ """Read a bounded slice without materialising SQLite's entire value.
1532
+
1533
+ The SQL entry point shares query_batch's transaction, source proof,
1534
+ cancellation, result limits, and cleanup. It opens only main-database
1535
+ incremental BLOB handles with writeable=False, and exposes no handle.
1536
+ """
1537
+ self._check_thread()
1538
+ if len(arguments) != 5:
1539
+ raise TrustedLiveSqlRejectedError("BLOB reads require five arguments.")
1540
+ table, column, row_id, offset, length = arguments
1541
+ control = self._active_operation
1542
+ connection = self._connection
1543
+ if control is None or connection is None:
1544
+ raise TrustedLiveSqlRejectedError("BLOB reads require an active finite query.")
1545
+ self._raise_if_operation_aborted(control)
1546
+ if (not isinstance(table, str) or not isinstance(column, str)
1547
+ or not 0 < len(table) <= 1024 or not 0 < len(column) <= 1024
1548
+ or "\x00" in table or "\x00" in column
1549
+ or type(row_id) is not int or type(offset) is not int
1550
+ or type(length) is not int or offset < 0
1551
+ or not 0 <= length <= 256 * 1024):
1552
+ raise TrustedLiveSqlRejectedError("Invalid bounded BLOB read arguments.")
1553
+ if length > connection.limit(apsw.SQLITE_LIMIT_LENGTH):
1554
+ raise TrustedLiveResultLimitError("BLOB chunk exceeds the statement value limit.")
1555
+ blob = connection.blob_open("main", table, column, row_id, False)
1556
+ try:
1557
+ self._raise_if_operation_aborted(control)
1558
+ blob.seek(offset)
1559
+ value = blob.read(length)
1560
+ self._raise_if_operation_aborted(control)
1561
+ return value
1562
+ finally:
1563
+ try:
1564
+ blob.close()
1565
+ except BaseException as error:
1566
+ control.blob_cleanup_error = TrustedLiveCleanupError(
1567
+ "Could not prove incremental BLOB handle cleanup."
1568
+ )
1569
+ raise control.blob_cleanup_error from error
1570
+
1571
+ def _record_policy_denial(self) -> int:
1572
+ """Mark the active operation and return SQLite's deny result."""
1573
+
1574
+ with self._operation_mutex:
1575
+ control = self._active_operation
1576
+ if control is not None:
1577
+ control.policy_denied = True
1578
+ return apsw.SQLITE_DENY
1579
+
1580
+ def _authorize(
1581
+ self,
1582
+ operation: int,
1583
+ parameter_one: str | None,
1584
+ parameter_two: str | None,
1585
+ database_name: str | None,
1586
+ trigger_or_view: str | None,
1587
+ ) -> int:
1588
+ del trigger_or_view
1589
+ if operation in {
1590
+ apsw.SQLITE_SELECT,
1591
+ apsw.SQLITE_READ,
1592
+ apsw.SQLITE_RECURSIVE,
1593
+ }:
1594
+ return apsw.SQLITE_OK
1595
+ if operation == apsw.SQLITE_FUNCTION:
1596
+ function_name = (parameter_two or "").casefold()
1597
+ if function_name in {
1598
+ "eval",
1599
+ "fts3_tokenizer",
1600
+ "load_extension",
1601
+ "readfile",
1602
+ "writefile",
1603
+ }:
1604
+ return self._record_policy_denial()
1605
+ return apsw.SQLITE_OK
1606
+ if operation == apsw.SQLITE_PRAGMA:
1607
+ pragma_name = (parameter_one or "").casefold()
1608
+ if (
1609
+ pragma_name in {
1610
+ "data_version", "user_version", "application_id",
1611
+ "page_count", "page_size",
1612
+ }
1613
+ and parameter_two is None
1614
+ and database_name in {None, "main"}
1615
+ ):
1616
+ return apsw.SQLITE_OK
1617
+ return self._record_policy_denial()
1618
+ if operation == apsw.SQLITE_TRANSACTION and self._internal_transaction_control:
1619
+ return apsw.SQLITE_OK
1620
+ return self._record_policy_denial()
1621
+
1622
+ def _execute_transaction_control(
1623
+ self,
1624
+ sql: str,
1625
+ ) -> None:
1626
+ self._check_thread()
1627
+ connection = self._connection
1628
+ if self._closed or connection is None:
1629
+ raise TrustedLiveReaderClosedError("The trusted live reader is closed.")
1630
+ self._internal_transaction_control = True
1631
+ cursor: apsw.Cursor | None = None
1632
+ body_error: BaseException | None = None
1633
+ body_traceback: TracebackType | None = None
1634
+ try:
1635
+ cursor = connection.cursor()
1636
+ cursor.execute(sql).fetchall()
1637
+ except BaseException as error:
1638
+ body_error = error
1639
+ body_traceback = error.__traceback__
1640
+ finally:
1641
+ self._internal_transaction_control = False
1642
+ if cursor is not None:
1643
+ try:
1644
+ cursor.close()
1645
+ except BaseException as close_error:
1646
+ if body_error is None:
1647
+ body_error = close_error
1648
+ body_traceback = close_error.__traceback__
1649
+ else:
1650
+ body_error.add_note(
1651
+ f"SQLite cursor cleanup also failed: {close_error!r}"
1652
+ )
1653
+ if body_error is not None:
1654
+ raise body_error.with_traceback(body_traceback)
1655
+
1656
+ def _install_result_length_limit(
1657
+ self,
1658
+ connection: apsw.Connection,
1659
+ control: _TrustedOperationControl,
1660
+ ) -> _TrustedSqliteLengthLimit:
1661
+ """Install and verify the operation's absolute SQLite scalar cap."""
1662
+
1663
+ self._raise_if_operation_aborted(control)
1664
+ try:
1665
+ previous = connection.limit(apsw.SQLITE_LIMIT_LENGTH)
1666
+ except BaseException as error:
1667
+ raise TrustedLiveCleanupError(
1668
+ "The trusted reader could not inspect SQLite's prior "
1669
+ "result-length limit; the connection must be retired."
1670
+ ) from error
1671
+ effective = min(previous, TRUSTED_LIVE_MAX_SCALAR_BYTES)
1672
+ try:
1673
+ observed_previous = connection.limit(
1674
+ apsw.SQLITE_LIMIT_LENGTH,
1675
+ effective,
1676
+ )
1677
+ observed_effective = connection.limit(apsw.SQLITE_LIMIT_LENGTH)
1678
+ except BaseException as error:
1679
+ setup_error = TrustedLiveCleanupError(
1680
+ "The trusted reader could not install its SQLite result-length "
1681
+ "limit; the connection must be retired."
1682
+ )
1683
+ try:
1684
+ connection.limit(apsw.SQLITE_LIMIT_LENGTH, previous)
1685
+ restored = connection.limit(apsw.SQLITE_LIMIT_LENGTH)
1686
+ except BaseException as restore_error:
1687
+ setup_error.add_note(
1688
+ f"SQLite limit restoration also failed: {restore_error!r}"
1689
+ )
1690
+ else:
1691
+ if restored != previous:
1692
+ setup_error.add_note(
1693
+ "SQLite did not confirm restoration of its prior length limit."
1694
+ )
1695
+ raise setup_error from error
1696
+ if observed_previous != previous or observed_effective != effective:
1697
+ try:
1698
+ connection.limit(apsw.SQLITE_LIMIT_LENGTH, previous)
1699
+ except BaseException as restore_error:
1700
+ cleanup_failure = TrustedLiveCleanupError(
1701
+ "The trusted reader could neither verify nor restore its "
1702
+ "SQLite result-length limit."
1703
+ )
1704
+ cleanup_failure.add_note(
1705
+ f"SQLite limit restoration also failed: {restore_error!r}"
1706
+ )
1707
+ raise cleanup_failure from restore_error
1708
+ raise TrustedLiveCleanupError(
1709
+ "The trusted reader could not verify its SQLite result-length "
1710
+ "limit; the connection must be retired."
1711
+ )
1712
+ control.operation_result_length_limit = effective
1713
+ control.result_length_limit = effective
1714
+ return _TrustedSqliteLengthLimit(previous=previous, effective=effective)
1715
+
1716
+ def _consume_statement_limit_fault(self, phase: str) -> bool:
1717
+ if (
1718
+ not self._test_statement_limit_fault_consumed
1719
+ and self._test_statement_limit_fault == phase
1720
+ ):
1721
+ self._test_statement_limit_fault_consumed = True
1722
+ return True
1723
+ return False
1724
+
1725
+ @staticmethod
1726
+ def _note_best_effort_limit_restore(
1727
+ connection: apsw.Connection,
1728
+ target: int,
1729
+ setup_error: TrustedLiveCleanupError,
1730
+ ) -> None:
1731
+ """Try to regain a known limit after uncertain statement setup."""
1732
+
1733
+ try:
1734
+ connection.limit(apsw.SQLITE_LIMIT_LENGTH, target)
1735
+ restored = connection.limit(apsw.SQLITE_LIMIT_LENGTH)
1736
+ except BaseException as restore_error:
1737
+ setup_error.add_note(
1738
+ f"SQLite limit restoration also failed: {restore_error!r}"
1739
+ )
1740
+ else:
1741
+ if restored != target:
1742
+ setup_error.add_note(
1743
+ "SQLite did not confirm restoration of the operation-wide "
1744
+ "length limit."
1745
+ )
1746
+
1747
+ def _install_statement_result_length_limit(
1748
+ self,
1749
+ connection: apsw.Connection,
1750
+ control: _TrustedOperationControl,
1751
+ column_count: int,
1752
+ ) -> _TrustedSqliteLengthLimit:
1753
+ """Install the width-derived cap before APSW can produce one row."""
1754
+
1755
+ self._raise_if_operation_aborted(control)
1756
+ baseline = control.operation_result_length_limit
1757
+ if baseline is None:
1758
+ raise TrustedLiveCleanupError(
1759
+ "The trusted reader lost its operation-wide SQLite result-length "
1760
+ "baseline; the connection must be retired."
1761
+ )
1762
+ try:
1763
+ observed_baseline = connection.limit(apsw.SQLITE_LIMIT_LENGTH)
1764
+ row_trace = connection.row_trace
1765
+ convert_jsonb = connection.convert_jsonb
1766
+ except BaseException as error:
1767
+ raise TrustedLiveCleanupError(
1768
+ "The trusted reader could not inspect the SQLite row-conversion "
1769
+ "and result-length state; the connection must be retired."
1770
+ ) from error
1771
+ if observed_baseline != baseline:
1772
+ raise TrustedLiveCleanupError(
1773
+ "SQLite did not retain the verified operation-wide result-length "
1774
+ "baseline; the connection must be retired."
1775
+ )
1776
+ if row_trace is not None or convert_jsonb is not None:
1777
+ raise TrustedLiveCleanupError(
1778
+ "APSW's standard tuple/scalar row conversion was not active; "
1779
+ "the raw/logical pre-yield row bounds could not be proved."
1780
+ )
1781
+
1782
+ effective = _trusted_statement_length_limit(baseline, column_count)
1783
+ self._raise_if_operation_aborted(control)
1784
+ if self._consume_statement_limit_fault("statement_limit_install"):
1785
+ raise TrustedLiveCleanupError(
1786
+ "Injected per-statement SQLite result-length installation "
1787
+ "failure; the connection must be retired."
1788
+ )
1789
+ try:
1790
+ observed_previous = connection.limit(
1791
+ apsw.SQLITE_LIMIT_LENGTH,
1792
+ effective,
1793
+ )
1794
+ observed_effective = connection.limit(apsw.SQLITE_LIMIT_LENGTH)
1795
+ except BaseException as error:
1796
+ setup_error = TrustedLiveCleanupError(
1797
+ "The trusted reader could not install its per-statement SQLite "
1798
+ "result-length limit; the connection must be retired."
1799
+ )
1800
+ self._note_best_effort_limit_restore(connection, baseline, setup_error)
1801
+ raise setup_error from error
1802
+
1803
+ verification_fault = self._consume_statement_limit_fault(
1804
+ "statement_limit_verify"
1805
+ )
1806
+ if (
1807
+ verification_fault
1808
+ or observed_previous != baseline
1809
+ or observed_effective != effective
1810
+ ):
1811
+ setup_error = TrustedLiveCleanupError(
1812
+ "The trusted reader could not verify its per-statement SQLite "
1813
+ "result-length limit; the connection must be retired."
1814
+ )
1815
+ self._note_best_effort_limit_restore(connection, baseline, setup_error)
1816
+ raise setup_error
1817
+
1818
+ control.result_length_limit = effective
1819
+ return _TrustedSqliteLengthLimit(previous=baseline, effective=effective)
1820
+
1821
+ def _restore_statement_result_length_limit(
1822
+ self,
1823
+ connection: apsw.Connection,
1824
+ control: _TrustedOperationControl,
1825
+ installed: _TrustedSqliteLengthLimit,
1826
+ ) -> TrustedLiveCleanupError | None:
1827
+ """Restore and verify the operation baseline after one statement."""
1828
+
1829
+ if self._consume_statement_limit_fault("statement_limit_restore"):
1830
+ return TrustedLiveCleanupError(
1831
+ "Injected per-statement SQLite result-length restoration "
1832
+ "failure; the connection must be retired."
1833
+ )
1834
+ try:
1835
+ observed_effective = connection.limit(
1836
+ apsw.SQLITE_LIMIT_LENGTH,
1837
+ installed.previous,
1838
+ )
1839
+ observed_baseline = connection.limit(apsw.SQLITE_LIMIT_LENGTH)
1840
+ except BaseException as error:
1841
+ return TrustedLiveCleanupError(
1842
+ "The trusted reader could not restore the operation-wide SQLite "
1843
+ f"result-length baseline: {error}"
1844
+ )
1845
+ if (
1846
+ observed_effective != installed.effective
1847
+ or observed_baseline != installed.previous
1848
+ ):
1849
+ return TrustedLiveCleanupError(
1850
+ "The trusted reader could not verify restoration of the "
1851
+ "operation-wide SQLite result-length baseline."
1852
+ )
1853
+ control.result_length_limit = installed.previous
1854
+ return None
1855
+
1856
+ def _restore_result_length_limit(
1857
+ self,
1858
+ connection: apsw.Connection,
1859
+ control: _TrustedOperationControl,
1860
+ installed: _TrustedSqliteLengthLimit,
1861
+ ) -> TrustedLiveCleanupError | None:
1862
+ """Restore and verify SQLite's prior runtime length limit."""
1863
+
1864
+ try:
1865
+ observed_effective = connection.limit(
1866
+ apsw.SQLITE_LIMIT_LENGTH,
1867
+ installed.previous,
1868
+ )
1869
+ observed_previous = connection.limit(apsw.SQLITE_LIMIT_LENGTH)
1870
+ except BaseException as error:
1871
+ return TrustedLiveCleanupError(
1872
+ "The trusted reader could not restore its prior SQLite "
1873
+ f"result-length limit: {error}"
1874
+ )
1875
+ if (
1876
+ observed_effective != installed.effective
1877
+ or observed_previous != installed.previous
1878
+ ):
1879
+ return TrustedLiveCleanupError(
1880
+ "The trusted reader could not verify restoration of its prior "
1881
+ "SQLite result-length limit."
1882
+ )
1883
+ control.operation_result_length_limit = None
1884
+ control.result_length_limit = None
1885
+ return None
1886
+
1887
+ def _query_spec_in_transaction(
1888
+ self,
1889
+ connection: apsw.Connection,
1890
+ query: TrustedQuery,
1891
+ result_budget: _TrustedResultBudget,
1892
+ control: _TrustedOperationControl,
1893
+ operation_cleanup_errors: list[BaseException],
1894
+ ) -> TrustedQueryResult:
1895
+ sql = query.sql
1896
+ bindings = query.bindings
1897
+ if not isinstance(sql, str) or not sql.strip() or "\x00" in sql:
1898
+ raise TrustedLiveSqlRejectedError(
1899
+ "A trusted query must be one non-empty SQLite statement."
1900
+ )
1901
+ details = apsw.ext.query_info(connection, sql, bindings)
1902
+ if (
1903
+ (details.query_remaining is not None and details.query_remaining.strip())
1904
+ or not details.is_readonly
1905
+ or not details.has_vdbe
1906
+ or details.is_explain
1907
+ ):
1908
+ raise TrustedLiveSqlRejectedError(
1909
+ "The trusted reader accepts exactly one ordinary read-only "
1910
+ "SELECT or supported read-only metadata PRAGMA statement."
1911
+ )
1912
+ columns = result_budget.start_result(details.description)
1913
+ cursor: apsw.Cursor | None = None
1914
+ statement_length_limit: _TrustedSqliteLengthLimit | None = None
1915
+ body_error: BaseException | None = None
1916
+ body_cause: BaseException | None = None
1917
+ body_traceback: TracebackType | None = None
1918
+ result: TrustedQueryResult | None = None
1919
+ try:
1920
+ try:
1921
+ statement_length_limit = self._install_statement_result_length_limit(
1922
+ connection,
1923
+ control,
1924
+ len(columns),
1925
+ )
1926
+ except TrustedLiveCleanupError:
1927
+ operation_cleanup_errors.append(
1928
+ TrustedLiveCleanupError(
1929
+ "Per-statement SQLite result-limit setup was not proved "
1930
+ "safe; the reader connection must be retired."
1931
+ )
1932
+ )
1933
+ raise
1934
+ self._raise_if_operation_aborted(control)
1935
+ cursor = connection.cursor()
1936
+ cursor.row_trace = None
1937
+ cursor.execute(details.first_query, bindings)
1938
+ try:
1939
+ execution_description = cursor.get_description()
1940
+ except apsw.ExecutionCompleteError:
1941
+ # APSW releases the completed statement before exposing its
1942
+ # description when a valid SELECT produces zero rows. The
1943
+ # exact same statement was prepared, authorised, and bounded
1944
+ # immediately above by query_info(), so its validated
1945
+ # description remains the authoritative zero-row metadata.
1946
+ execution_description = details.description
1947
+ if execution_description != details.description:
1948
+ raise TrustedLiveQueryError(
1949
+ "SQLite result metadata changed between validation and execution."
1950
+ )
1951
+ retained_rows: list[tuple[Any, ...]] = []
1952
+ for row in cursor:
1953
+ self._raise_if_operation_aborted(control)
1954
+ retained_row = result_budget.retain_row(
1955
+ row,
1956
+ column_count=len(columns),
1957
+ retained_row_count=len(retained_rows),
1958
+ )
1959
+ retained_rows.append(retained_row)
1960
+ result = TrustedQueryResult(columns=columns, rows=tuple(retained_rows))
1961
+ except apsw.Error as error:
1962
+ body_error = self._translate_sqlite_error(
1963
+ error,
1964
+ native_sequence=control.native_sequence,
1965
+ control=control,
1966
+ )
1967
+ body_cause = error
1968
+ body_traceback = body_error.__traceback__
1969
+ except BaseException as error:
1970
+ body_error = error
1971
+ body_traceback = error.__traceback__
1972
+ finally:
1973
+ if cursor is not None:
1974
+ try:
1975
+ cursor.close()
1976
+ except BaseException as close_error:
1977
+ operation_cleanup_errors.append(close_error)
1978
+ if body_error is None:
1979
+ body_error = close_error
1980
+ body_traceback = close_error.__traceback__
1981
+ else:
1982
+ body_error.add_note(
1983
+ f"SQLite cursor cleanup also failed: {close_error!r}"
1984
+ )
1985
+ if statement_length_limit is not None:
1986
+ restore_error = self._restore_statement_result_length_limit(
1987
+ connection,
1988
+ control,
1989
+ statement_length_limit,
1990
+ )
1991
+ if restore_error is not None:
1992
+ operation_cleanup_errors.append(
1993
+ TrustedLiveCleanupError(
1994
+ "Per-statement SQLite result-limit restoration was "
1995
+ "not proved safe; the reader connection must be retired."
1996
+ )
1997
+ )
1998
+ if body_error is None:
1999
+ body_error = restore_error
2000
+ body_traceback = restore_error.__traceback__
2001
+ else:
2002
+ body_error.add_note(
2003
+ "SQLite statement-limit cleanup also failed: "
2004
+ f"{restore_error!r}"
2005
+ )
2006
+ if body_error is None:
2007
+ try:
2008
+ self._raise_if_operation_aborted(control)
2009
+ except BaseException as error:
2010
+ body_error = error
2011
+ body_traceback = error.__traceback__
2012
+ if body_error is not None:
2013
+ if body_cause is not None:
2014
+ raise body_error.with_traceback(body_traceback) from body_cause
2015
+ raise body_error.with_traceback(body_traceback)
2016
+ if result is None:
2017
+ raise TrustedLiveQueryError(
2018
+ "The trusted query completed without a materialised result."
2019
+ )
2020
+ return result
2021
+
2022
+ def _bootstrap_scalar(
2023
+ self,
2024
+ sql: str,
2025
+ *,
2026
+ bootstrap: apsw.Connection | None = None,
2027
+ ) -> Any:
2028
+ if bootstrap is None:
2029
+ bootstrap = self._bootstrap
2030
+ if bootstrap is None:
2031
+ raise TrustedLiveReaderUnavailableError(
2032
+ "The trusted live reader's native control channel is closed."
2033
+ )
2034
+ cursor: apsw.Cursor | None = None
2035
+ body_error: BaseException | None = None
2036
+ body_traceback: TracebackType | None = None
2037
+ value: Any = None
2038
+ try:
2039
+ cursor = bootstrap.cursor()
2040
+ row = cursor.execute(sql, (self._token_string,)).fetchone()
2041
+ if row is None or len(row) != 1:
2042
+ raise TrustedLiveReaderUnavailableError(
2043
+ "The native trusted VFS returned an invalid control result."
2044
+ )
2045
+ value = row[0]
2046
+ except BaseException as error:
2047
+ body_error = error
2048
+ body_traceback = error.__traceback__
2049
+ finally:
2050
+ if cursor is not None:
2051
+ try:
2052
+ cursor.close()
2053
+ except BaseException as close_error:
2054
+ if body_error is None:
2055
+ body_error = close_error
2056
+ body_traceback = close_error.__traceback__
2057
+ else:
2058
+ body_error.add_note(
2059
+ f"Native control cursor cleanup also failed: "
2060
+ f"{close_error!r}"
2061
+ )
2062
+ if body_error is not None:
2063
+ raise body_error.with_traceback(body_traceback)
2064
+ return value
2065
+
2066
+ def _read_native_status(self) -> _TrustedNativeStatus:
2067
+ raw_status = self._bootstrap_scalar("SELECT qplot_trusted_vfs_status(?)")
2068
+ if not isinstance(raw_status, str):
2069
+ raise TrustedLiveReaderUnavailableError(
2070
+ "The native trusted VFS returned a non-text status."
2071
+ )
2072
+ try:
2073
+ parsed = json.loads(raw_status)
2074
+ except json.JSONDecodeError as error:
2075
+ raise TrustedLiveReaderUnavailableError(
2076
+ "The native trusted VFS returned malformed status JSON."
2077
+ ) from error
2078
+ expected_keys = {
2079
+ "sequence",
2080
+ "kind",
2081
+ "artifact",
2082
+ "operation",
2083
+ "sqlite_code",
2084
+ }
2085
+ if (
2086
+ not isinstance(parsed, dict)
2087
+ or set(parsed) != expected_keys
2088
+ or type(parsed["sequence"]) is not int
2089
+ or parsed["sequence"] < 0
2090
+ or parsed["kind"]
2091
+ not in {"none", "source_changed", "unsupported", "policy", "io"}
2092
+ or parsed["artifact"]
2093
+ not in {"none", "main", "wal", "shm", "journal", "temp"}
2094
+ or not isinstance(parsed["operation"], str)
2095
+ or type(parsed["sqlite_code"]) is not int
2096
+ or parsed["sqlite_code"] < 0
2097
+ ):
2098
+ raise TrustedLiveReaderUnavailableError(
2099
+ "The native trusted VFS returned an invalid status record."
2100
+ )
2101
+ return _TrustedNativeStatus(
2102
+ sequence=parsed["sequence"],
2103
+ kind=parsed["kind"],
2104
+ artifact=parsed["artifact"],
2105
+ operation=parsed["operation"],
2106
+ sqlite_code=parsed["sqlite_code"],
2107
+ )
2108
+
2109
+ def _native_status_if_available(self) -> _TrustedNativeStatus | None:
2110
+ if not self._native_extension_loaded or self._bootstrap is None:
2111
+ return None
2112
+ try:
2113
+ return self._read_native_status()
2114
+ except (apsw.Error, TrustedLiveReaderError):
2115
+ return None
2116
+
2117
+ def _validate_native_source(self, *, begin_reads: bool = False) -> None:
2118
+ # Validate all paths/handles, then reuse only pathname proofs during
2119
+ # this finite operation. _finish_operation always disables reuse and
2120
+ # revalidates before publication, including cancellation/error paths.
2121
+ function = "qplot_trusted_vfs_begin_reads" if begin_reads else "qplot_trusted_vfs_validate"
2122
+ result = self._bootstrap_scalar(f"SELECT {function}(?)")
2123
+ if result != 1:
2124
+ raise TrustedLiveReaderUnavailableError(
2125
+ "The native trusted VFS returned an invalid validation result."
2126
+ )
2127
+
2128
+ def _error_from_native_status(
2129
+ self,
2130
+ status: _TrustedNativeStatus,
2131
+ ) -> TrustedLiveReaderError | None:
2132
+ location = (
2133
+ f" ({status.artifact}/{status.operation}, SQLite code {status.sqlite_code})"
2134
+ )
2135
+ if status.kind == "source_changed":
2136
+ self._invalidated = True
2137
+ return TrustedLiveSourceChangedError(
2138
+ "The native trusted VFS proved that a source file was "
2139
+ f"replaced or changed identity{location}."
2140
+ )
2141
+ if status.kind == "unsupported":
2142
+ return TrustedLiveUnsupportedSourceError(
2143
+ f"The native trusted VFS rejected the source{location}."
2144
+ )
2145
+ if status.kind == "policy":
2146
+ return TrustedLiveSqlRejectedError(
2147
+ f"The native trusted VFS rejected a prohibited operation{location}."
2148
+ )
2149
+ if status.kind == "io":
2150
+ return TrustedLiveSourceIOError(
2151
+ f"The native trusted VFS reported a source I/O failure{location}."
2152
+ )
2153
+ return None
2154
+
2155
+ def _translate_sqlite_error(
2156
+ self,
2157
+ error: apsw.Error,
2158
+ *,
2159
+ native_sequence: int | None,
2160
+ control: _TrustedOperationControl | None = None,
2161
+ ) -> TrustedLiveReaderError:
2162
+ status = self._native_status_if_available()
2163
+ status_is_new = status is not None and (
2164
+ native_sequence is None or status.sequence != native_sequence
2165
+ )
2166
+ if status_is_new and status is not None:
2167
+ status_error = self._error_from_native_status(status)
2168
+ if status_error is not None:
2169
+ return status_error
2170
+
2171
+ if control is not None:
2172
+ cancel_event = control.cancel_event
2173
+ if cancel_event is not None and cancel_event.is_set():
2174
+ self._mark_operation_abort(control, "cancelled")
2175
+ if time.monotonic() >= control.deadline:
2176
+ self._mark_operation_abort(control, "deadline")
2177
+ reason = self._operation_abort_reason(control)
2178
+ if reason == "cancelled":
2179
+ return TrustedLiveCancelledError(
2180
+ "The trusted database read was cancelled."
2181
+ )
2182
+ if reason == "deadline":
2183
+ return TrustedLiveDeadlineExceededError(
2184
+ "The trusted database read deadline was exceeded."
2185
+ )
2186
+ policy_denied = control.policy_denied
2187
+ else:
2188
+ policy_denied = False
2189
+
2190
+ if isinstance(error, (apsw.BusyError, apsw.LockedError)):
2191
+ return TrustedLiveBusyTimeoutError(
2192
+ f"The trusted database remained busy beyond its bounded wait: {error}"
2193
+ )
2194
+ if isinstance(error, apsw.InterruptError):
2195
+ return TrustedLiveQueryError(
2196
+ "SQLite interrupted the trusted query without a matching "
2197
+ "cancellation or deadline request."
2198
+ )
2199
+ if isinstance(error, (apsw.CorruptError, apsw.NotADBError, apsw.FormatError)):
2200
+ return TrustedLiveInvalidDatabaseError(
2201
+ f"The selected database is corrupt or invalid: {error}"
2202
+ )
2203
+ if isinstance(error, apsw.TooBigError) and (
2204
+ control is not None and control.result_length_limit is not None
2205
+ ):
2206
+ return TrustedLiveResultLimitError(
2207
+ "SQLite stopped producing a text or blob result at qPlot's "
2208
+ f"{control.result_length_limit}-byte runtime length limit."
2209
+ )
2210
+ if policy_denied:
2211
+ return TrustedLiveSqlRejectedError(
2212
+ f"The trusted reader rejected the SQL statement: {error}"
2213
+ )
2214
+ if isinstance(error, apsw.ReadOnlyError):
2215
+ return TrustedLiveSourceIOError(
2216
+ "SQLite's physically read-only source boundary could not "
2217
+ f"complete required coordination: {error}"
2218
+ )
2219
+ if isinstance(error, apsw.PermissionsError):
2220
+ return TrustedLiveSourceIOError(
2221
+ f"SQLite could not access the trusted source: {error}"
2222
+ )
2223
+ if isinstance(
2224
+ error,
2225
+ (
2226
+ apsw.CantOpenError,
2227
+ apsw.NoLFSError,
2228
+ apsw.VFSNotImplementedError,
2229
+ ),
2230
+ ):
2231
+ return TrustedLiveUnsupportedSourceError(
2232
+ f"The selected source or platform is unsupported: {error}"
2233
+ )
2234
+ if isinstance(error, (apsw.IOError, apsw.FullError)):
2235
+ return TrustedLiveSourceIOError(
2236
+ f"SQLite could not read the trusted source: {error}"
2237
+ )
2238
+ if isinstance(
2239
+ error,
2240
+ (
2241
+ apsw.BindingsError,
2242
+ apsw.MismatchError,
2243
+ apsw.RangeError,
2244
+ apsw.SchemaChangeError,
2245
+ apsw.SQLError,
2246
+ ),
2247
+ ):
2248
+ return TrustedLiveQueryError(f"The trusted read-only query failed: {error}")
2249
+ if control is not None:
2250
+ return TrustedLiveQueryError(
2251
+ f"SQLite failed while executing the trusted query: {error}"
2252
+ )
2253
+ return TrustedLiveReaderUnavailableError(
2254
+ f"SQLite failed while establishing the trusted reader boundary: {error}"
2255
+ )
2256
+
2257
+ def _finish_operation(
2258
+ self,
2259
+ connection: apsw.Connection,
2260
+ control: _TrustedOperationControl,
2261
+ *,
2262
+ transaction_started: bool,
2263
+ handlers_installed: bool,
2264
+ publication_pending: bool,
2265
+ prior_cleanup_errors: Sequence[BaseException] = (),
2266
+ ) -> TrustedLiveReaderError | None:
2267
+ with self._operation_mutex:
2268
+ if self._active_operation is control:
2269
+ self._active_operation = None
2270
+
2271
+ cleanup_errors = list(prior_cleanup_errors)
2272
+ if control.blob_cleanup_error is not None:
2273
+ cleanup_errors.append(control.blob_cleanup_error)
2274
+ if handlers_installed:
2275
+ cleanup_errors.extend(self._remove_operation_handlers(connection, control))
2276
+ if transaction_started:
2277
+ try:
2278
+ # A fatal VFS/SQLite error can roll the transaction back before
2279
+ # Python reaches this cleanup path. Treat that already-clean
2280
+ # state as success; issuing ROLLBACK then would create a false
2281
+ # "no transaction is active" cleanup failure and hide the
2282
+ # identity error that caused the automatic rollback.
2283
+ if connection.in_transaction:
2284
+ self._execute_transaction_control("ROLLBACK")
2285
+ except BaseException as error:
2286
+ cleanup_errors.append(error)
2287
+ try:
2288
+ transaction_state = connection.txn_state("main")
2289
+ except BaseException as error:
2290
+ cleanup_errors.append(error)
2291
+ else:
2292
+ if transaction_state != apsw.SQLITE_TXN_NONE:
2293
+ cleanup_errors.append(
2294
+ TrustedLiveCleanupError(
2295
+ "SQLite retained a transaction after trusted-reader rollback."
2296
+ )
2297
+ )
2298
+ try:
2299
+ self._validate_native_source()
2300
+ post_status = self._read_native_status()
2301
+ if publication_pending and post_status.sequence != control.native_sequence:
2302
+ status_error = self._error_from_native_status(post_status)
2303
+ if status_error is not None:
2304
+ cleanup_errors.append(status_error)
2305
+ except apsw.Error as error:
2306
+ cleanup_errors.append(
2307
+ self._translate_sqlite_error(
2308
+ error,
2309
+ native_sequence=control.native_sequence,
2310
+ control=control,
2311
+ )
2312
+ )
2313
+ except BaseException as error:
2314
+ cleanup_errors.append(error)
2315
+
2316
+ if not cleanup_errors and publication_pending:
2317
+ if control.cancel_event is not None and control.cancel_event.is_set():
2318
+ return TrustedLiveCancelledError(
2319
+ "The trusted database read was cancelled before publication."
2320
+ )
2321
+ if time.monotonic() >= control.deadline:
2322
+ return TrustedLiveDeadlineExceededError(
2323
+ "The trusted database read deadline expired before publication."
2324
+ )
2325
+ if not cleanup_errors:
2326
+ return None
2327
+
2328
+ if any(
2329
+ isinstance(error, TrustedLiveSourceChangedError) for error in cleanup_errors
2330
+ ):
2331
+ self._invalidated = True
2332
+ self._closed = True
2333
+ close_cleanup_error = self._close_resources(force=True)
2334
+ if close_cleanup_error is not None:
2335
+ for preceding_error in cleanup_errors:
2336
+ close_cleanup_error.add_note(
2337
+ "Failure that required forced reader cleanup: "
2338
+ f"{type(preceding_error).__name__}: {preceding_error}"
2339
+ )
2340
+ return close_cleanup_error
2341
+ primary = cleanup_errors[0]
2342
+ if isinstance(primary, TrustedLiveReaderError):
2343
+ cleanup_error = primary
2344
+ else:
2345
+ cleanup_error = TrustedLiveCleanupError(
2346
+ f"The trusted reader could not prove transaction cleanup: {primary}"
2347
+ )
2348
+ for additional_error in cleanup_errors[1:]:
2349
+ cleanup_error.add_note(f"Additional cleanup failure: {additional_error!r}")
2350
+ return cleanup_error
2351
+
2352
+ def query_batch(
2353
+ self,
2354
+ queries: Sequence[TrustedQuery],
2355
+ *,
2356
+ timeout: float | None = None,
2357
+ deadline: float | None = None,
2358
+ cancel_event: threading.Event | None = None,
2359
+ ) -> tuple[TrustedQueryResult, ...]:
2360
+ """Run a finite query batch in one repeatable-read transaction.
2361
+
2362
+ The supplied sequence is copied before ``BEGIN``. Every item must be a
2363
+ :class:`TrustedQuery`; no callback or cursor escapes while SQLite holds
2364
+ read locks. The effective deadline is the earliest of ``timeout`` and
2365
+ ``deadline``. If neither is supplied, the reader's finite default is
2366
+ used.
2367
+ """
2368
+
2369
+ specifications = tuple(queries)
2370
+ if not specifications:
2371
+ raise TrustedLiveSqlRejectedError(
2372
+ "A trusted query batch must contain at least one statement."
2373
+ )
2374
+ if len(specifications) > TRUSTED_LIVE_MAX_BATCH_QUERIES:
2375
+ raise TrustedLiveResultLimitError(
2376
+ "A trusted query batch contains more than "
2377
+ f"{TRUSTED_LIVE_MAX_BATCH_QUERIES} statements."
2378
+ )
2379
+ if not all(isinstance(query, TrustedQuery) for query in specifications):
2380
+ raise TypeError("query_batch accepts only TrustedQuery specifications.")
2381
+
2382
+ result_budget = _TrustedResultBudget.for_query_batch()
2383
+ connection, control = self._start_operation(
2384
+ timeout=timeout,
2385
+ deadline=deadline,
2386
+ cancel_event=cancel_event,
2387
+ )
2388
+ result_budget.abort_check = lambda: self._raise_if_operation_aborted(control)
2389
+ transaction_started = False
2390
+ handlers_installed = False
2391
+ body_error: BaseException | None = None
2392
+ body_cause: BaseException | None = None
2393
+ body_traceback: TracebackType | None = None
2394
+ results: tuple[TrustedQueryResult, ...] | None = None
2395
+ result_length_limit: _TrustedSqliteLengthLimit | None = None
2396
+ operation_cleanup_errors: list[BaseException] = []
2397
+ try:
2398
+ self._raise_if_operation_aborted(control)
2399
+ status = self._read_native_status()
2400
+ control.native_sequence = status.sequence
2401
+ self._validate_native_source(begin_reads=True)
2402
+ self._raise_if_operation_aborted(control)
2403
+ handlers_installed = True
2404
+ self._install_operation_handlers(connection, control)
2405
+ try:
2406
+ result_length_limit = self._install_result_length_limit(
2407
+ connection,
2408
+ control,
2409
+ )
2410
+ except TrustedLiveCleanupError as error:
2411
+ operation_cleanup_errors.append(
2412
+ TrustedLiveCleanupError(
2413
+ "SQLite result-limit setup was not proved safe; the "
2414
+ "reader connection must be retired."
2415
+ )
2416
+ )
2417
+ raise error
2418
+ self._raise_if_operation_aborted(control)
2419
+ try:
2420
+ self._execute_transaction_control("BEGIN")
2421
+ finally:
2422
+ # If BEGIN itself completed but explicit cursor cleanup failed,
2423
+ # this still forces rollback on the exceptional path.
2424
+ transaction_started = connection.in_transaction
2425
+ materialised: list[TrustedQueryResult] = []
2426
+ for query in specifications:
2427
+ self._raise_if_operation_aborted(control)
2428
+ materialised.append(
2429
+ self._query_spec_in_transaction(
2430
+ connection,
2431
+ query,
2432
+ result_budget,
2433
+ control,
2434
+ operation_cleanup_errors,
2435
+ )
2436
+ )
2437
+ self._raise_if_operation_aborted(control)
2438
+ results = tuple(materialised)
2439
+ except apsw.Error as error:
2440
+ body_error = self._translate_sqlite_error(
2441
+ error,
2442
+ native_sequence=control.native_sequence,
2443
+ control=control,
2444
+ )
2445
+ body_cause = error
2446
+ body_traceback = body_error.__traceback__
2447
+ except BaseException as error:
2448
+ body_error = error
2449
+ body_traceback = error.__traceback__
2450
+ finally:
2451
+ if result_length_limit is not None:
2452
+ restore_error = self._restore_result_length_limit(
2453
+ connection,
2454
+ control,
2455
+ result_length_limit,
2456
+ )
2457
+ if restore_error is not None:
2458
+ operation_cleanup_errors.append(restore_error)
2459
+
2460
+ cleanup_error = self._finish_operation(
2461
+ connection,
2462
+ control,
2463
+ transaction_started=transaction_started,
2464
+ handlers_installed=handlers_installed,
2465
+ publication_pending=body_error is None,
2466
+ prior_cleanup_errors=operation_cleanup_errors,
2467
+ )
2468
+ if body_error is not None:
2469
+ if cleanup_error is not None:
2470
+ if isinstance(cleanup_error, TrustedLiveCleanupError) or self._closed:
2471
+ preserved_body_error = body_error.with_traceback(body_traceback)
2472
+ if body_cause is not None:
2473
+ preserved_body_error.__cause__ = body_cause
2474
+ raise cleanup_error from preserved_body_error
2475
+ body_error.add_note(
2476
+ f"Trusted-reader cleanup also failed: {cleanup_error!r}"
2477
+ )
2478
+ if body_cause is not None:
2479
+ raise body_error.with_traceback(body_traceback) from body_cause
2480
+ raise body_error.with_traceback(body_traceback)
2481
+ if cleanup_error is not None:
2482
+ raise cleanup_error
2483
+ if results is None:
2484
+ raise TrustedLiveQueryError(
2485
+ "The trusted query batch completed without results."
2486
+ )
2487
+ return results
2488
+
2489
+ def query(
2490
+ self,
2491
+ sql: str,
2492
+ bindings: SqliteBindings = None,
2493
+ *,
2494
+ timeout: float | None = None,
2495
+ deadline: float | None = None,
2496
+ cancel_event: threading.Event | None = None,
2497
+ ) -> TrustedQueryResult:
2498
+ """Run one materialised statement in a bounded read transaction."""
2499
+
2500
+ return self.query_batch(
2501
+ (TrustedQuery(sql, bindings),),
2502
+ timeout=timeout,
2503
+ deadline=deadline,
2504
+ cancel_event=cancel_event,
2505
+ )[0]
2506
+
2507
+ def data_version(
2508
+ self,
2509
+ *,
2510
+ timeout: float | None = None,
2511
+ deadline: float | None = None,
2512
+ cancel_event: threading.Event | None = None,
2513
+ ) -> int:
2514
+ """Return ``PRAGMA data_version`` in its own finite transaction."""
2515
+
2516
+ result = self.query(
2517
+ "PRAGMA data_version",
2518
+ timeout=timeout,
2519
+ deadline=deadline,
2520
+ cancel_event=cancel_event,
2521
+ )
2522
+ if (
2523
+ result.columns != ("data_version",)
2524
+ or len(result.rows) != 1
2525
+ or len(result.rows[0]) != 1
2526
+ or not isinstance(result.rows[0][0], int)
2527
+ ):
2528
+ raise TrustedLiveReaderError(
2529
+ "SQLite returned an invalid PRAGMA data_version result."
2530
+ )
2531
+ return result.rows[0][0]
2532
+
2533
+ def _read_native_audit(
2534
+ self,
2535
+ bootstrap: apsw.Connection,
2536
+ ) -> TrustedVfsAudit:
2537
+ raw_audit = self._bootstrap_scalar(
2538
+ "SELECT qplot_trusted_vfs_audit(?)",
2539
+ bootstrap=bootstrap,
2540
+ )
2541
+ if not isinstance(raw_audit, str):
2542
+ raise TrustedLiveReaderError(
2543
+ "The native VFS returned an invalid audit result."
2544
+ )
2545
+ try:
2546
+ parsed = json.loads(raw_audit)
2547
+ except json.JSONDecodeError as error:
2548
+ raise TrustedLiveReaderError(
2549
+ "The native VFS returned malformed audit counters."
2550
+ ) from error
2551
+ if not isinstance(parsed, dict) or not all(
2552
+ isinstance(key, str) and type(value) is int for key, value in parsed.items()
2553
+ ):
2554
+ raise TrustedLiveReaderError(
2555
+ "The native VFS returned malformed audit counters."
2556
+ )
2557
+ return TrustedVfsAudit(MappingProxyType(parsed))
2558
+
2559
+ def audit(self) -> TrustedVfsAudit:
2560
+ """Return native counters, including the final close-time snapshot."""
2561
+ self._check_thread()
2562
+ bootstrap = self._bootstrap
2563
+ if bootstrap is not None:
2564
+ return self._read_native_audit(bootstrap)
2565
+ if self._final_audit is not None:
2566
+ return self._final_audit
2567
+ raise TrustedLiveReaderClosedError(
2568
+ "The trusted live reader audit channel is closed."
2569
+ )
2570
+
2571
+ @staticmethod
2572
+ def _verify_final_audit(audit: TrustedVfsAudit) -> None:
2573
+ """Require native proof and SHM state to be fully released."""
2574
+
2575
+ counters = audit.counters
2576
+ required = {
2577
+ "base_close_error",
2578
+ "proof_open",
2579
+ "proof_close",
2580
+ "proof_close_error",
2581
+ "proof_active",
2582
+ "shm_unmap_error",
2583
+ "shm_unmap_delete_forwarded",
2584
+ }
2585
+ missing = required.difference(counters)
2586
+ if missing:
2587
+ missing_names = ", ".join(sorted(missing))
2588
+ raise TrustedLiveCleanupError(
2589
+ f"The native VFS omitted required cleanup counters: {missing_names}."
2590
+ )
2591
+ failures: list[str] = []
2592
+ if counters["proof_active"] != 0:
2593
+ failures.append(f"proof_active={counters['proof_active']}")
2594
+ if counters["proof_open"] != counters["proof_close"]:
2595
+ failures.append(
2596
+ f"proof_open={counters['proof_open']} but "
2597
+ f"proof_close={counters['proof_close']}"
2598
+ )
2599
+ for counter_name in (
2600
+ "base_close_error",
2601
+ "proof_close_error",
2602
+ "shm_unmap_error",
2603
+ "shm_unmap_delete_forwarded",
2604
+ ):
2605
+ if counters[counter_name] != 0:
2606
+ failures.append(f"{counter_name}={counters[counter_name]}")
2607
+ if failures:
2608
+ raise TrustedLiveCleanupError(
2609
+ "The native VFS did not release trusted proof/SHM state: "
2610
+ + ", ".join(failures)
2611
+ + "."
2612
+ )
2613
+
2614
+ def _close_resources(self, *, force: bool) -> TrustedLiveCleanupError | None:
2615
+ if self._resource_cleanup_complete:
2616
+ cleanup_error = self._resource_cleanup_error
2617
+ if cleanup_error is not None and not force:
2618
+ raise cleanup_error
2619
+ return cleanup_error
2620
+
2621
+ errors: list[BaseException] = []
2622
+ if self._preflight_cleanup_error is not None:
2623
+ errors.append(self._preflight_cleanup_error)
2624
+ native_release_confirmed = (
2625
+ self._native_release_confirmed or not self._native_extension_loaded
2626
+ )
2627
+ try:
2628
+ connection, self._connection = self._connection, None
2629
+ if connection is not None:
2630
+ try:
2631
+ connection.close(False)
2632
+ except BaseException as error:
2633
+ errors.append(error)
2634
+ try:
2635
+ connection.close(True)
2636
+ except BaseException as force_close_error:
2637
+ errors.append(force_close_error)
2638
+
2639
+ bootstrap, self._bootstrap = self._bootstrap, None
2640
+ if bootstrap is not None:
2641
+ final_audit_captured = False
2642
+ final_audit_verified = False
2643
+ final_audit_error: BaseException | None = None
2644
+ if self._native_session_attempted:
2645
+ try:
2646
+ final_audit = self._read_native_audit(bootstrap)
2647
+ self._final_audit = final_audit
2648
+ final_audit_captured = True
2649
+ self._verify_final_audit(final_audit)
2650
+ final_audit_verified = True
2651
+ except BaseException as error:
2652
+ # xOpen can reject input before the native session is
2653
+ # configured. Defer this error until release reports
2654
+ # whether a matching session actually existed.
2655
+ final_audit_error = error
2656
+ if self._native_extension_loaded:
2657
+ cursor: apsw.Cursor | None = None
2658
+ release_result: int | None = None
2659
+ release_error: BaseException | None = None
2660
+ try:
2661
+ cursor = bootstrap.cursor()
2662
+ row = cursor.execute(
2663
+ "SELECT qplot_trusted_vfs_release(?)",
2664
+ (self._token_string,),
2665
+ ).fetchone()
2666
+ if (
2667
+ row is None
2668
+ or len(row) != 1
2669
+ or type(row[0]) is not int
2670
+ or row[0] not in {0, 1}
2671
+ ):
2672
+ raise TrustedLiveCleanupError(
2673
+ "The native VFS returned an invalid release result."
2674
+ )
2675
+ release_result = row[0]
2676
+ except BaseException as error:
2677
+ release_error = error
2678
+ finally:
2679
+ if cursor is not None:
2680
+ try:
2681
+ cursor.close()
2682
+ except BaseException as error:
2683
+ errors.append(error)
2684
+ if release_error is not None:
2685
+ if final_audit_error is not None:
2686
+ errors.append(final_audit_error)
2687
+ errors.append(release_error)
2688
+ elif release_result == 0:
2689
+ if final_audit_captured:
2690
+ errors.append(
2691
+ TrustedLiveCleanupError(
2692
+ "The native VFS reported no session after "
2693
+ "returning its audit."
2694
+ )
2695
+ )
2696
+ else:
2697
+ # A zero result authoritatively proves that xOpen
2698
+ # rejected the request before configuring native
2699
+ # state. Its expected unknown-token audit error is
2700
+ # therefore not cleanup uncertainty.
2701
+ native_release_confirmed = True
2702
+ self._native_release_confirmed = True
2703
+ elif release_result == 1:
2704
+ native_release_confirmed = True
2705
+ self._native_release_confirmed = True
2706
+ if not self._native_session_attempted:
2707
+ errors.append(
2708
+ TrustedLiveCleanupError(
2709
+ "The native VFS released an unexpected session."
2710
+ )
2711
+ )
2712
+ elif final_audit_error is not None:
2713
+ errors.append(final_audit_error)
2714
+ elif not final_audit_verified:
2715
+ errors.append(
2716
+ TrustedLiveCleanupError(
2717
+ "The native VFS session was released without "
2718
+ "a verified final audit."
2719
+ )
2720
+ )
2721
+ try:
2722
+ bootstrap.close(True)
2723
+ except BaseException as error:
2724
+ errors.append(error)
2725
+
2726
+ temporary_directory, self._temporary_directory = (
2727
+ self._temporary_directory,
2728
+ None,
2729
+ )
2730
+ self._native_temporary_path = None
2731
+ if temporary_directory is not None:
2732
+ try:
2733
+ temporary_directory.cleanup()
2734
+ except BaseException as error:
2735
+ errors.append(error)
2736
+ finally:
2737
+ if not native_release_confirmed and not errors:
2738
+ errors.append(
2739
+ TrustedLiveCleanupError(
2740
+ "The native trusted VFS release could not be confirmed."
2741
+ )
2742
+ )
2743
+
2744
+ if errors:
2745
+ cleanup_error = TrustedLiveCleanupError(
2746
+ "The trusted reader did not close cleanly; cleanup could not be "
2747
+ f"proved and this process is quarantined: {errors[0]}"
2748
+ )
2749
+ for additional_error in errors[1:]:
2750
+ cleanup_error.add_note(
2751
+ f"Additional close failure: {additional_error!r}"
2752
+ )
2753
+ _quarantine_process_session(
2754
+ self._process_session_owner,
2755
+ cleanup_error,
2756
+ )
2757
+ self._resource_cleanup_error = cleanup_error
2758
+ self._resource_cleanup_complete = True
2759
+ if force:
2760
+ return cleanup_error
2761
+ raise cleanup_error
2762
+ _release_process_session(self._process_session_owner)
2763
+ self._native_release_confirmed = native_release_confirmed
2764
+ self._resource_cleanup_complete = True
2765
+ return None
2766
+
2767
+ def close(self) -> None:
2768
+ """Close the SQLite handles and remove the private temp directory."""
2769
+ self._check_thread()
2770
+ if self._closed:
2771
+ return
2772
+ with self._operation_mutex:
2773
+ if self._active_operation is not None:
2774
+ raise TrustedLiveTransactionError(
2775
+ "Interrupt and finish the active operation before closing "
2776
+ "its trusted reader."
2777
+ )
2778
+ self._closed = True
2779
+ self._close_resources(force=False)
2780
+
2781
+ def __enter__(self) -> TrustedLiveReader:
2782
+ self._require_open()
2783
+ return self
2784
+
2785
+ def __exit__(
2786
+ self,
2787
+ exception_type: type[BaseException] | None,
2788
+ exception: BaseException | None,
2789
+ traceback: TracebackType | None,
2790
+ ) -> None:
2791
+ del exception_type, exception, traceback
2792
+ self.close()
2793
+
2794
+ def __del__(self) -> None:
2795
+ """Best-effort release if an owner abandons the reader without closing."""
2796
+ try:
2797
+ if getattr(self, "_closed", True):
2798
+ return
2799
+ self._closed = True
2800
+ operation_mutex = getattr(self, "_operation_mutex", None)
2801
+ if operation_mutex is not None:
2802
+ with operation_mutex:
2803
+ self._active_operation = None
2804
+ self._close_resources(force=True)
2805
+ except BaseException:
2806
+ # Finalizers may run during interpreter teardown or after a partial
2807
+ # constructor failure. They must never make that teardown noisy.
2808
+ pass