onec-interactive-runtime-core 0.1.19__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 (87) hide show
  1. onec_interactive_runtime_core-0.1.19.dist-info/METADATA +12 -0
  2. onec_interactive_runtime_core-0.1.19.dist-info/RECORD +87 -0
  3. onec_interactive_runtime_core-0.1.19.dist-info/WHEEL +4 -0
  4. onec_interactive_runtime_core-0.1.19.dist-info/licenses/COPYRIGHT +9 -0
  5. onec_interactive_runtime_core-0.1.19.dist-info/licenses/LICENSE +232 -0
  6. onec_interactive_runtime_core-0.1.19.dist-info/licenses/THIRD_PARTY_NOTICES.txt +32 -0
  7. onec_runtime/AGENTS.md +7 -0
  8. onec_runtime/__init__.py +3 -0
  9. onec_runtime/artifacts.py +59 -0
  10. onec_runtime/bootstrap.py +310 -0
  11. onec_runtime/breakpoint_workspace.py +249 -0
  12. onec_runtime/bsl/__init__.py +139 -0
  13. onec_runtime/bsl/diagnostics.py +1046 -0
  14. onec_runtime/bsl/full_ast_worker_projection.py +522 -0
  15. onec_runtime/bsl/generated_semantic_parser.py +6322 -0
  16. onec_runtime/bsl/lexer.py +164 -0
  17. onec_runtime/bsl/module_catalog.py +420 -0
  18. onec_runtime/bsl/module_delta.py +1079 -0
  19. onec_runtime/bsl/module_universe.py +1877 -0
  20. onec_runtime/bsl/notebook_cells.py +232 -0
  21. onec_runtime/bsl/notebook_method_globals.py +74 -0
  22. onec_runtime/bsl/notebook_methods.py +175 -0
  23. onec_runtime/bsl/parser_artifact_identity.py +119 -0
  24. onec_runtime/bsl/parser_target.py +388 -0
  25. onec_runtime/bsl/preprocessor.py +16 -0
  26. onec_runtime/bsl/semantic_lowering.py +1539 -0
  27. onec_runtime/bsl/source_maps.py +2411 -0
  28. onec_runtime/bsl/worker_dependency_resolver.py +289 -0
  29. onec_runtime/bsl/worker_preprocessor.py +247 -0
  30. onec_runtime/bsl/worker_projection_model.py +137 -0
  31. onec_runtime/bsl/worker_reload_source_map.py +1697 -0
  32. onec_runtime/capture.py +113 -0
  33. onec_runtime/capture_source.py +453 -0
  34. onec_runtime/compact_table.py +376 -0
  35. onec_runtime/compact_table_backend.py +500 -0
  36. onec_runtime/config.py +198 -0
  37. onec_runtime/configurator_agent.py +520 -0
  38. onec_runtime/controller_worker.py +400 -0
  39. onec_runtime/credentials.py +18 -0
  40. onec_runtime/epf_container.py +196 -0
  41. onec_runtime/errors.py +205 -0
  42. onec_runtime/experiment.py +130 -0
  43. onec_runtime/extension_bundle.py +944 -0
  44. onec_runtime/extension_lifecycle.py +402 -0
  45. onec_runtime/extension_state.py +222 -0
  46. onec_runtime/fault_injection.py +30 -0
  47. onec_runtime/kernel.py +179 -0
  48. onec_runtime/lease.py +123 -0
  49. onec_runtime/observation.py +287 -0
  50. onec_runtime/performance_profile.py +245 -0
  51. onec_runtime/privacy.py +263 -0
  52. onec_runtime/processes.py +265 -0
  53. onec_runtime/prototype_runtime.py +2644 -0
  54. onec_runtime/rdbg/__init__.py +1 -0
  55. onec_runtime/rdbg/models.py +130 -0
  56. onec_runtime/rdbg/reconnect.py +59 -0
  57. onec_runtime/rdbg/session.py +913 -0
  58. onec_runtime/rdbg/transport.py +138 -0
  59. onec_runtime/rdbg/xml_codec.py +980 -0
  60. onec_runtime/recovery.py +106 -0
  61. onec_runtime/recovery_journal.py +79 -0
  62. onec_runtime/resources/extension/OnecInteractiveRuntime.cfe +0 -0
  63. onec_runtime/resources/extension/extension-manifest.json +108 -0
  64. onec_runtime/runtime_api.py +5272 -0
  65. onec_runtime/runtime_contracts.py +196 -0
  66. onec_runtime/server_worker.py +2034 -0
  67. onec_runtime/session.py +2123 -0
  68. onec_runtime/startup_diagnostics.py +45 -0
  69. onec_runtime/stop_routing.py +65 -0
  70. onec_runtime/supervised_processes.py +232 -0
  71. onec_runtime/supervised_runtime.py +512 -0
  72. onec_runtime/supervisor.py +363 -0
  73. onec_runtime/supervisor_model.py +148 -0
  74. onec_runtime/supervisor_protocol.py +77 -0
  75. onec_runtime/table_materialization.py +471 -0
  76. onec_runtime/table_transfer_backend.py +203 -0
  77. onec_runtime/table_value.py +311 -0
  78. onec_runtime/toolchain.py +730 -0
  79. onec_runtime/value_materialization.py +432 -0
  80. onec_runtime/value_transfer_backend.py +199 -0
  81. onec_runtime/worker_breakpoints.py +1431 -0
  82. onec_runtime/worker_epf.py +313 -0
  83. onec_runtime/worker_epf_template.py +53 -0
  84. onec_runtime/worker_stage_protocol.py +516 -0
  85. onec_runtime/worker_universe.py +3772 -0
  86. onec_runtime_build/__init__.py +1 -0
  87. onec_runtime_build/parser_target_development.py +116 -0
@@ -0,0 +1,402 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import tempfile
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+ from typing import ContextManager, Protocol
9
+
10
+ from onec_runtime.config import RuntimeConfig
11
+ from onec_runtime.errors import (
12
+ ExtensionIdentityConflict,
13
+ ExtensionLifecycleError,
14
+ ExtensionNotInstalled,
15
+ )
16
+ from onec_runtime.extension_bundle import (
17
+ ExtensionBundle,
18
+ ExtensionFingerprints,
19
+ ExtensionHandshakeEvidence,
20
+ fingerprint_extension_dump,
21
+ )
22
+ from onec_runtime.extension_state import (
23
+ ExtensionStateStore,
24
+ InfobaseExtensionLock,
25
+ VerifiedExtensionState,
26
+ )
27
+ from onec_runtime.performance_profile import PhaseRecorder
28
+ from onec_runtime.toolchain import ToolResult
29
+
30
+ _PRODUCT_VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z")
31
+
32
+
33
+ def _product_version_key(value: str) -> tuple[tuple[int, str], ...]:
34
+ match = _PRODUCT_VERSION.fullmatch(value)
35
+ if match is None:
36
+ raise ExtensionLifecycleError(
37
+ "extension artifact versions must use canonical MAJOR.MINOR.PATCH decimal syntax"
38
+ )
39
+ return tuple((len(component), component) for component in match.groups())
40
+
41
+
42
+ class TargetExtensionState(StrEnum):
43
+ ABSENT = "absent"
44
+ CURRENT = "current"
45
+ OLDER = "older"
46
+ FOREIGN = "foreign"
47
+ INSTALLED = "installed"
48
+ UPDATED = "updated"
49
+ USER_MANAGED = "user-managed"
50
+
51
+
52
+ class LifecycleMode(StrEnum):
53
+ FAST = "fast"
54
+ PROBED = "probed"
55
+ SLOW = "slow"
56
+ MANUAL = "manual"
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class LifecycleDecision:
61
+ mode: LifecycleMode
62
+ target_state: TargetExtensionState
63
+ bundle: ExtensionBundle
64
+ retry_allowed: bool
65
+
66
+
67
+ class ExtensionToolOperations(Protocol):
68
+ def mutation_session(self, log_dir: Path) -> ContextManager[None]: ...
69
+
70
+ def dump_files(self, destination: Path, log_path: Path) -> ToolResult: ...
71
+
72
+ def dump_cfe(self, destination: Path, log_path: Path) -> ToolResult: ...
73
+
74
+ def load_cfe(self, source: Path, log_path: Path) -> ToolResult | None: ...
75
+
76
+ def apply(self, log_path: Path) -> ToolResult | None: ...
77
+
78
+ def disable_safe_mode(self, log_dir: Path) -> None: ...
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class _OperationPaths:
83
+ root: Path
84
+ initial_dump: Path
85
+ final_dump: Path
86
+ rollback_dump: Path
87
+ backup_cfe: Path
88
+
89
+ def log(self, name: str) -> Path:
90
+ return self.root / f"{name}.log"
91
+
92
+
93
+ class ExtensionLifecycle:
94
+ def __init__(
95
+ self,
96
+ config: RuntimeConfig,
97
+ bundle: ExtensionBundle,
98
+ state_store: ExtensionStateStore,
99
+ tools: ExtensionToolOperations,
100
+ profiler: PhaseRecorder | None = None,
101
+ *,
102
+ lifecycle_lock: InfobaseExtensionLock | None = None,
103
+ lock_timeout_s: float = 30.0,
104
+ ) -> None:
105
+ self.config = config
106
+ self.bundle = bundle
107
+ self.state_store = state_store
108
+ self.tools = tools
109
+ self.profiler = profiler if profiler is not None else PhaseRecorder()
110
+ state_root = config.runtime_dir / "extension-state"
111
+ self._lock = lifecycle_lock or InfobaseExtensionLock(
112
+ state_root, config.infobase_identity
113
+ )
114
+ self._lock_timeout_s = lock_timeout_s
115
+
116
+ def prepare(self, *, force_slow: bool = False) -> LifecycleDecision:
117
+ manifest = self.bundle.manifest
118
+ if not force_slow and self.state_store.matches(manifest):
119
+ return self._fast_decision()
120
+
121
+ with self._lock.acquire(timeout_s=self._lock_timeout_s):
122
+ if not force_slow and self.state_store.matches(manifest):
123
+ return self._fast_decision()
124
+ self.state_store.invalidate()
125
+ paths = self._new_operation_paths()
126
+ state, installed = self._inspect(paths.initial_dump, paths.log("inspect"))
127
+ if state not in (
128
+ TargetExtensionState.ABSENT,
129
+ TargetExtensionState.CURRENT,
130
+ TargetExtensionState.OLDER,
131
+ ):
132
+ raise ExtensionIdentityConflict(
133
+ "installed extension has a foreign permanent identity"
134
+ )
135
+
136
+ # The exact artifact is already present. Admit it only after the
137
+ # live client/server handshake and safe-mode property check;
138
+ # repair through the Agent if either check fails.
139
+ if state is TargetExtensionState.CURRENT and not force_slow:
140
+ return LifecycleDecision(
141
+ LifecycleMode.PROBED,
142
+ TargetExtensionState.CURRENT,
143
+ self.bundle,
144
+ retry_allowed=True,
145
+ )
146
+
147
+ with self.tools.mutation_session(paths.root / "agent"):
148
+ if state is TargetExtensionState.ABSENT:
149
+ self._install(paths)
150
+ result_state = TargetExtensionState.INSTALLED
151
+ elif state is TargetExtensionState.CURRENT:
152
+ result_state = TargetExtensionState.CURRENT
153
+ else:
154
+ assert installed is not None
155
+ self._update(paths, installed)
156
+ result_state = TargetExtensionState.UPDATED
157
+
158
+ if state is TargetExtensionState.ABSENT:
159
+ self._require_current(paths.final_dump, paths.log("verify"))
160
+ self.profiler.measure(
161
+ "extension.safe_mode",
162
+ lambda: self.tools.disable_safe_mode(paths.root / "safe-mode"),
163
+ )
164
+ return LifecycleDecision(
165
+ LifecycleMode.SLOW,
166
+ result_state,
167
+ self.bundle,
168
+ retry_allowed=False,
169
+ )
170
+
171
+ def invalidate_marker(self) -> None:
172
+ self.state_store.invalidate()
173
+
174
+ def prepare_manual(self) -> LifecycleDecision:
175
+ with self._lock.acquire(timeout_s=self._lock_timeout_s):
176
+ self.state_store.invalidate()
177
+ return LifecycleDecision(
178
+ LifecycleMode.MANUAL,
179
+ TargetExtensionState.USER_MANAGED,
180
+ self.bundle,
181
+ retry_allowed=False,
182
+ )
183
+
184
+ def commit_handshake(
185
+ self,
186
+ evidence: tuple[ExtensionHandshakeEvidence, ExtensionHandshakeEvidence],
187
+ ) -> None:
188
+ def commit() -> None:
189
+ self._validated_handshake(evidence)
190
+ manifest = self.bundle.manifest
191
+ state = VerifiedExtensionState(
192
+ infobase_path=self.config.infobase_identity,
193
+ product_id=manifest.product_id,
194
+ cfe_sha256=manifest.cfe_sha256,
195
+ manifest_schema_version=manifest.schema_version,
196
+ artifact_version=manifest.artifact_version,
197
+ protocol_version=manifest.protocol_version,
198
+ identity_sha256=manifest.fingerprints.identity_sha256,
199
+ )
200
+ with self._lock.acquire(timeout_s=self._lock_timeout_s):
201
+ self.state_store.write(state)
202
+
203
+ self.profiler.measure("extension.handshake", commit)
204
+
205
+ def accept_manual_handshake(
206
+ self,
207
+ evidence: tuple[ExtensionHandshakeEvidence, ExtensionHandshakeEvidence],
208
+ ) -> str:
209
+ def accept() -> str:
210
+ managed, _server = self._validated_handshake(
211
+ evidence,
212
+ allow_artifact_version_mismatch=True,
213
+ )
214
+ return managed.artifact_version
215
+
216
+ return self.profiler.measure("extension.handshake", accept)
217
+
218
+ def _fast_decision(self) -> LifecycleDecision:
219
+ return LifecycleDecision(
220
+ LifecycleMode.FAST,
221
+ TargetExtensionState.CURRENT,
222
+ self.bundle,
223
+ retry_allowed=True,
224
+ )
225
+
226
+ def _new_operation_paths(self) -> _OperationPaths:
227
+ parent = self.config.runtime_dir / "extension-lifecycle"
228
+ parent.mkdir(parents=True, exist_ok=True)
229
+ root = Path(tempfile.mkdtemp(prefix="attempt-", dir=parent))
230
+ root.chmod(0o700)
231
+ return _OperationPaths(
232
+ root=root,
233
+ initial_dump=root / "installed-before",
234
+ final_dump=root / "installed-after",
235
+ rollback_dump=root / "installed-rollback",
236
+ backup_cfe=root / "installed-backup.cfe",
237
+ )
238
+
239
+ def _inspect(
240
+ self, destination: Path, log_path: Path
241
+ ) -> tuple[TargetExtensionState, ExtensionFingerprints | None]:
242
+ def inspect() -> ExtensionFingerprints | None:
243
+ try:
244
+ self.tools.dump_files(destination, log_path)
245
+ except ExtensionNotInstalled:
246
+ return None
247
+ return fingerprint_extension_dump(destination, expected_product_id=None)
248
+
249
+ installed = self.profiler.measure("extension.inspect", inspect)
250
+ if installed is None:
251
+ return TargetExtensionState.ABSENT, None
252
+
253
+ expected = self.bundle.manifest.fingerprints
254
+ if installed == expected:
255
+ return TargetExtensionState.CURRENT, installed
256
+ if (
257
+ installed.identity == expected.identity
258
+ and installed.identity_sha256 == expected.identity_sha256
259
+ ):
260
+ installed_version = _product_version_key(
261
+ installed.artifact.artifact_version
262
+ )
263
+ packaged_version = _product_version_key(expected.artifact.artifact_version)
264
+ if installed_version < packaged_version:
265
+ return TargetExtensionState.OLDER, installed
266
+ raise ExtensionLifecycleError(
267
+ "installed same-identity extension is not a strictly older artifact"
268
+ )
269
+ return TargetExtensionState.FOREIGN, installed
270
+
271
+ def _install(self, paths: _OperationPaths) -> None:
272
+ self.profiler.measure(
273
+ "extension.install",
274
+ lambda: self.tools.load_cfe(self.bundle.cfe_path, paths.log("install")),
275
+ )
276
+ self._apply(paths.log("apply"))
277
+
278
+ def _update(
279
+ self, paths: _OperationPaths, old_fingerprints: ExtensionFingerprints
280
+ ) -> None:
281
+ backup_ready = False
282
+
283
+ def backup_and_load() -> ToolResult | None:
284
+ nonlocal backup_ready
285
+ self.tools.dump_cfe(paths.backup_cfe, paths.log("backup"))
286
+ backup_ready = True
287
+ return self.tools.load_cfe(self.bundle.cfe_path, paths.log("update"))
288
+
289
+ try:
290
+ self.profiler.measure("extension.update", backup_and_load)
291
+ self._apply(paths.log("apply"))
292
+ self._require_current(paths.final_dump, paths.log("verify"))
293
+ except BaseException as primary_error:
294
+ if backup_ready:
295
+ try:
296
+ self._rollback(paths, old_fingerprints)
297
+ except BaseException as rollback_error: # noqa: BLE001
298
+ primary_error.add_note(
299
+ f"Extension rollback failed: {type(rollback_error).__name__}"
300
+ )
301
+ raise
302
+
303
+ def _rollback(
304
+ self, paths: _OperationPaths, old_fingerprints: ExtensionFingerprints
305
+ ) -> None:
306
+ self.profiler.measure(
307
+ "extension.update",
308
+ lambda: self.tools.load_cfe(paths.backup_cfe, paths.log("rollback-load")),
309
+ )
310
+ self._apply(paths.log("rollback-apply"))
311
+ restored = self.profiler.measure(
312
+ "extension.inspect",
313
+ lambda: self._dump_fingerprints(
314
+ paths.rollback_dump, paths.log("rollback-verify")
315
+ ),
316
+ )
317
+ if restored != old_fingerprints:
318
+ raise ExtensionLifecycleError(
319
+ "extension rollback did not restore the exact prior artifact"
320
+ )
321
+
322
+ def _apply(self, log_path: Path) -> None:
323
+ self.profiler.measure("extension.apply", lambda: self.tools.apply(log_path))
324
+
325
+ def _require_current(self, destination: Path, log_path: Path) -> None:
326
+ state, _ = self._inspect(destination, log_path)
327
+ if state is not TargetExtensionState.CURRENT:
328
+ raise ExtensionLifecycleError(
329
+ "extension mutation did not produce the exact packaged artifact"
330
+ )
331
+
332
+ def _dump_fingerprints(
333
+ self, destination: Path, log_path: Path
334
+ ) -> ExtensionFingerprints:
335
+ self.tools.dump_files(destination, log_path)
336
+ return fingerprint_extension_dump(destination)
337
+
338
+ def _validated_handshake(
339
+ self,
340
+ evidence: tuple[ExtensionHandshakeEvidence, ExtensionHandshakeEvidence],
341
+ *,
342
+ allow_artifact_version_mismatch: bool = False,
343
+ ) -> tuple[ExtensionHandshakeEvidence, ExtensionHandshakeEvidence]:
344
+ if not isinstance(evidence, tuple) or len(evidence) != 2:
345
+ raise ExtensionLifecycleError(
346
+ "extension handshake requires exactly two evidence records"
347
+ )
348
+ if any(not isinstance(item, ExtensionHandshakeEvidence) for item in evidence):
349
+ raise ExtensionLifecycleError("extension handshake evidence is malformed")
350
+ by_target = {item.target_type: item for item in evidence}
351
+ if len(by_target) != len(evidence):
352
+ raise ExtensionLifecycleError(
353
+ "extension handshake contains duplicate target records"
354
+ )
355
+ server_target_type = self.config.server_target_type
356
+ if set(by_target) != {"ManagedClient", server_target_type}:
357
+ raise ExtensionLifecycleError(
358
+ "extension handshake requires one managed and one server record"
359
+ )
360
+
361
+ managed = by_target["ManagedClient"]
362
+ server = by_target[server_target_type]
363
+ manifest = self.bundle.manifest
364
+ expected_locations = {
365
+ "ManagedClient": manifest.breakpoints.managed,
366
+ server_target_type: manifest.breakpoints.server_entry,
367
+ }
368
+ if any(
369
+ item.location != expected_locations[item.target_type]
370
+ for item in (managed, server)
371
+ ):
372
+ raise ExtensionLifecycleError(
373
+ "extension handshake does not match required breakpoint locations"
374
+ )
375
+ if (
376
+ managed.product_id != server.product_id
377
+ or managed.artifact_version != server.artifact_version
378
+ or managed.protocol_version != server.protocol_version
379
+ ):
380
+ raise ExtensionLifecycleError(
381
+ "extension client/server handshake values disagree"
382
+ )
383
+ if (
384
+ managed.product_id != manifest.product_id
385
+ or managed.protocol_version != manifest.protocol_version
386
+ or (
387
+ not allow_artifact_version_mismatch
388
+ and managed.artifact_version != manifest.artifact_version
389
+ )
390
+ ):
391
+ raise ExtensionLifecycleError(
392
+ "extension handshake does not match the packaged manifest"
393
+ )
394
+ return managed, server
395
+
396
+ __all__ = [
397
+ "ExtensionLifecycle",
398
+ "ExtensionToolOperations",
399
+ "LifecycleDecision",
400
+ "LifecycleMode",
401
+ "TargetExtensionState",
402
+ ]
@@ -0,0 +1,222 @@
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ import json
5
+ import math
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ import time
10
+ from collections.abc import Iterator
11
+ from contextlib import AbstractContextManager, contextmanager
12
+ from dataclasses import asdict, dataclass
13
+ from hashlib import sha256
14
+ from pathlib import Path
15
+ from typing import BinaryIO
16
+
17
+ from onec_runtime.errors import ExtensionLockTimeout
18
+ from onec_runtime.extension_bundle import ExtensionManifest
19
+
20
+ if sys.platform == "win32":
21
+ import msvcrt
22
+ else:
23
+ import fcntl
24
+
25
+ _STATE_FIELDS = {
26
+ "infobase_path",
27
+ "product_id",
28
+ "cfe_sha256",
29
+ "manifest_schema_version",
30
+ "artifact_version",
31
+ "protocol_version",
32
+ "identity_sha256",
33
+ "safe_mode",
34
+ }
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class VerifiedExtensionState:
39
+ infobase_path: str
40
+ product_id: str
41
+ cfe_sha256: str
42
+ manifest_schema_version: int
43
+ artifact_version: str
44
+ protocol_version: str
45
+ identity_sha256: str
46
+ safe_mode: bool = False
47
+
48
+
49
+ def _canonical_infobase(infobase_dir: Path | str) -> str:
50
+ # Strings are already canonical connection identities from RuntimeConfig.
51
+ # Keep Path callers and existing file cache keys backward compatible.
52
+ if isinstance(infobase_dir, str):
53
+ return infobase_dir
54
+ return str(infobase_dir.resolve())
55
+
56
+
57
+ def _infobase_key(canonical_infobase: str) -> str:
58
+ return sha256(canonical_infobase.encode()).hexdigest()
59
+
60
+
61
+ def _is_int(value: object) -> bool:
62
+ return isinstance(value, int) and not isinstance(value, bool)
63
+
64
+
65
+ def _is_hash(value: object) -> bool:
66
+ return (
67
+ isinstance(value, str)
68
+ and len(value) == 64
69
+ and all(character in "0123456789abcdef" for character in value)
70
+ )
71
+
72
+
73
+ def _decode_state(
74
+ payload: object, *, canonical_infobase: str
75
+ ) -> VerifiedExtensionState | None:
76
+ if not isinstance(payload, dict) or set(payload) != _STATE_FIELDS:
77
+ return None
78
+ if payload["safe_mode"] is not False:
79
+ return None
80
+ if not _is_int(payload["manifest_schema_version"]):
81
+ return None
82
+ if payload["infobase_path"] != canonical_infobase:
83
+ return None
84
+ for field in ("product_id", "artifact_version", "protocol_version"):
85
+ value = payload[field]
86
+ if not isinstance(value, str) or not value:
87
+ return None
88
+ for field in ("cfe_sha256", "identity_sha256"):
89
+ if not _is_hash(payload[field]):
90
+ return None
91
+ return VerifiedExtensionState(
92
+ infobase_path=payload["infobase_path"],
93
+ product_id=payload["product_id"],
94
+ cfe_sha256=payload["cfe_sha256"],
95
+ manifest_schema_version=payload["manifest_schema_version"],
96
+ artifact_version=payload["artifact_version"],
97
+ protocol_version=payload["protocol_version"],
98
+ identity_sha256=payload["identity_sha256"],
99
+ safe_mode=False,
100
+ )
101
+
102
+
103
+ class ExtensionStateStore:
104
+ def __init__(self, runtime_dir: Path, infobase_dir: Path | str) -> None:
105
+ self._canonical_infobase = _canonical_infobase(infobase_dir)
106
+ self.path = runtime_dir / f"{_infobase_key(self._canonical_infobase)}.json"
107
+
108
+ def read(self) -> VerifiedExtensionState | None:
109
+ try:
110
+ payload = json.loads(self.path.read_text(encoding="utf-8"))
111
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
112
+ return None
113
+ return _decode_state(payload, canonical_infobase=self._canonical_infobase)
114
+
115
+ def matches(self, manifest: ExtensionManifest) -> bool:
116
+ state = self.read()
117
+ if state is None:
118
+ return False
119
+ return bool(
120
+ state.product_id == manifest.product_id
121
+ and state.cfe_sha256 == manifest.cfe_sha256
122
+ and state.manifest_schema_version == manifest.schema_version
123
+ and state.artifact_version == manifest.artifact_version
124
+ and state.protocol_version == manifest.protocol_version
125
+ and state.identity_sha256 == manifest.fingerprints.identity_sha256
126
+ )
127
+
128
+ def write(self, state: VerifiedExtensionState) -> None:
129
+ payload = asdict(state)
130
+ if _decode_state(payload, canonical_infobase=self._canonical_infobase) != state:
131
+ raise ValueError("extension state is invalid for this infobase")
132
+
133
+ self.path.parent.mkdir(parents=True, exist_ok=True)
134
+ descriptor, temporary_name = tempfile.mkstemp(
135
+ prefix=f".{self.path.stem}-",
136
+ suffix=".tmp",
137
+ dir=self.path.parent,
138
+ )
139
+ temporary_path = Path(temporary_name)
140
+ try:
141
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as handle:
142
+ json.dump(payload, handle, sort_keys=True, separators=(",", ":"))
143
+ handle.write("\n")
144
+ handle.flush()
145
+ os.fsync(handle.fileno())
146
+ os.replace(temporary_path, self.path)
147
+ finally:
148
+ temporary_path.unlink(missing_ok=True)
149
+
150
+ def invalidate(self) -> None:
151
+ self.path.unlink(missing_ok=True)
152
+
153
+
154
+ class InfobaseExtensionLock:
155
+ def __init__(self, runtime_dir: Path, infobase_dir: Path | str) -> None:
156
+ canonical_infobase = _canonical_infobase(infobase_dir)
157
+ self.path = runtime_dir / f"{_infobase_key(canonical_infobase)}.lock"
158
+
159
+ def acquire(self, *, timeout_s: float) -> AbstractContextManager[None]:
160
+ if not math.isfinite(timeout_s) or timeout_s < 0:
161
+ raise ValueError("timeout_s must be finite and non-negative")
162
+ return self._acquire(timeout_s=timeout_s)
163
+
164
+ @contextmanager
165
+ def _acquire(self, *, timeout_s: float) -> Iterator[None]:
166
+ self.path.parent.mkdir(parents=True, exist_ok=True)
167
+ with self.path.open("a+b") as handle:
168
+ self._ensure_lock_byte(handle)
169
+ deadline = time.monotonic() + timeout_s
170
+ while not self._try_lock(handle):
171
+ remaining = deadline - time.monotonic()
172
+ if remaining <= 0:
173
+ raise ExtensionLockTimeout(
174
+ f"timed out acquiring extension lifecycle lock: {self.path}"
175
+ )
176
+ time.sleep(min(0.01, remaining))
177
+ try:
178
+ yield None
179
+ finally:
180
+ self._unlock(handle)
181
+
182
+ @staticmethod
183
+ def _ensure_lock_byte(handle: BinaryIO) -> None:
184
+ handle.seek(0, os.SEEK_END)
185
+ if handle.tell() == 0:
186
+ handle.write(b"\0")
187
+ handle.flush()
188
+ os.fsync(handle.fileno())
189
+
190
+ @staticmethod
191
+ def _try_lock(handle: BinaryIO) -> bool:
192
+ handle.seek(0)
193
+ if sys.platform == "win32":
194
+ try:
195
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
196
+ except OSError as error:
197
+ if error.errno in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}:
198
+ return False
199
+ raise
200
+ else:
201
+ try:
202
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
203
+ except OSError as error:
204
+ if error.errno in {errno.EACCES, errno.EAGAIN}:
205
+ return False
206
+ raise
207
+ return True
208
+
209
+ @staticmethod
210
+ def _unlock(handle: BinaryIO) -> None:
211
+ handle.seek(0)
212
+ if sys.platform == "win32":
213
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
214
+ else:
215
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
216
+
217
+
218
+ __all__ = [
219
+ "ExtensionStateStore",
220
+ "InfobaseExtensionLock",
221
+ "VerifiedExtensionState",
222
+ ]
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from typing import Callable
5
+
6
+ from onec_runtime.errors import RdbgTransportError
7
+
8
+
9
+ class FaultPoint(str, Enum):
10
+ AFTER_CAPTURE_CHECKPOINT = "after_capture_checkpoint"
11
+ AFTER_FIRST_ROOT_WRITE = "after_first_root_write"
12
+ AFTER_CONTINUE_ACK = "after_continue_ack"
13
+
14
+
15
+ class InjectedTransportFailure(RdbgTransportError):
16
+ """A deterministic transport loss at a tested lifecycle boundary."""
17
+
18
+
19
+ class CloseTransportAt:
20
+ def __init__(self, point: FaultPoint, close: Callable[[], None]) -> None:
21
+ self.point = point
22
+ self.close = close
23
+ self.fired = False
24
+
25
+ def __call__(self, point: FaultPoint) -> None:
26
+ if self.fired or point is not self.point:
27
+ return
28
+ self.fired = True
29
+ self.close()
30
+ raise InjectedTransportFailure(f"Injected fault at {point.value}")