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,2123 @@
1
+ from __future__ import annotations
2
+
3
+ import stat
4
+ import warnings
5
+ from contextlib import contextmanager
6
+ from collections.abc import Callable, Mapping, Sequence
7
+ from dataclasses import dataclass, field, replace
8
+ from enum import StrEnum
9
+ from hashlib import sha256
10
+ from pathlib import Path
11
+ from threading import Event, RLock, Thread, current_thread
12
+ from time import sleep
13
+ from typing import Iterator, cast
14
+ from uuid import UUID, uuid4
15
+
16
+ import pandas as pd
17
+ import psutil
18
+
19
+ from onec_runtime.artifacts import ArtifactWriter
20
+ from onec_runtime.bootstrap import (
21
+ ExtensionHandshakeContract,
22
+ ServerGuardSession,
23
+ enable_server_kernel_loop,
24
+ observe_extension_handshake,
25
+ verify_extension_handshake,
26
+ verify_extension_safe_mode_disabled,
27
+ wait_for_managed_startup_stop,
28
+ wait_for_server_entry_then_service,
29
+ )
30
+ from onec_runtime.bsl import (
31
+ SourceUnitKind,
32
+ SourceUnitRef,
33
+ mapped_visible_source,
34
+ source_sha256,
35
+ )
36
+ from onec_runtime.bsl.module_universe import (
37
+ WorkerModuleUnit,
38
+ )
39
+ from onec_runtime.bsl.module_catalog import SessionCommonModuleCatalog
40
+ from onec_runtime.capture_source import (
41
+ CaptureBinding,
42
+ CapturePointRequest,
43
+ CaptureSourceConfig,
44
+ CommonModuleCaptureResolver,
45
+ ResolvedCapturePoint,
46
+ )
47
+ from onec_runtime.config import RuntimeConfig
48
+ from onec_runtime.configurator_agent import ExtensionAgentEditor, edit_extension
49
+ from onec_runtime.errors import (
50
+ CaptureSourceNotConfigured,
51
+ CommandTimeout,
52
+ ExtensionHandshakeError,
53
+ ManualExtensionUnavailable,
54
+ ManualExtensionVersionWarning,
55
+ ProcessStartError,
56
+ ProtocolError,
57
+ RdbgDebugUiNotRegistered,
58
+ TargetLost,
59
+ UnexpectedStop,
60
+ )
61
+ from onec_runtime.extension_bundle import (
62
+ ExtensionBundle,
63
+ ExtensionHandshakeEvidence,
64
+ ExtensionManifest,
65
+ packaged_extension_bundle,
66
+ )
67
+ from onec_runtime.extension_lifecycle import (
68
+ ExtensionLifecycle,
69
+ LifecycleDecision,
70
+ LifecycleMode,
71
+ )
72
+ from onec_runtime.extension_state import ExtensionStateStore
73
+ from onec_runtime.performance_profile import PhaseRecorder
74
+ from onec_runtime.processes import FileModeProcesses
75
+ from onec_runtime.prototype_runtime import (
76
+ ContinuationAttemptEvidence,
77
+ ContinuationAttemptSpec,
78
+ OperationState,
79
+ PrototypeRuntimeController,
80
+ )
81
+ from onec_runtime.rdbg.models import ModuleLocation, TargetId
82
+ from onec_runtime.rdbg.session import RdbgSession
83
+ from onec_runtime.rdbg.transport import RdbgTransport, TranscriptEntry
84
+ from onec_runtime.recovery_journal import RecoveryJournal
85
+ from onec_runtime.runtime_api import (
86
+ PrototypeRuntimeApi,
87
+ RuntimeNamespaceSnapshot,
88
+ RuntimeReply,
89
+ RuntimeStatus,
90
+ )
91
+ from onec_runtime.runtime_contracts import OperationExecutionProvenance
92
+ from onec_runtime.server_worker import (
93
+ NotebookWorkerArtifactBuilder,
94
+ )
95
+ from onec_runtime.startup_diagnostics import startup_log_hint
96
+ from onec_runtime.worker_universe import (
97
+ WorkerGenerationHandle,
98
+ WorkerModuleArtifactBuilder,
99
+ WorkerModuleArtifactCache,
100
+ )
101
+ from onec_runtime.worker_breakpoints import (
102
+ WorkerBreakpointReloadPolicy,
103
+ WorkerBreakpointReloadReport,
104
+ WorkerBreakpointStatus,
105
+ )
106
+ from onec_runtime.table_materialization import ReferenceMode
107
+ from onec_runtime.toolchain import (
108
+ ToolResult,
109
+ dump_target_extension_cfe,
110
+ dump_target_extension_files,
111
+ )
112
+
113
+
114
+ class ExtensionMode(StrEnum):
115
+ AUTO = "auto"
116
+ MANUAL = "manual"
117
+
118
+
119
+ @dataclass(frozen=True, slots=True, repr=False)
120
+ class RuntimeSessionConfig:
121
+ """Configuration owned by the headless runtime session."""
122
+
123
+ runtime: RuntimeConfig
124
+ evidence_root: Path | str | None = None
125
+ chunk_size: int = 2400
126
+ startup_profiler: PhaseRecorder | None = None
127
+ capture_source: CaptureSourceConfig | None = None
128
+ source_root: Path | str | None = None
129
+ extension_mode: ExtensionMode = ExtensionMode.AUTO
130
+
131
+ def __post_init__(self) -> None:
132
+ if not isinstance(self.extension_mode, ExtensionMode):
133
+ raise TypeError("extension_mode must be an ExtensionMode")
134
+ evidence_root = self.evidence_root
135
+ if evidence_root is None:
136
+ if not isinstance(self.runtime, RuntimeConfig):
137
+ raise ValueError("evidence_root requires a RuntimeConfig")
138
+ evidence_root = self.runtime.artifacts_dir
139
+ object.__setattr__(self, "evidence_root", Path(evidence_root).resolve())
140
+ if self.source_root is not None:
141
+ supplied_source_root = Path(self.source_root).absolute()
142
+ try:
143
+ # Resolve only after inspecting the original spelling: resolve()
144
+ # erases symlink/junction evidence, including in parent paths.
145
+ for component in (*reversed(supplied_source_root.parents), supplied_source_root):
146
+ status = component.lstat()
147
+ if (stat.S_ISLNK(status.st_mode) or
148
+ getattr(status, "st_file_attributes", 0) &
149
+ getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)):
150
+ raise ProtocolError("common-module source root is unsafe")
151
+ resolved_source_root = supplied_source_root.resolve(strict=True)
152
+ except OSError as error:
153
+ raise ProtocolError(
154
+ "common-module source root is unsafe"
155
+ ) from error
156
+ object.__setattr__(
157
+ self,
158
+ "source_root",
159
+ resolved_source_root,
160
+ )
161
+ if self.chunk_size <= 0:
162
+ raise ValueError("chunk_size must be positive")
163
+
164
+
165
+ @dataclass(frozen=True, slots=True)
166
+ class RuntimeCaptureFence:
167
+ capture_intent_id: str
168
+ operation_id: str
169
+ source_revision: int
170
+ source_sha256: str
171
+ capture_generation: int
172
+ stop_sequence: int
173
+
174
+
175
+ @dataclass(frozen=True, slots=True)
176
+ class RuntimeCaptureArming:
177
+ ticket_id: str
178
+ expected_controller_operation_id: int
179
+ expected_stop_sequence: int
180
+
181
+
182
+ @dataclass(frozen=True, slots=True)
183
+ class _ActiveCaptureTicket:
184
+ ticket_id: str
185
+ capture_intent_id: str
186
+ operation_id: str
187
+ capture_generation: int
188
+ source_revision: int
189
+ source_sha256: str
190
+ stop_sequence: int
191
+
192
+
193
+ @dataclass(frozen=True, slots=True)
194
+ class _AttemptBootstrapEvidence:
195
+ managed_stop: dict[str, object]
196
+ server_entry_stop: dict[str, object]
197
+ server_service_stop: dict[str, object]
198
+ guard: dict[str, object]
199
+ same_session: bool
200
+
201
+
202
+ class _OwnedAttemptFailure(Exception):
203
+ def __init__(
204
+ self,
205
+ primary: BaseException,
206
+ *,
207
+ stage: str,
208
+ repairable: bool,
209
+ cleanup_succeeded: bool,
210
+ cleanup_notes: tuple[str, ...],
211
+ ) -> None:
212
+ super().__init__(type(primary).__name__)
213
+ self.primary = primary
214
+ self.stage = stage
215
+ self.repairable = repairable
216
+ self.cleanup_succeeded = cleanup_succeeded
217
+ self.cleanup_notes = cleanup_notes
218
+
219
+
220
+ _STARTUP_CLEANUP_RETRY_NOTE = (
221
+ "Runtime startup cleanup is incomplete; retry with exception.retry_cleanup()"
222
+ )
223
+
224
+
225
+ def _expose_startup_cleanup_retry(
226
+ error: BaseException,
227
+ retry_cleanup: Callable[[], None],
228
+ ) -> None:
229
+ setattr(error, "retry_cleanup", retry_cleanup)
230
+ notes = getattr(error, "__notes__", ())
231
+ if _STARTUP_CLEANUP_RETRY_NOTE not in notes:
232
+ error.add_note(_STARTUP_CLEANUP_RETRY_NOTE)
233
+
234
+
235
+ class _StartupAttemptCleanup:
236
+ def __init__(
237
+ self,
238
+ runtime: RuntimeConfig,
239
+ processes: FileModeProcesses,
240
+ transport: RdbgTransport | None,
241
+ rdbg: RdbgSession | None,
242
+ ) -> None:
243
+ self._server_mode = runtime.is_server_infobase
244
+ self._processes = processes
245
+ self._transport = transport
246
+ self._rdbg = rdbg
247
+ self._server_session_terminated = not self._server_mode or rdbg is None
248
+ self._native_client_termination_requested = False
249
+ self._processes_closed = False
250
+ self._debug_ui_detached = not self._server_mode or rdbg is None
251
+ self._transport_closed = transport is None
252
+ self.cleanup_error_types: tuple[str, ...] = ()
253
+
254
+ def _raise_cleanup_error(self, errors: list[BaseException]) -> None:
255
+ self.cleanup_error_types = tuple(
256
+ type(error).__name__ for error in errors
257
+ )
258
+ if errors:
259
+ raise ProtocolError(
260
+ "Runtime startup cleanup failed: "
261
+ + ", ".join(self.cleanup_error_types)
262
+ ) from None
263
+
264
+ def retry_cleanup(self) -> None:
265
+ """Retry incomplete startup cleanup in safe resource order."""
266
+ errors: list[BaseException] = []
267
+ self.cleanup_error_types = ()
268
+ if self._server_mode:
269
+ if not self._server_session_terminated:
270
+ try:
271
+ assert self._rdbg is not None
272
+ self._native_client_termination_requested = self._rdbg.terminate_bound_server_session()
273
+ except RdbgDebugUiNotRegistered:
274
+ # Native termination cannot use a UI the server has lost.
275
+ # Close only our client, then finish deregistration.
276
+ self._server_session_terminated = True
277
+ except BaseException as error:
278
+ errors.append(error)
279
+ else:
280
+ self._server_session_terminated = True
281
+ self._raise_cleanup_error(errors)
282
+ if not self._processes_closed:
283
+ try:
284
+ if self._native_client_termination_requested:
285
+ self._processes.close(graceful_client_timeout_s=3.0)
286
+ else:
287
+ self._processes.close()
288
+ except BaseException as error:
289
+ errors.append(error)
290
+ else:
291
+ self._processes_closed = True
292
+ self._raise_cleanup_error(errors)
293
+ if not self._debug_ui_detached:
294
+ try:
295
+ assert self._rdbg is not None
296
+ self._rdbg.detach()
297
+ except BaseException as error:
298
+ errors.append(error)
299
+ else:
300
+ self._debug_ui_detached = True
301
+ self._raise_cleanup_error(errors)
302
+ if not self._transport_closed:
303
+ try:
304
+ assert self._transport is not None
305
+ self._transport.close()
306
+ except BaseException as error:
307
+ errors.append(error)
308
+ else:
309
+ self._transport_closed = True
310
+ self._raise_cleanup_error(errors)
311
+ return
312
+
313
+ if not self._transport_closed:
314
+ try:
315
+ assert self._transport is not None
316
+ self._transport.close()
317
+ except BaseException as error:
318
+ errors.append(error)
319
+ else:
320
+ self._transport_closed = True
321
+ if not self._processes_closed:
322
+ try:
323
+ self._processes.close()
324
+ except BaseException as error:
325
+ errors.append(error)
326
+ else:
327
+ self._processes_closed = True
328
+ self._raise_cleanup_error(errors)
329
+
330
+
331
+ def _start_rdbg_with_registration_retry(
332
+ *,
333
+ transport: RdbgTransport,
334
+ location: ModuleLocation,
335
+ alias: str,
336
+ server_target_type: str,
337
+ break_on_next: bool,
338
+ progress: Callable[[str], None] | None,
339
+ on_candidate: Callable[[RdbgSession], None] | None = None,
340
+ attempts: int = 24,
341
+ retry_interval_s: float = 5.0,
342
+ ) -> RdbgSession:
343
+ """Register a fresh debugger UI before any owned 1C client is launched."""
344
+ if attempts < 1 or retry_interval_s < 0:
345
+ raise ValueError("RDBG registration retry bounds are invalid")
346
+ for number in range(1, attempts + 1):
347
+ rdbg = RdbgSession(
348
+ transport,
349
+ location,
350
+ alias=alias,
351
+ server_target_type=server_target_type,
352
+ break_on_next=break_on_next,
353
+ )
354
+ if on_candidate is not None:
355
+ on_candidate(rdbg)
356
+ try:
357
+ rdbg.initialize()
358
+ rdbg.set_service_breakpoint()
359
+ rdbg.verify_registration()
360
+ except RdbgDebugUiNotRegistered as error:
361
+ try:
362
+ rdbg.detach()
363
+ except BaseException as cleanup_error:
364
+ error.add_note(
365
+ "RDBG registration cleanup failed: "
366
+ f"{type(cleanup_error).__name__}"
367
+ )
368
+ raise
369
+ if number == attempts:
370
+ raise ProtocolError(
371
+ "RDBG debug UI registration did not stabilize after "
372
+ f"{attempts} attempts"
373
+ ) from error
374
+ if progress is not None:
375
+ progress(
376
+ "Повторная регистрация интерфейса отладки 1С "
377
+ f"({number + 1}/{attempts})"
378
+ )
379
+ sleep(retry_interval_s)
380
+ except BaseException as error:
381
+ try:
382
+ rdbg.detach()
383
+ except BaseException as cleanup_error:
384
+ error.add_note(
385
+ "RDBG registration cleanup failed: "
386
+ f"{type(cleanup_error).__name__}"
387
+ )
388
+ raise
389
+ else:
390
+ return rdbg
391
+ raise AssertionError("RDBG registration retry bound was exceeded")
392
+
393
+
394
+ @dataclass(slots=True)
395
+ class _OnecInteractiveRuntimeTools:
396
+ runtime: RuntimeConfig
397
+ _editor: ExtensionAgentEditor | None = field(default=None, init=False)
398
+
399
+ @contextmanager
400
+ def mutation_session(self, log_dir: Path) -> Iterator[None]:
401
+ if self._editor is not None:
402
+ raise ProtocolError("extension agent session is already active")
403
+ with edit_extension(self.runtime, log_dir) as editor:
404
+ self._editor = editor
405
+ try:
406
+ yield
407
+ finally:
408
+ self._editor = None
409
+
410
+ def dump_files(self, destination: Path, log_path: Path) -> ToolResult:
411
+ if self._editor is not None:
412
+ with self._editor.designer_access():
413
+ return dump_target_extension_files(self.runtime, destination, log_path)
414
+ return dump_target_extension_files(self.runtime, destination, log_path)
415
+
416
+ def dump_cfe(self, destination: Path, log_path: Path) -> ToolResult:
417
+ if self._editor is not None:
418
+ with self._editor.designer_access():
419
+ return dump_target_extension_cfe(self.runtime, destination, log_path)
420
+ return dump_target_extension_cfe(self.runtime, destination, log_path)
421
+
422
+ def load_cfe(self, source: Path, log_path: Path) -> None:
423
+ if self._editor is None:
424
+ raise ProtocolError("extension agent session is not active")
425
+ self._editor.load_cfe(source)
426
+
427
+ def apply(self, log_path: Path) -> None:
428
+ if self._editor is None:
429
+ raise ProtocolError("extension agent session is not active")
430
+ self._editor.apply()
431
+
432
+ def disable_safe_mode(self, log_dir: Path) -> None:
433
+ if self._editor is None:
434
+ raise ProtocolError("extension agent session is not active")
435
+ self._editor.disable_safe_mode()
436
+
437
+
438
+ class _SessionContinuationAdmission:
439
+ def __init__(
440
+ self,
441
+ session: "RuntimeSession",
442
+ api_admission: object,
443
+ *,
444
+ active_ticket: _ActiveCaptureTicket | None,
445
+ active_points: tuple[object, ...],
446
+ active_locations: tuple[object, ...],
447
+ arming: RuntimeCaptureArming | None,
448
+ ) -> None:
449
+ self._session = session
450
+ self._api_admission = api_admission
451
+ self._active_ticket = active_ticket
452
+ self._active_points = active_points
453
+ self._active_locations = active_locations
454
+ self.arming = arming
455
+ self._closed = False
456
+
457
+ def commit(self) -> None:
458
+ if not self._closed:
459
+ getattr(self._api_admission, "commit")()
460
+ self._closed = True
461
+
462
+ def rollback(self) -> None:
463
+ if self._closed:
464
+ return
465
+ getattr(self._api_admission, "rollback")()
466
+ with self._session._operation_lock:
467
+ self._session._active_capture_ticket = self._active_ticket
468
+ self._session._active_capture_points = self._active_points
469
+ self._session._active_capture_locations = self._active_locations
470
+ self._closed = True
471
+
472
+ def quarantine(self) -> None:
473
+ if self._closed:
474
+ return
475
+ try:
476
+ getattr(self._api_admission, "quarantine")()
477
+ finally:
478
+ with self._session._operation_lock:
479
+ self._session._active_capture_ticket = None
480
+ self._session._active_capture_points = ()
481
+ self._session._active_capture_locations = ()
482
+ self._closed = True
483
+
484
+
485
+ def runtime_bootstrap_locations(
486
+ manifest: ExtensionManifest,
487
+ ) -> tuple[ModuleLocation, ModuleLocation, ModuleLocation]:
488
+ breakpoints = manifest.breakpoints
489
+ return breakpoints.managed, breakpoints.server_entry, breakpoints.server_service
490
+
491
+
492
+ def _redacted_transcript_sink(
493
+ artifacts: ArtifactWriter,
494
+ ) -> Callable[[TranscriptEntry], None]:
495
+ def sink(entry: TranscriptEntry) -> None:
496
+ artifacts.append_jsonl(
497
+ "rdbg-summary.jsonl",
498
+ {
499
+ "command": entry.command,
500
+ "status_code": entry.status_code,
501
+ "duration_ms": entry.duration_ms,
502
+ "request_sha256": sha256(entry.request).hexdigest(),
503
+ "response_sha256": sha256(entry.response).hexdigest(),
504
+ "error": bool(entry.error),
505
+ },
506
+ )
507
+
508
+ return sink
509
+
510
+
511
+ def _stop_summary(stop: object, *, target_type: str) -> dict[str, object]:
512
+ location = getattr(stop, "location")
513
+ return {
514
+ "target_type": target_type,
515
+ "module_type": location.module_type,
516
+ "line": location.line,
517
+ "extension_name": location.extension_name,
518
+ "reason": getattr(stop, "reason"),
519
+ "stop_by_breakpoint": getattr(stop, "stop_by_breakpoint"),
520
+ }
521
+
522
+
523
+ def _handshake_summary(evidence: ExtensionHandshakeEvidence) -> dict[str, object]:
524
+ location = evidence.location
525
+ return {
526
+ "target_type": evidence.target_type,
527
+ "product_id": evidence.product_id,
528
+ "artifact_version": evidence.artifact_version,
529
+ "protocol_version": evidence.protocol_version,
530
+ "location": {
531
+ "module_type": location.module_type,
532
+ "url": location.url,
533
+ "object_id": str(location.object_id),
534
+ "property_id": str(location.property_id),
535
+ "line": location.line,
536
+ "extension_name": location.extension_name,
537
+ "ext_id": location.ext_id,
538
+ },
539
+ }
540
+
541
+
542
+ class RuntimeSession:
543
+ """Own one headless 1C runtime/debugger pair."""
544
+
545
+ def __init__(
546
+ self,
547
+ config: RuntimeSessionConfig,
548
+ processes: FileModeProcesses,
549
+ transport: RdbgTransport,
550
+ rdbg: RdbgSession,
551
+ runtime_api: PrototypeRuntimeApi,
552
+ artifacts: ArtifactWriter,
553
+ *,
554
+ heartbeat_interval_s: float = 15.0,
555
+ ) -> None:
556
+ if heartbeat_interval_s <= 0:
557
+ raise ValueError("heartbeat_interval_s must be positive")
558
+ self.config = config
559
+ self._processes = processes
560
+ self._transport = transport
561
+ self._rdbg = rdbg
562
+ self.runtime_api = runtime_api
563
+ self.artifacts = artifacts
564
+ self._common_module_catalog = (
565
+ None
566
+ if config.source_root is None
567
+ else SessionCommonModuleCatalog(
568
+ config.source_root,
569
+ profile="runtime-session-server-v1",
570
+ )
571
+ )
572
+ self._worker_file_revisions: dict[str, int] = {}
573
+ self._active_worker_file_units: dict[str, SourceUnitRef] = {}
574
+ self._closed = False
575
+ self._close_lock = RLock()
576
+ self._transport_closed = False
577
+ self._processes_closed = False
578
+ self._runtime_api_closed = False
579
+ self._debug_ui_detached = not config.runtime.is_server_infobase
580
+ self._server_session_terminated = not config.runtime.is_server_infobase
581
+ self._native_client_termination_requested = False
582
+ self._operation_lock = RLock()
583
+ self._capture_locations: dict[tuple[str, int], object] = {}
584
+ self._capture_source_resolver: CommonModuleCaptureResolver | None = None
585
+ self._capture_source_bindings: dict[
586
+ tuple[str, int], CaptureBinding
587
+ ] = {}
588
+ self._active_capture_points: tuple[object, ...] = ()
589
+ self._active_capture_locations: tuple[object, ...] = ()
590
+ self._file_capture_points: tuple[ModuleLocation, ...] = ()
591
+ self._active_capture_ticket: _ActiveCaptureTicket | None = None
592
+ self._capture_resume_listeners: list[Callable[[RuntimeCaptureFence], None]] = []
593
+ self._attempt_bootstrap_evidence: _AttemptBootstrapEvidence | None = None
594
+ self._heartbeat_interval_s = heartbeat_interval_s
595
+ self._heartbeat_stop = Event()
596
+ self._heartbeat_thread = Thread(
597
+ target=self._heartbeat_loop,
598
+ name="onec-runtime-rdbg-heartbeat",
599
+ daemon=True,
600
+ )
601
+ self._heartbeat_thread.start()
602
+
603
+ def _heartbeat_loop(self) -> None:
604
+ while not self._heartbeat_stop.wait(self._heartbeat_interval_s):
605
+ if not self._operation_lock.acquire(blocking=False):
606
+ continue
607
+ lost_debug_ui = False
608
+ lost_owned_process = False
609
+ try:
610
+ if not self._closed:
611
+ try:
612
+ self._rdbg.heartbeat()
613
+ except RdbgDebugUiNotRegistered:
614
+ lost_debug_ui = True
615
+ except Exception:
616
+ # A transient transport failure must not permanently
617
+ # disable later keepalives. The next notebook command
618
+ # remains the authoritative place to surface it.
619
+ pass
620
+ ensure_running = getattr(self._processes, "ensure_running", None)
621
+ if callable(ensure_running):
622
+ try:
623
+ ensure_running()
624
+ except TargetLost:
625
+ lost_owned_process = True
626
+ except Exception:
627
+ # Process inspection can fail transiently too.
628
+ pass
629
+ finally:
630
+ self._operation_lock.release()
631
+ if lost_debug_ui or lost_owned_process:
632
+ try:
633
+ self.close()
634
+ except BaseException:
635
+ # The owner retains incomplete cleanup for an explicit retry.
636
+ warnings.warn(
637
+ "1C runtime lost its debug UI or owned process; "
638
+ "cleanup is incomplete; "
639
+ "retry runtime.close()",
640
+ RuntimeWarning,
641
+ stacklevel=2,
642
+ )
643
+ return
644
+
645
+ @classmethod
646
+ def start(
647
+ cls,
648
+ config: RuntimeSessionConfig,
649
+ *,
650
+ progress: Callable[[str], None] | None = None,
651
+ ) -> "RuntimeSession":
652
+ runtime = config.runtime
653
+ artifacts = ArtifactWriter(config.evidence_root, "runtime-session")
654
+ profiler = config.startup_profiler or PhaseRecorder()
655
+ attempt_failures: list[dict[str, object]] = []
656
+ primary_error: BaseException | None = None
657
+ completed_session: RuntimeSession | None = None
658
+ try:
659
+ if progress is not None:
660
+ progress("Загрузка расширения 1С")
661
+ bundle = profiler.measure(
662
+ "extension.bundle",
663
+ lambda: packaged_extension_bundle(runtime.runtime_dir),
664
+ )
665
+ state_store = ExtensionStateStore(
666
+ runtime.runtime_dir / "extension-state",
667
+ runtime.infobase_identity,
668
+ )
669
+ lifecycle = ExtensionLifecycle(
670
+ runtime,
671
+ bundle,
672
+ state_store,
673
+ _OnecInteractiveRuntimeTools(runtime),
674
+ profiler=profiler,
675
+ )
676
+ manual_mode = config.extension_mode is ExtensionMode.MANUAL
677
+ if progress is not None:
678
+ progress("Проверка состояния расширения 1С")
679
+ decision = lifecycle.prepare_manual() if manual_mode else lifecycle.prepare()
680
+ # An RDBG UI can vanish after the pre-launch probe while the client
681
+ # starts. Keep one fresh full attempt separate from extension repair.
682
+ attempt_limit = (1 if manual_mode else 2) + 1
683
+ registration_retry_available = True
684
+ extension_repair_available = not manual_mode
685
+
686
+ for attempt_number in range(attempt_limit):
687
+ try:
688
+ runtime_session, evidence = cls._start_attempt(
689
+ config,
690
+ artifacts,
691
+ bundle,
692
+ lifecycle,
693
+ profiler,
694
+ verify_safe_mode=decision.mode in {
695
+ LifecycleMode.FAST, LifecycleMode.PROBED,
696
+ },
697
+ progress=progress,
698
+ )
699
+ except _OwnedAttemptFailure as failure:
700
+ attempt_failures.append(
701
+ {
702
+ "admitted": False,
703
+ "attempt": attempt_number + 1,
704
+ "cleanup_succeeded": failure.cleanup_succeeded,
705
+ "decision_mode": decision.mode.value,
706
+ "error_type": type(failure.primary).__name__,
707
+ "repairable": failure.repairable,
708
+ "stage": failure.stage,
709
+ }
710
+ )
711
+ manual_unavailable = (
712
+ manual_mode
713
+ and failure.stage in {"managed-bootstrap", "server-entry-wait"}
714
+ and isinstance(failure.primary, (CommandTimeout, UnexpectedStop))
715
+ )
716
+ if manual_unavailable:
717
+ unavailable = ManualExtensionUnavailable(
718
+ "The user-managed runtime extension is unavailable; "
719
+ "install and apply the CFE from the current release "
720
+ "and preserve its bootstrap ABI"
721
+ )
722
+ for note in failure.cleanup_notes:
723
+ unavailable.add_note(note)
724
+ retry_cleanup = getattr(
725
+ failure.primary, "retry_cleanup", None
726
+ )
727
+ if callable(retry_cleanup):
728
+ _expose_startup_cleanup_retry(
729
+ unavailable, retry_cleanup
730
+ )
731
+ raise unavailable from None
732
+ if (
733
+ registration_retry_available
734
+ and failure.cleanup_succeeded
735
+ and isinstance(failure.primary, RdbgDebugUiNotRegistered)
736
+ ):
737
+ registration_retry_available = False
738
+ if progress is not None:
739
+ progress("Повторный запуск после потери интерфейса отладки 1С")
740
+ continue
741
+ may_repair = (
742
+ extension_repair_available
743
+ and not manual_mode
744
+ and decision.mode in {LifecycleMode.FAST, LifecycleMode.PROBED}
745
+ and decision.retry_allowed
746
+ and failure.repairable
747
+ and failure.cleanup_succeeded
748
+ )
749
+ if not may_repair:
750
+ raise failure.primary.with_traceback(
751
+ failure.primary.__traceback__
752
+ ) from None
753
+ extension_repair_available = False
754
+
755
+ def repair() -> LifecycleDecision:
756
+ lifecycle.invalidate_marker()
757
+ if progress is not None:
758
+ progress("Повторная подготовка расширения через Конфигуратор 1С")
759
+ return lifecycle.prepare(force_slow=True)
760
+
761
+ try:
762
+ decision = profiler.measure("extension.retry", repair)
763
+ except BaseException as repair_error: # noqa: BLE001
764
+ failure.primary.add_note(
765
+ "Runtime startup repair failed: "
766
+ f"{type(repair_error).__name__}"
767
+ )
768
+ retry_cleanup = getattr(repair_error, "retry_cleanup", None)
769
+ if callable(retry_cleanup):
770
+ _expose_startup_cleanup_retry(failure.primary, retry_cleanup)
771
+ raise failure.primary.with_traceback(
772
+ failure.primary.__traceback__
773
+ ) from None
774
+ continue
775
+
776
+ try:
777
+ manifest = bundle.manifest
778
+ manual_extension: dict[str, object] | None = None
779
+ if manual_mode:
780
+ observed_version = lifecycle.accept_manual_handshake(
781
+ (evidence[0], evidence[1])
782
+ )
783
+ version_matches = observed_version == manifest.artifact_version
784
+ if not version_matches:
785
+ warnings.warn(
786
+ "User-managed runtime extension artifact version "
787
+ f"{observed_version!r} differs from packaged release "
788
+ f"{manifest.artifact_version!r}; continuing in manual mode",
789
+ ManualExtensionVersionWarning,
790
+ stacklevel=2,
791
+ )
792
+ manual_extension = {
793
+ "packaged_artifact_version": manifest.artifact_version,
794
+ "observed_artifact_version": observed_version,
795
+ "version_matches": version_matches,
796
+ "protocol_version": manifest.protocol_version,
797
+ }
798
+ else:
799
+ lifecycle.commit_handshake((evidence[0], evidence[1]))
800
+ details = runtime_session._attempt_bootstrap_evidence
801
+ if details is None:
802
+ raise ProtocolError("Runtime bootstrap evidence is unavailable")
803
+ bootstrap_payload: dict[str, object] = {
804
+ "status": "PASS",
805
+ "lifecycle": {
806
+ "mode": decision.mode.value,
807
+ "target_state": decision.target_state.value,
808
+ },
809
+ "bundle": {
810
+ "source": "packaged-release",
811
+ "artifact_sha256": manifest.fingerprints.artifact_sha256,
812
+ "cfe_sha256": manifest.cfe_sha256,
813
+ "identity_sha256": manifest.fingerprints.identity_sha256,
814
+ },
815
+ "attempt_failures": attempt_failures,
816
+ "handshakes": [
817
+ _handshake_summary(item) for item in evidence
818
+ ],
819
+ "managed_stop": details.managed_stop,
820
+ "server_entry_stop": details.server_entry_stop,
821
+ "server_service_stop": details.server_service_stop,
822
+ "guard": details.guard,
823
+ "same_session": details.same_session,
824
+ }
825
+ if manual_extension is not None:
826
+ bootstrap_payload["manual_extension"] = manual_extension
827
+ artifacts.write_json("bootstrap.json", bootstrap_payload)
828
+ except BaseException as error:
829
+ try:
830
+ runtime_session.close()
831
+ except BaseException as cleanup_error:
832
+ error.add_note(
833
+ "Runtime startup cleanup failed: "
834
+ f"{type(cleanup_error).__name__}"
835
+ )
836
+ _expose_startup_cleanup_retry(
837
+ error, runtime_session.close
838
+ )
839
+ raise
840
+ if config.capture_source is not None:
841
+ try:
842
+ runtime_session.configure_capture_source(
843
+ config.capture_source.project,
844
+ config.capture_source.source_root,
845
+ )
846
+ except BaseException as error:
847
+ try:
848
+ runtime_session.close()
849
+ except BaseException as cleanup_error: # noqa: BLE001
850
+ error.add_note(
851
+ "Runtime startup cleanup failed: "
852
+ f"{type(cleanup_error).__name__}"
853
+ )
854
+ _expose_startup_cleanup_retry(
855
+ error, runtime_session.close
856
+ )
857
+ raise
858
+ if progress is not None:
859
+ try:
860
+ progress("Сеанс 1С готов")
861
+ except BaseException as error:
862
+ try:
863
+ runtime_session.close()
864
+ except BaseException as cleanup_error:
865
+ error.add_note(
866
+ "Runtime startup cleanup failed: "
867
+ f"{type(cleanup_error).__name__}"
868
+ )
869
+ _expose_startup_cleanup_retry(
870
+ error, runtime_session.close
871
+ )
872
+ raise
873
+ completed_session = runtime_session
874
+ return runtime_session
875
+
876
+ raise AssertionError("runtime startup retry bound was exceeded")
877
+ except BaseException as error:
878
+ primary_error = error
879
+ raise
880
+ finally:
881
+ try:
882
+ artifacts.write_json(
883
+ "bootstrap-phases.json",
884
+ [event.as_dict() for event in profiler.events],
885
+ )
886
+ artifacts.write_json("bootstrap-attempts.json", attempt_failures)
887
+ except BaseException as evidence_error:
888
+ if primary_error is not None:
889
+ primary_error.add_note(
890
+ "Runtime startup phase evidence write failed: "
891
+ f"{type(evidence_error).__name__}"
892
+ )
893
+ else:
894
+ public_error = ProtocolError(
895
+ "Runtime startup phase evidence could not be written"
896
+ )
897
+ if completed_session is not None:
898
+ try:
899
+ completed_session.close()
900
+ except BaseException as cleanup_error:
901
+ public_error.add_note(
902
+ "Runtime startup cleanup failed: "
903
+ f"{type(cleanup_error).__name__}"
904
+ )
905
+ _expose_startup_cleanup_retry(
906
+ public_error, completed_session.close
907
+ )
908
+ raise public_error from None
909
+
910
+ @classmethod
911
+ def _start_attempt(
912
+ cls,
913
+ config: RuntimeSessionConfig,
914
+ artifacts: ArtifactWriter,
915
+ bundle: ExtensionBundle,
916
+ lifecycle: ExtensionLifecycle,
917
+ profiler: PhaseRecorder,
918
+ *,
919
+ verify_safe_mode: bool = False,
920
+ progress: Callable[[str], None] | None = None,
921
+ ) -> tuple["RuntimeSession", tuple[ExtensionHandshakeEvidence, ...]]:
922
+ del lifecycle, profiler
923
+ runtime = config.runtime
924
+ processes = FileModeProcesses(runtime)
925
+ transport: RdbgTransport | None = None
926
+ rdbg: RdbgSession | None = None
927
+ registration_candidates: list[RdbgSession] = []
928
+ runtime_session: RuntimeSession | None = None
929
+ stage = "process-start"
930
+ try:
931
+ managed_location, entry_location, service_location = (
932
+ runtime_bootstrap_locations(bundle.manifest)
933
+ )
934
+ if progress is not None:
935
+ progress("Запуск сервера отладки 1С")
936
+ debug_port = processes.start_debug_server()
937
+ transport = RdbgTransport(
938
+ runtime.debug_host,
939
+ debug_port,
940
+ transcript=_redacted_transcript_sink(artifacts),
941
+ )
942
+ rdbg = _start_rdbg_with_registration_retry(
943
+ transport=transport,
944
+ location=managed_location,
945
+ alias=runtime.infobase_debug_alias,
946
+ server_target_type=runtime.server_target_type,
947
+ break_on_next=not runtime.is_server_infobase,
948
+ progress=progress,
949
+ on_candidate=registration_candidates.append,
950
+ )
951
+ launch_token = "onec-runtime:" + uuid4().hex if runtime.is_server_infobase else None
952
+ if progress is not None:
953
+ progress("Запуск 1С:Предприятия")
954
+ processes.start_debuggee(
955
+ debug_port,
956
+ execute_external=False,
957
+ thick_client=False,
958
+ startup_parameter=launch_token,
959
+ )
960
+ stage = "managed-bootstrap"
961
+ if progress is not None:
962
+ progress("Ожидание клиентского сеанса 1С")
963
+ managed_stop = wait_for_managed_startup_stop(
964
+ rdbg,
965
+ managed_location,
966
+ timeout_s=150.0,
967
+ on_poll=processes.ensure_running,
968
+ )
969
+ if rdbg.target is None or rdbg.target.target_type != "ManagedClient":
970
+ raise ProtocolError("Runtime bootstrap did not stop on ManagedClient")
971
+ client_target = rdbg.target
972
+ if runtime.is_server_infobase:
973
+ rdbg.bind_server_session(launch_token=launch_token)
974
+ contract = ExtensionHandshakeContract(
975
+ bundle.manifest.product_id,
976
+ bundle.manifest.artifact_version,
977
+ bundle.manifest.protocol_version,
978
+ )
979
+ if config.extension_mode is ExtensionMode.MANUAL:
980
+ def handshake(
981
+ actual: ServerGuardSession,
982
+ *,
983
+ target_type: str,
984
+ location: ModuleLocation,
985
+ ) -> ExtensionHandshakeEvidence:
986
+ return observe_extension_handshake(
987
+ actual, target_type=target_type, location=location
988
+ )
989
+ else:
990
+ def handshake(
991
+ actual: ServerGuardSession,
992
+ *,
993
+ target_type: str,
994
+ location: ModuleLocation,
995
+ ) -> ExtensionHandshakeEvidence:
996
+ return verify_extension_handshake(
997
+ actual, contract, target_type=target_type, location=location
998
+ )
999
+ stage = "managed-handshake"
1000
+ if progress is not None:
1001
+ progress("Проверка расширения в клиентском сеансе")
1002
+ managed_handshake = handshake(
1003
+ rdbg,
1004
+ target_type="ManagedClient",
1005
+ location=managed_location,
1006
+ )
1007
+ stage = "server-guard"
1008
+ guard = enable_server_kernel_loop(rdbg)
1009
+ server_handshake: ExtensionHandshakeEvidence | None = None
1010
+ server_entry_target: TargetId | None = None
1011
+
1012
+ def verify_server_entry(actual: object) -> None:
1013
+ nonlocal server_handshake, server_entry_target, stage
1014
+ stage = "server-entry-handshake"
1015
+ if rdbg.target is None or rdbg.target.target_type != runtime.server_target_type:
1016
+ raise ProtocolError(
1017
+ f"Runtime server entry did not stop on {runtime.server_target_type}"
1018
+ )
1019
+ server_entry_target = rdbg.target.target_id
1020
+ server_handshake = handshake(
1021
+ cast(ServerGuardSession, actual),
1022
+ target_type=runtime.server_target_type,
1023
+ location=entry_location,
1024
+ )
1025
+ stage = "server-service"
1026
+
1027
+ stage = "server-entry-wait"
1028
+ if progress is not None:
1029
+ progress("Подключение серверного сеанса 1С")
1030
+ entry_stop, service_stop = wait_for_server_entry_then_service(
1031
+ rdbg,
1032
+ entry_location,
1033
+ service_location,
1034
+ timeout_s=150.0,
1035
+ on_entry=verify_server_entry,
1036
+ server_target_type=runtime.server_target_type,
1037
+ )
1038
+ if server_handshake is None or server_entry_target is None:
1039
+ raise ProtocolError("Runtime server entry handshake was not observed")
1040
+ if rdbg.target is None or rdbg.target.target_type != runtime.server_target_type:
1041
+ raise ProtocolError(f"Runtime bootstrap did not stop on {runtime.server_target_type}")
1042
+ server_target = rdbg.target
1043
+ if (
1044
+ client_target.target_id.seance_id is None
1045
+ or server_entry_target.seance_id is None
1046
+ or client_target.target_id.seance_id
1047
+ != server_entry_target.seance_id
1048
+ ):
1049
+ raise ProtocolError(
1050
+ "Runtime client/server targets belong to different sessions"
1051
+ )
1052
+ if server_target.target_id != server_entry_target:
1053
+ raise ProtocolError(
1054
+ "Runtime server entry/service stops belong to different server targets"
1055
+ )
1056
+
1057
+ if verify_safe_mode:
1058
+ stage = "safe-mode-check"
1059
+ if progress is not None:
1060
+ progress("Проверка безопасного режима расширения 1С")
1061
+ verify_extension_safe_mode_disabled(rdbg)
1062
+
1063
+ journal = RecoveryJournal(artifacts.append_jsonl)
1064
+ controller = PrototypeRuntimeController(
1065
+ rdbg,
1066
+ service_location,
1067
+ command_timeout_s=90.0,
1068
+ journal=journal,
1069
+ )
1070
+ notebook_worker_builder = NotebookWorkerArtifactBuilder(runtime)
1071
+ worker_module_builder = WorkerModuleArtifactBuilder(
1072
+ notebook_worker_builder,
1073
+ cache=WorkerModuleArtifactCache(),
1074
+ packer_version="worker-epf-v1",
1075
+ target_profile="runtime-session-server-v1",
1076
+ )
1077
+ api = PrototypeRuntimeApi(
1078
+ controller,
1079
+ journal=journal,
1080
+ notebook_worker_builder=notebook_worker_builder,
1081
+ worker_module_builder=worker_module_builder,
1082
+ )
1083
+ runtime_session = cls(config, processes, transport, rdbg, api, artifacts)
1084
+ runtime_session._attempt_bootstrap_evidence = _AttemptBootstrapEvidence(
1085
+ managed_stop=_stop_summary(
1086
+ managed_stop,
1087
+ target_type="ManagedClient",
1088
+ ),
1089
+ server_entry_stop=_stop_summary(
1090
+ entry_stop,
1091
+ target_type=runtime.server_target_type,
1092
+ ),
1093
+ server_service_stop=_stop_summary(
1094
+ service_stop,
1095
+ target_type=runtime.server_target_type,
1096
+ ),
1097
+ guard=guard.as_dict(),
1098
+ same_session=True,
1099
+ )
1100
+ return runtime_session, (managed_handshake, server_handshake)
1101
+ except BaseException as error:
1102
+ if rdbg is None and registration_candidates:
1103
+ # The helper may have failed while detaching its last candidate.
1104
+ # Keep that exact UI available to the owned cleanup retry.
1105
+ rdbg = registration_candidates[-1]
1106
+ if stage in {"managed-bootstrap", "server-entry-wait"} and isinstance(
1107
+ error, (CommandTimeout, UnexpectedStop, TargetLost)
1108
+ ):
1109
+ log_path = runtime.logs_dir / "1c-messages.log"
1110
+ hint = startup_log_hint(log_path)
1111
+ if hint is not None:
1112
+ error = ProcessStartError(
1113
+ f"1С:Предприятие: {hint}; подробности: {log_path}"
1114
+ )
1115
+ cleanup_errors: list[BaseException] = []
1116
+ cleanup_retry: Callable[[], None] | None = None
1117
+ cleanup_error_types: tuple[str, ...] = ()
1118
+ if runtime_session is not None:
1119
+ try:
1120
+ runtime_session.close()
1121
+ except BaseException as cleanup_error:
1122
+ cleanup_errors.append(cleanup_error)
1123
+ cleanup_retry = runtime_session.close
1124
+ else:
1125
+ cleanup = _StartupAttemptCleanup(
1126
+ runtime, processes, transport, rdbg
1127
+ )
1128
+ try:
1129
+ cleanup.retry_cleanup()
1130
+ except BaseException as cleanup_error:
1131
+ cleanup_errors.append(cleanup_error)
1132
+ cleanup_error_types = cleanup.cleanup_error_types
1133
+ cleanup_retry = cleanup.retry_cleanup
1134
+ # Keep only our cleanup diagnostics, separate from arbitrary primary notes.
1135
+ cleanup_notes: list[str] = []
1136
+ recorded_error_types = cleanup_error_types or tuple(
1137
+ type(recorded_cleanup_error).__name__
1138
+ for recorded_cleanup_error in cleanup_errors
1139
+ )
1140
+ for error_type in recorded_error_types:
1141
+ if not (
1142
+ len(error_type) <= 64
1143
+ and error_type.isascii()
1144
+ and error_type.isidentifier()
1145
+ ):
1146
+ error_type = "Exception"
1147
+ note = f"Runtime startup cleanup failed: {error_type}"
1148
+ cleanup_notes.append(note)
1149
+ error.add_note(note)
1150
+ if cleanup_retry is not None:
1151
+ _expose_startup_cleanup_retry(error, cleanup_retry)
1152
+ repairable = (
1153
+ stage in {"managed-handshake", "server-entry-handshake"}
1154
+ and isinstance(error, ExtensionHandshakeError)
1155
+ ) or (
1156
+ stage == "safe-mode-check"
1157
+ and isinstance(error, ExtensionHandshakeError)
1158
+ ) or (
1159
+ stage == "managed-bootstrap"
1160
+ and isinstance(error, (CommandTimeout, UnexpectedStop))
1161
+ )
1162
+ raise _OwnedAttemptFailure(
1163
+ error,
1164
+ stage=stage,
1165
+ repairable=repairable,
1166
+ cleanup_succeeded=not cleanup_errors,
1167
+ cleanup_notes=tuple(cleanup_notes),
1168
+ ) from None
1169
+
1170
+ def execute_bsl(
1171
+ self,
1172
+ source: str,
1173
+ *,
1174
+ source_unit: SourceUnitRef | None = None,
1175
+ on_execution_provenance: (
1176
+ Callable[[OperationExecutionProvenance], None] | None
1177
+ ) = None,
1178
+ ) -> RuntimeReply:
1179
+ with self._operation_lock:
1180
+ arguments: dict[str, object] = {}
1181
+ if source_unit is not None:
1182
+ arguments["source_unit"] = source_unit
1183
+ if on_execution_provenance is not None:
1184
+ arguments["on_execution_provenance"] = (
1185
+ on_execution_provenance
1186
+ )
1187
+ return self.runtime_api.execute_bsl(source, **arguments) # type: ignore[arg-type]
1188
+
1189
+ def configure_capture_source(self, project: str, source_root: Path | str) -> None:
1190
+ with self._operation_lock:
1191
+ if self._active_capture_ticket is not None:
1192
+ raise ProtocolError(
1193
+ "capture source cannot change during an active capture"
1194
+ )
1195
+ resolver = CommonModuleCaptureResolver(project, source_root)
1196
+ self.runtime_api.configure_capture_points(())
1197
+ self._file_capture_points = ()
1198
+ self._capture_source_resolver = resolver
1199
+ self._capture_source_bindings = {}
1200
+ self._capture_locations = {}
1201
+
1202
+ def clear_capture_source(self) -> None:
1203
+ with self._operation_lock:
1204
+ if self._active_capture_ticket is not None:
1205
+ raise ProtocolError(
1206
+ "capture source cannot change during an active capture"
1207
+ )
1208
+ self.runtime_api.configure_capture_points(())
1209
+ self._file_capture_points = ()
1210
+ self._capture_source_resolver = None
1211
+ self._capture_source_bindings = {}
1212
+ self._capture_locations = {}
1213
+
1214
+ def resolve_capture_points(
1215
+ self, points: Sequence[CapturePointRequest]
1216
+ ) -> tuple[ResolvedCapturePoint, ...]:
1217
+ with self._operation_lock:
1218
+ resolver = self._capture_source_resolver
1219
+ if resolver is None:
1220
+ raise CaptureSourceNotConfigured(
1221
+ "Symbolic capture source is not configured; call "
1222
+ "configure_capture_source(project, source_root) first"
1223
+ )
1224
+ bindings = resolver.resolve(points)
1225
+ indexed = {
1226
+ (binding.point.name.casefold(), binding.point.line): binding
1227
+ for binding in bindings
1228
+ }
1229
+ self._capture_source_bindings = indexed
1230
+ self._capture_locations = {
1231
+ key: binding.location for key, binding in indexed.items()
1232
+ }
1233
+ return tuple(binding.point for binding in bindings)
1234
+
1235
+ def verify_capture_points(
1236
+ self, points: Sequence[ResolvedCapturePoint]
1237
+ ) -> tuple[CaptureBinding, ...]:
1238
+ with self._operation_lock:
1239
+ bindings: list[CaptureBinding] = []
1240
+ for point in points:
1241
+ binding = self._capture_source_bindings.get(
1242
+ (point.name.casefold(), point.line)
1243
+ )
1244
+ if binding is None or binding.point != point:
1245
+ raise ValueError(
1246
+ "capture point was not resolved by this capture source"
1247
+ )
1248
+ bindings.append(binding)
1249
+ resolver = self._capture_source_resolver
1250
+ if resolver is None:
1251
+ raise ValueError(
1252
+ "capture point was not resolved by this capture source"
1253
+ )
1254
+ result = tuple(bindings)
1255
+ resolver.verify(result)
1256
+ return result
1257
+
1258
+ def prepare_main_for_capture(
1259
+ self, source: str, *, source_unit: SourceUnitRef
1260
+ ) -> object:
1261
+ with self._operation_lock:
1262
+ if self._closed:
1263
+ raise ProtocolError("ZUP demo runtime session is closed")
1264
+ return self.runtime_api.prepare_main_for_capture(
1265
+ source,
1266
+ source_unit=source_unit,
1267
+ )
1268
+
1269
+ def activate_prepared_main_for_capture(self, prepared: object) -> object:
1270
+ with self._operation_lock:
1271
+ if self._closed:
1272
+ raise ProtocolError("ZUP demo runtime session is closed")
1273
+ return self.runtime_api.activate_prepared_main_for_capture(prepared)
1274
+
1275
+ def prepared_main_execution_provenance(
1276
+ self,
1277
+ prepared: object,
1278
+ ) -> OperationExecutionProvenance:
1279
+ with self._operation_lock:
1280
+ if self._closed:
1281
+ raise ProtocolError("ZUP demo runtime session is closed")
1282
+ return self.runtime_api.prepared_main_execution_provenance(
1283
+ prepared
1284
+ )
1285
+
1286
+ def execute_prepared_main_for_capture(self, prepared: object) -> object:
1287
+ with self._operation_lock:
1288
+ if self._closed:
1289
+ raise ProtocolError("ZUP demo runtime session is closed")
1290
+ return self.runtime_api._attempt_prepared_main_for_capture(prepared)
1291
+
1292
+ def discard_prepared_main_for_capture(self, prepared: object) -> None:
1293
+ with self._operation_lock:
1294
+ if self._closed:
1295
+ raise ProtocolError("ZUP demo runtime session is closed")
1296
+ self.runtime_api.discard_prepared_main_for_capture(prepared)
1297
+
1298
+ def prepare_capture_hypothesis(
1299
+ self, source: str, capture: object
1300
+ ) -> object:
1301
+ with self._operation_lock:
1302
+ if self._closed:
1303
+ raise ProtocolError("ZUP demo runtime session is closed")
1304
+ self._require_capture_fence(capture)
1305
+ return self.runtime_api.prepare_capture_hypothesis(source)
1306
+
1307
+ def execute_prepared_capture_hypothesis(
1308
+ self, prepared: object, capture: object
1309
+ ) -> RuntimeReply:
1310
+ with self._operation_lock:
1311
+ if self._closed:
1312
+ raise ProtocolError("ZUP demo runtime session is closed")
1313
+ self._require_capture_fence(capture)
1314
+ return self.runtime_api.execute_prepared_capture_hypothesis(prepared)
1315
+
1316
+ def prepared_capture_hypothesis_provenance(
1317
+ self,
1318
+ prepared: object,
1319
+ ) -> OperationExecutionProvenance:
1320
+ with self._operation_lock:
1321
+ if self._closed:
1322
+ raise ProtocolError("ZUP demo runtime session is closed")
1323
+ return self.runtime_api.prepared_capture_hypothesis_provenance(
1324
+ prepared
1325
+ )
1326
+
1327
+ def quarantine_capture_inspection(self, capture: object) -> None:
1328
+ """Revoke every inspection route when preparation ownership is uncertain."""
1329
+ with self._operation_lock:
1330
+ if self._closed:
1331
+ raise ProtocolError("ZUP demo runtime session is closed")
1332
+ self._require_capture_fence(capture)
1333
+ # Revoke the public session ticket before touching the lower layer:
1334
+ # even a failing controller cleanup must leave later reads fenced.
1335
+ self._active_capture_ticket = None
1336
+ self.runtime_api.invalidate_capture_inspection()
1337
+
1338
+ def arm_capture_intent(self, intent: object) -> RuntimeCaptureArming:
1339
+ with self._operation_lock:
1340
+ previous_ticket = self._active_capture_ticket
1341
+ previous_points = self._active_capture_points
1342
+ previous_locations = tuple(getattr(self, "_active_capture_locations", ()))
1343
+ previous_file_points = tuple(getattr(self, "_file_capture_points", ()))
1344
+ bindings = self.verify_capture_points(intent.points)
1345
+ locations = [binding.location for binding in bindings]
1346
+ # This public evidence is intentionally planned before live RDBG
1347
+ # state changes and never includes the opaque ticket itself.
1348
+ self.artifacts.append_jsonl(
1349
+ "capture-tickets.jsonl",
1350
+ {
1351
+ "capture_ticket_planned": True,
1352
+ "capture_intent_id": intent.capture_intent_id,
1353
+ "capture_generation": intent.capture_generation,
1354
+ "source_revision": intent.source_revision,
1355
+ "source_sha256": intent.source_sha256,
1356
+ },
1357
+ )
1358
+ try:
1359
+ if previous_ticket is not None:
1360
+ self.runtime_api.configure_continuation_capture_points(tuple(locations))
1361
+ else:
1362
+ self.runtime_api.configure_capture_points(tuple(locations))
1363
+ ticket = self.runtime_api.prepare_capture_ticket()
1364
+ except BaseException:
1365
+ try:
1366
+ if previous_ticket is not None:
1367
+ self.runtime_api.configure_continuation_capture_points(previous_locations)
1368
+ else:
1369
+ self.runtime_api.configure_capture_points(())
1370
+ finally:
1371
+ self._active_capture_points = previous_points
1372
+ self._active_capture_ticket = previous_ticket
1373
+ self._file_capture_points = previous_file_points
1374
+ raise
1375
+ self._active_capture_points = tuple(intent.points)
1376
+ self._active_capture_locations = tuple(locations)
1377
+ self._file_capture_points = ()
1378
+ self._active_capture_ticket = _ActiveCaptureTicket(
1379
+ ticket.ticket_id,
1380
+ intent.capture_intent_id,
1381
+ intent.operation_id,
1382
+ intent.capture_generation,
1383
+ intent.source_revision,
1384
+ intent.source_sha256,
1385
+ ticket.expected_stop_sequence,
1386
+ )
1387
+ return RuntimeCaptureArming(
1388
+ ticket.ticket_id,
1389
+ ticket.expected_operation_id,
1390
+ ticket.expected_stop_sequence,
1391
+ )
1392
+
1393
+ def prepare_capture_successor(
1394
+ self,
1395
+ intent: object | None,
1396
+ *,
1397
+ attempt: object,
1398
+ ) -> _SessionContinuationAdmission:
1399
+ """Transact session ticket/points with API/controller successor state."""
1400
+ with self._operation_lock:
1401
+ if self._closed:
1402
+ raise ProtocolError("ZUP demo runtime session is closed")
1403
+ previous_ticket = self._active_capture_ticket
1404
+ if previous_ticket is None:
1405
+ raise ProtocolError("successor admission requires an active capture")
1406
+ if attempt.capture_generation != previous_ticket.capture_generation:
1407
+ raise ValueError("continuation attempt capture_generation changed")
1408
+ previous_points = self._active_capture_points
1409
+ previous_locations = self._active_capture_locations
1410
+ locations: tuple[object, ...]
1411
+ if intent is None:
1412
+ locations = ()
1413
+ else:
1414
+ if (
1415
+ intent.operation_id != attempt.request_operation_id
1416
+ or intent.source_revision != previous_ticket.source_revision
1417
+ or intent.source_sha256 != previous_ticket.source_sha256
1418
+ or intent.capture_generation <= previous_ticket.capture_generation
1419
+ ):
1420
+ raise ValueError("capture successor evidence changed")
1421
+ resolved_locations: list[object] = []
1422
+ for point in intent.points:
1423
+ location = self._capture_locations.get(
1424
+ (point.name.casefold(), point.line)
1425
+ )
1426
+ if location is None:
1427
+ raise ValueError(
1428
+ "capture intent was not resolved by this session"
1429
+ )
1430
+ resolved_locations.append(location)
1431
+ locations = tuple(resolved_locations)
1432
+ api_admission = self.runtime_api.begin_continuation_admission(
1433
+ ContinuationAttemptSpec(
1434
+ attempt.attempt_id,
1435
+ attempt.capture_generation,
1436
+ attempt.request_operation_id,
1437
+ attempt.dirty_roots,
1438
+ ),
1439
+ locations, # type: ignore[arg-type]
1440
+ )
1441
+ try:
1442
+ ticket = getattr(api_admission, "ticket", None)
1443
+ arming: RuntimeCaptureArming | None = None
1444
+ next_points: tuple[object, ...] = ()
1445
+ next_locations: tuple[object, ...] = ()
1446
+ next_ticket: _ActiveCaptureTicket | None = None
1447
+ if intent is not None:
1448
+ if ticket is None:
1449
+ raise ProtocolError(
1450
+ "successor capture ticket is unavailable"
1451
+ )
1452
+ arming = RuntimeCaptureArming(
1453
+ ticket.ticket_id,
1454
+ ticket.expected_operation_id,
1455
+ ticket.expected_stop_sequence,
1456
+ )
1457
+ next_points = tuple(intent.points)
1458
+ next_locations = locations
1459
+ next_ticket = _ActiveCaptureTicket(
1460
+ ticket.ticket_id,
1461
+ intent.capture_intent_id,
1462
+ intent.operation_id,
1463
+ intent.capture_generation,
1464
+ intent.source_revision,
1465
+ intent.source_sha256,
1466
+ ticket.expected_stop_sequence,
1467
+ )
1468
+ self._active_capture_points = next_points
1469
+ self._active_capture_locations = next_locations
1470
+ self._active_capture_ticket = next_ticket
1471
+ return _SessionContinuationAdmission(
1472
+ self,
1473
+ api_admission,
1474
+ active_ticket=previous_ticket,
1475
+ active_points=previous_points,
1476
+ active_locations=previous_locations,
1477
+ arming=arming,
1478
+ )
1479
+ except BaseException:
1480
+ try:
1481
+ getattr(api_admission, "rollback")()
1482
+ except BaseException:
1483
+ self._active_capture_ticket = None
1484
+ self._active_capture_points = ()
1485
+ self._active_capture_locations = ()
1486
+ raise
1487
+ self._active_capture_ticket = previous_ticket
1488
+ self._active_capture_points = previous_points
1489
+ self._active_capture_locations = previous_locations
1490
+ raise
1491
+
1492
+ def continuation_admission_is_uncertain(self) -> bool:
1493
+ """Classify a failed lower-layer admission without exposing internals."""
1494
+ return self.runtime_api.continuation_admission_is_uncertain()
1495
+
1496
+ def capture_location(
1497
+ self, location: object, *, ticket_id: str | None, intent: object
1498
+ ) -> object | None:
1499
+ with self._operation_lock:
1500
+ active = getattr(self, "_active_capture_ticket", None)
1501
+ if (
1502
+ active is None
1503
+ or ticket_id != active.ticket_id
1504
+ or intent.capture_intent_id != active.capture_intent_id
1505
+ or intent.capture_generation != active.capture_generation
1506
+ or intent.source_revision != active.source_revision
1507
+ or intent.source_sha256 != active.source_sha256
1508
+ ):
1509
+ return None
1510
+ for point in getattr(self, "_active_capture_points", ()):
1511
+ candidate = getattr(self, "_capture_locations", {}).get(
1512
+ (point.name.casefold(), point.line)
1513
+ )
1514
+ if candidate == location:
1515
+ # Arming is bound to the submitted MAIN revision, while a
1516
+ # paused frame is fenced by the exact source identity that
1517
+ # produced the observed stop. Publish that transition at
1518
+ # the same correlation boundary that proves the point.
1519
+ self._active_capture_ticket = replace(
1520
+ active,
1521
+ source_revision=point.source_revision,
1522
+ source_sha256=point.source_sha256,
1523
+ )
1524
+ return point
1525
+ return None
1526
+
1527
+ def disarm_capture_intent(self, *, policy: str) -> None:
1528
+ if policy not in {"terminal_main", "terminal_no_stop"}:
1529
+ raise ValueError("capture disarm policy is unsupported")
1530
+ with self._operation_lock:
1531
+ active = self._active_capture_ticket
1532
+ self.runtime_api.configure_capture_points(())
1533
+ self._file_capture_points = ()
1534
+ self._active_capture_points = ()
1535
+ self._active_capture_ticket = None
1536
+ if active is not None:
1537
+ self._notify_capture_ended(active)
1538
+
1539
+ def resume_capture(
1540
+ self,
1541
+ *,
1542
+ dirty_roots: tuple[str, ...] = (),
1543
+ continuation_attempt_id: str | None = None,
1544
+ ) -> RuntimeReply:
1545
+ with self._operation_lock:
1546
+ active = self._active_capture_ticket
1547
+ try:
1548
+ reply = self.runtime_api.resume_capture(
1549
+ dirty_roots=dirty_roots,
1550
+ continuation_attempt_id=continuation_attempt_id,
1551
+ )
1552
+ except BaseException:
1553
+ # A non-CAPTURED runtime is not inspectable even when transport
1554
+ # outcome is unknown; expire the service fence before it can
1555
+ # issue another metadata request.
1556
+ if active is not None and self.runtime_api.status().state is not OperationState.CAPTURED:
1557
+ self._active_capture_ticket = None
1558
+ self._notify_capture_ended(active)
1559
+ raise
1560
+ if (
1561
+ active is not None
1562
+ and getattr(reply, "capture_ticket", None)
1563
+ == getattr(active, "ticket_id", None)
1564
+ and getattr(reply, "state", None) is OperationState.CAPTURED
1565
+ ):
1566
+ return reply
1567
+ if active is not None:
1568
+ self._active_capture_ticket = None
1569
+ self._notify_capture_ended(active)
1570
+ return reply
1571
+
1572
+ def resume_debug_stop(
1573
+ self,
1574
+ *,
1575
+ timeout_s: float | None = None,
1576
+ ) -> RuntimeReply:
1577
+ with self._operation_lock:
1578
+ active = self._active_capture_ticket
1579
+ reply = self.runtime_api.resume_debug_stop(timeout_s=timeout_s)
1580
+ if active is None or reply.state in {
1581
+ OperationState.CAPTURED,
1582
+ OperationState.CAPTURE_DEBUG_STOPPED,
1583
+ OperationState.DEBUG_STOPPED,
1584
+ }:
1585
+ return reply
1586
+ self._active_capture_ticket = None
1587
+ self._notify_capture_ended(active)
1588
+ return reply
1589
+
1590
+ def rearm_capture_successor(self, locations: tuple[object, ...]) -> None:
1591
+ with self._operation_lock:
1592
+ if self._active_capture_ticket is None:
1593
+ raise ProtocolError("successor rearm requires an active capture")
1594
+ self.runtime_api.configure_continuation_capture_points(locations) # type: ignore[arg-type]
1595
+ self._active_capture_points = ()
1596
+ self._active_capture_locations = ()
1597
+ self._active_capture_ticket = None
1598
+
1599
+ def continuation_attempt_evidence(
1600
+ self, attempt_id: str
1601
+ ) -> ContinuationAttemptEvidence:
1602
+ with self._operation_lock:
1603
+ return self.runtime_api.continuation_attempt_evidence(attempt_id)
1604
+
1605
+ def add_capture_resume_listener(
1606
+ self, listener: Callable[[RuntimeCaptureFence], None]
1607
+ ) -> None:
1608
+ if not callable(listener):
1609
+ raise TypeError("capture resume listener must be callable")
1610
+ if not hasattr(self, "_capture_resume_listeners"):
1611
+ self._capture_resume_listeners = []
1612
+ self._capture_resume_listeners.append(listener)
1613
+
1614
+ def _notify_capture_ended(self, active: _ActiveCaptureTicket) -> None:
1615
+ fence = RuntimeCaptureFence(
1616
+ active.capture_intent_id,
1617
+ active.operation_id,
1618
+ active.source_revision,
1619
+ active.source_sha256,
1620
+ active.capture_generation,
1621
+ active.stop_sequence,
1622
+ )
1623
+ for listener in tuple(getattr(self, "_capture_resume_listeners", ())):
1624
+ listener(fence)
1625
+
1626
+ def _require_capture_fence(self, capture: object) -> None:
1627
+ active = self._active_capture_ticket
1628
+ if active is None or (
1629
+ active.capture_intent_id != capture.capture_intent_id
1630
+ or active.operation_id != capture.operation_id
1631
+ or active.capture_generation != capture.capture_generation
1632
+ or active.source_revision != capture.source_revision
1633
+ or active.source_sha256 != capture.source_sha256
1634
+ or active.stop_sequence != capture.stop_sequence
1635
+ ):
1636
+ raise ProtocolError("capture frame is stale or unavailable")
1637
+
1638
+ def frame_variables(self, capture: object, *, filters: Mapping[str, object], cursor: int, limit: int, timeout_s: float | None = None) -> Mapping[str, object]:
1639
+ with self._operation_lock:
1640
+ self._require_capture_fence(capture)
1641
+ return self.runtime_api.capture_frame_variables(
1642
+ filters=filters, cursor=cursor, limit=limit, timeout_s=timeout_s
1643
+ )
1644
+
1645
+ def capture_stack(self, capture: object, *, cursor: int, limit: int, timeout_s: float | None = None) -> Mapping[str, object]:
1646
+ with self._operation_lock:
1647
+ self._require_capture_fence(capture)
1648
+ return self.runtime_api.capture_stack(
1649
+ cursor=cursor, limit=limit, timeout_s=timeout_s
1650
+ )
1651
+
1652
+ def capture_frame(self, capture: object, *, level: int, cursor: int, limit: int, name: str | None = None, timeout_s: float | None = None) -> Mapping[str, object]:
1653
+ with self._operation_lock:
1654
+ self._require_capture_fence(capture)
1655
+ return self.runtime_api.capture_frame(
1656
+ level=level, cursor=cursor, limit=limit, name=name,
1657
+ timeout_s=timeout_s,
1658
+ )
1659
+
1660
+ def resolve_manager_origin(self, capture: object, origin: object, *, timeout_s: float | None = None) -> Mapping[str, object]:
1661
+ with self._operation_lock:
1662
+ self._require_capture_fence(capture)
1663
+ return self.runtime_api.resolve_capture_manager_origin(
1664
+ origin, timeout_s=timeout_s
1665
+ )
1666
+
1667
+ def temporary_tables(self, capture: object, manager_handle: str, *, names: tuple[str, ...] | None, cursor: int, limit: int, selection: object | None, timeout_s: float | None = None) -> Mapping[str, object]:
1668
+ with self._operation_lock:
1669
+ self._require_capture_fence(capture)
1670
+ return self.runtime_api.capture_temporary_tables(
1671
+ manager_handle, names=names, cursor=cursor, limit=limit,
1672
+ selection=selection, timeout_s=timeout_s,
1673
+ )
1674
+
1675
+ def load_worker_modules(
1676
+ self,
1677
+ units: tuple[WorkerModuleUnit, ...],
1678
+ *,
1679
+ breakpoint_policy: WorkerBreakpointReloadPolicy = (
1680
+ WorkerBreakpointReloadPolicy.STRICT
1681
+ ),
1682
+ profiler: PhaseRecorder | None = None,
1683
+ ) -> WorkerGenerationHandle:
1684
+ with self._operation_lock:
1685
+ def reload_modules() -> WorkerGenerationHandle:
1686
+ if type(units) is not tuple or not units:
1687
+ raise ProtocolError(
1688
+ "Worker module units must be a non-empty tuple"
1689
+ )
1690
+ if any(not isinstance(unit, WorkerModuleUnit) for unit in units):
1691
+ raise ProtocolError(
1692
+ "Worker module catalog binding does not match"
1693
+ )
1694
+ names = tuple(unit.logical_name.casefold() for unit in units)
1695
+ if len(names) != len(set(names)):
1696
+ raise ProtocolError("Worker module names must be unique")
1697
+ catalog = self._require_common_module_catalog()
1698
+ generation = self.runtime_api.load_worker_modules(
1699
+ units,
1700
+ common_modules=catalog,
1701
+ breakpoint_policy=breakpoint_policy,
1702
+ profiler=profiler,
1703
+ )
1704
+ self._active_worker_file_units.clear()
1705
+ return generation
1706
+
1707
+ return (
1708
+ reload_modules()
1709
+ if profiler is None
1710
+ else profiler.measure(
1711
+ "end_to_end",
1712
+ reload_modules,
1713
+ item_count=lambda _result: len(units),
1714
+ )
1715
+ )
1716
+
1717
+ def load_worker_module(self, path: Path | str) -> WorkerGenerationHandle:
1718
+ """Publish a saved Designer or EDT common module by its BSL file path."""
1719
+ with self._operation_lock:
1720
+ name, source = self._require_common_module_catalog().read_worker_module_source(
1721
+ path
1722
+ )
1723
+ key = name.casefold()
1724
+ revision = self._worker_file_revisions.get(key, 0) + 1
1725
+ reference = SourceUnitRef(
1726
+ SourceUnitKind.MODULE,
1727
+ name,
1728
+ revision,
1729
+ source_sha256(source),
1730
+ )
1731
+ unit = WorkerModuleUnit(
1732
+ name,
1733
+ "module",
1734
+ revision,
1735
+ mapped_visible_source(source, reference),
1736
+ )
1737
+ generation = self.load_worker_modules((unit,))
1738
+ self._worker_file_revisions[key] = revision
1739
+ self._active_worker_file_units[key] = reference
1740
+ return generation
1741
+
1742
+ def add_worker_breakpoint(
1743
+ self,
1744
+ path: str,
1745
+ line: int,
1746
+ ) -> WorkerBreakpointStatus:
1747
+ """Set a one-based source line breakpoint in a file-loaded Worker module."""
1748
+ if not isinstance(path, str) or not path:
1749
+ raise ValueError("Worker breakpoint path must be a non-empty string")
1750
+ if type(line) is not int or line < 1:
1751
+ raise ValueError("Worker breakpoint line must be positive")
1752
+ with self._operation_lock:
1753
+ name, source = self._require_common_module_catalog().read_worker_module_source(
1754
+ path
1755
+ )
1756
+ source_unit = self._active_worker_file_units.get(name.casefold())
1757
+ if source_unit is None:
1758
+ raise ProtocolError("Worker module must be loaded before adding a breakpoint")
1759
+ if source_unit.source_sha256 != source_sha256(source):
1760
+ raise ProtocolError("Worker module source changed; reload it before adding a breakpoint")
1761
+ return self.runtime_api.add_worker_breakpoint(
1762
+ source_unit,
1763
+ name.casefold(),
1764
+ line,
1765
+ )
1766
+
1767
+ def remove_worker_breakpoint(self, breakpoint_id: UUID) -> None:
1768
+ with self._operation_lock:
1769
+ self.runtime_api.remove_worker_breakpoint(breakpoint_id)
1770
+
1771
+ def set_worker_breakpoint_enabled(
1772
+ self,
1773
+ breakpoint_id: UUID,
1774
+ enabled: bool,
1775
+ ) -> WorkerBreakpointStatus:
1776
+ with self._operation_lock:
1777
+ return self.runtime_api.set_worker_breakpoint_enabled(
1778
+ breakpoint_id,
1779
+ enabled,
1780
+ )
1781
+
1782
+ def worker_breakpoint_status(
1783
+ self,
1784
+ breakpoint_id: UUID,
1785
+ ) -> WorkerBreakpointStatus:
1786
+ with self._operation_lock:
1787
+ return self.runtime_api.worker_breakpoint_status(breakpoint_id)
1788
+
1789
+ def list_worker_breakpoints(self) -> tuple[WorkerBreakpointStatus, ...]:
1790
+ with self._operation_lock:
1791
+ return self.runtime_api.list_worker_breakpoints()
1792
+
1793
+ def last_worker_breakpoint_reload_report(
1794
+ self,
1795
+ ) -> WorkerBreakpointReloadReport | None:
1796
+ with self._operation_lock:
1797
+ return self.runtime_api.last_worker_breakpoint_reload_report()
1798
+
1799
+ def _require_common_module_catalog(self) -> SessionCommonModuleCatalog:
1800
+ catalog = self._common_module_catalog
1801
+ if catalog is None:
1802
+ raise ProtocolError("Worker common-module source root is not configured")
1803
+ return catalog
1804
+
1805
+ def release_worker_generation(self, handle: WorkerGenerationHandle) -> None:
1806
+ """Release the active API generation; superseded handles are already stale."""
1807
+ with self._operation_lock:
1808
+ self.runtime_api.release_worker_generation(handle)
1809
+ self._active_worker_file_units.clear()
1810
+
1811
+ def configure_capture_points(self, locations: tuple[ModuleLocation, ...]) -> None:
1812
+ """Configure already-resolved debugger locations for an interactive client."""
1813
+ with self._operation_lock:
1814
+ self.runtime_api.configure_capture_points(locations)
1815
+ self._file_capture_points = tuple(locations)
1816
+
1817
+ def add_capture_point(self, path: str, line: int) -> ModuleLocation:
1818
+ """Arm a common-module breakpoint using only a source path and line."""
1819
+ if not isinstance(path, str) or not path:
1820
+ raise ValueError("capture point path must be a non-empty string")
1821
+ if type(line) is not int or line < 1:
1822
+ raise ValueError("capture point line must be positive")
1823
+ with self._operation_lock:
1824
+ catalog = self._common_module_catalog
1825
+ if catalog is None:
1826
+ raise ProtocolError("capture source_root is not configured")
1827
+ name, _source = catalog.read_worker_module_source(path)
1828
+ location = CommonModuleCaptureResolver(
1829
+ "Notebook", catalog.source_root
1830
+ ).resolve_module_line(name, line)
1831
+ locations = (*self._file_capture_points, location)
1832
+ self.runtime_api.configure_capture_points(locations)
1833
+ self._file_capture_points = locations
1834
+ return location
1835
+
1836
+ def clear_capture_points(self) -> None:
1837
+ self.configure_capture_points(())
1838
+
1839
+ def to_df(
1840
+ self,
1841
+ handle: str,
1842
+ *,
1843
+ refs: str | ReferenceMode = ReferenceMode.PRESENTATION,
1844
+ ref_columns: dict[str, str | ReferenceMode] | None = None,
1845
+ uuid_suffix: str = "__uuid",
1846
+ chunk_size: int | None = None,
1847
+ profiler: PhaseRecorder | None = None,
1848
+ ) -> pd.DataFrame:
1849
+ with self._operation_lock:
1850
+ self._require_public_value_handle(handle)
1851
+ return self.runtime_api.materialize_table(
1852
+ handle,
1853
+ refs=refs,
1854
+ ref_columns=ref_columns,
1855
+ uuid_suffix=uuid_suffix,
1856
+ chunk_size=chunk_size or self.config.chunk_size,
1857
+ profiler=profiler,
1858
+ )
1859
+
1860
+ def project_to_df(
1861
+ self,
1862
+ handle: str,
1863
+ selection: dict[str, object],
1864
+ **options: object,
1865
+ ) -> pd.DataFrame:
1866
+ """Materialize a bounded table projection for a frontend value proxy."""
1867
+ with self._operation_lock:
1868
+ self._require_public_value_handle(handle)
1869
+ return self.runtime_api.project_to_df(handle, selection, **options)
1870
+
1871
+ def materialize(
1872
+ self,
1873
+ handle: str,
1874
+ *,
1875
+ refs: str | ReferenceMode = ReferenceMode.PRESENTATION,
1876
+ ref_columns: dict[str, str | ReferenceMode] | None = None,
1877
+ uuid_suffix: str = "__uuid",
1878
+ chunk_size: int | None = None,
1879
+ max_depth: int = 32,
1880
+ max_items: int = 100_000,
1881
+ max_bytes: int = 64 * 1024 * 1024,
1882
+ timeout_s: float | None = None,
1883
+ profiler: PhaseRecorder | None = None,
1884
+ ) -> object:
1885
+ with self._operation_lock:
1886
+ self._require_public_value_handle(handle)
1887
+ options: dict[str, object] = {
1888
+ "refs": refs,
1889
+ "ref_columns": ref_columns,
1890
+ "uuid_suffix": uuid_suffix,
1891
+ "chunk_size": chunk_size or self.config.chunk_size,
1892
+ "max_depth": max_depth,
1893
+ "max_items": max_items,
1894
+ "max_bytes": max_bytes,
1895
+ "profiler": profiler,
1896
+ }
1897
+ if timeout_s is not None:
1898
+ options["timeout_s"] = timeout_s
1899
+ return self.runtime_api.materialize_value(
1900
+ handle,
1901
+ **options,
1902
+ )
1903
+
1904
+ def materialize_value(self, handle: str, **options: object) -> object:
1905
+ """Expose recursive materialization under the frontend proxy contract."""
1906
+ return self.materialize(handle, **options)
1907
+
1908
+ def project_value(
1909
+ self,
1910
+ handle: str,
1911
+ selection: dict[str, object],
1912
+ **options: object,
1913
+ ) -> object:
1914
+ """Materialize a bounded recursive projection for a frontend proxy."""
1915
+ with self._operation_lock:
1916
+ self._require_public_value_handle(handle)
1917
+ return self.runtime_api.project_value(handle, selection, **options)
1918
+
1919
+ def materialization_kind(
1920
+ self, handle: str, *, timeout_s: float | None = None
1921
+ ) -> str:
1922
+ with self._operation_lock:
1923
+ self._require_public_value_handle(handle)
1924
+ if timeout_s is None:
1925
+ return self.runtime_api.materialization_kind(handle)
1926
+ return self.runtime_api.materialization_kind(handle, timeout_s=timeout_s)
1927
+
1928
+ def materialize_value_payload(self, handle: str, **options: object) -> bytes:
1929
+ with self._operation_lock:
1930
+ self._require_public_value_handle(handle)
1931
+ return self.runtime_api.materialize_value_payload(handle, **options)
1932
+
1933
+ def materialize_table_payload(self, handle: str, **options: object) -> bytes:
1934
+ with self._operation_lock:
1935
+ self._require_public_value_handle(handle)
1936
+ return self.runtime_api.materialize_table_payload(handle, **options)
1937
+
1938
+ def project_value_payload(self, handle: str, selection, **options: object): # type: ignore[no-untyped-def]
1939
+ with self._operation_lock:
1940
+ self._require_public_value_handle(handle)
1941
+ return self.runtime_api.project_value_payload(
1942
+ handle,
1943
+ kind=selection.kind.value,
1944
+ offset=selection.offset,
1945
+ limit=selection.limit,
1946
+ columns=selection.columns,
1947
+ names=selection.names,
1948
+ **options,
1949
+ )
1950
+
1951
+ def require_public_value_handle(self, handle: str) -> None:
1952
+ with self._operation_lock:
1953
+ self._require_public_value_handle(handle)
1954
+
1955
+ def require_public_value_handles(self, handles: tuple[str, ...]) -> None:
1956
+ with self._operation_lock:
1957
+ self.runtime_api.require_public_value_handles(handles)
1958
+
1959
+ def _require_public_value_handle(self, handle: object) -> None:
1960
+ if not isinstance(handle, str):
1961
+ return
1962
+ normalized = handle.casefold()
1963
+ if normalized.startswith(
1964
+ "Контекст.RuntimeWorkerActiveGeneration".casefold()
1965
+ ) or normalized.startswith("__OnecPinnedWorkerGeneration".casefold()):
1966
+ raise ProtocolError("Worker generation objects are not public values")
1967
+ self.runtime_api.require_public_value_handle(handle)
1968
+
1969
+ def status(self) -> RuntimeStatus:
1970
+ with self._operation_lock:
1971
+ return self.runtime_api.status()
1972
+
1973
+ def namespace_snapshot(self) -> RuntimeNamespaceSnapshot:
1974
+ with self._operation_lock:
1975
+ if self._closed:
1976
+ raise ProtocolError("ZUP demo runtime session is closed")
1977
+ return self.runtime_api.namespace_snapshot()
1978
+
1979
+ def completion_fields(
1980
+ self, handle: str, *, table_row: bool = False, timeout_s: float = 1.0
1981
+ ) -> tuple[str, ...]:
1982
+ """Return live field names without waiting behind a running cell."""
1983
+ if not self._operation_lock.acquire(blocking=False):
1984
+ raise ProtocolError("Runtime is already executing another request")
1985
+ try:
1986
+ if self._closed:
1987
+ raise ProtocolError("Runtime session is closed")
1988
+ return self.runtime_api.completion_fields(
1989
+ handle, table_row=table_row, timeout_s=timeout_s
1990
+ )
1991
+ finally:
1992
+ self._operation_lock.release()
1993
+
1994
+ def close(self) -> None:
1995
+ self._close(shutdown=False)
1996
+
1997
+ def close_for_kernel_shutdown(self) -> None:
1998
+ """Release the owned server session before local Worker bookkeeping.
1999
+
2000
+ A Jupyter kernel has a short external shutdown deadline. Its Python
2001
+ objects disappear with the process, but the 1C server session does not.
2002
+ """
2003
+ self._close(shutdown=self.config.runtime.is_server_infobase)
2004
+
2005
+ def _close(self, *, shutdown: bool) -> None:
2006
+ with self._close_lock:
2007
+ if self._closed:
2008
+ return
2009
+ self._heartbeat_stop.set()
2010
+ if current_thread() is not self._heartbeat_thread:
2011
+ self._heartbeat_thread.join(timeout=2.0)
2012
+ errors: list[BaseException] = []
2013
+ with self._operation_lock:
2014
+ if not shutdown and not self._runtime_api_closed:
2015
+ close_runtime_api = getattr(self.runtime_api, "close", None)
2016
+ if not callable(close_runtime_api):
2017
+ self._runtime_api_closed = True
2018
+ else:
2019
+ try:
2020
+ close_runtime_api()
2021
+ except BaseException as error:
2022
+ errors.append(error)
2023
+ else:
2024
+ self._runtime_api_closed = True
2025
+ if self.config.runtime.is_server_infobase:
2026
+ if not self._server_session_terminated:
2027
+ try:
2028
+ self._native_client_termination_requested = self._rdbg.terminate_bound_server_session()
2029
+ except RdbgDebugUiNotRegistered:
2030
+ # The UI is gone; fall back to closing our owned client.
2031
+ self._server_session_terminated = True
2032
+ except BaseException as error:
2033
+ errors.append(error)
2034
+ else:
2035
+ self._server_session_terminated = True
2036
+ # Server termination needs the owned client connection alive.
2037
+ # The cluster debugger belongs to the service, not this session.
2038
+ if self._server_session_terminated and not self._processes_closed:
2039
+ try:
2040
+ if self._native_client_termination_requested:
2041
+ self._processes.close(graceful_client_timeout_s=3.0)
2042
+ else:
2043
+ self._processes.close()
2044
+ except BaseException as error:
2045
+ errors.append(error)
2046
+ else:
2047
+ self._processes_closed = True
2048
+ if self._processes_closed and not self._debug_ui_detached:
2049
+ try:
2050
+ self._rdbg.detach()
2051
+ except BaseException as error:
2052
+ errors.append(error)
2053
+ else:
2054
+ self._debug_ui_detached = True
2055
+ if not self._transport_closed and (
2056
+ not self.config.runtime.is_server_infobase
2057
+ or self._debug_ui_detached
2058
+ ):
2059
+ try:
2060
+ self._transport.close()
2061
+ except BaseException as error:
2062
+ errors.append(error)
2063
+ else:
2064
+ self._transport_closed = True
2065
+ if not self.config.runtime.is_server_infobase and not self._processes_closed:
2066
+ try:
2067
+ self._processes.close()
2068
+ except BaseException as error:
2069
+ errors.append(error)
2070
+ else:
2071
+ self._processes_closed = True
2072
+ if (
2073
+ shutdown
2074
+ and self._server_session_terminated
2075
+ and self._processes_closed
2076
+ and self._debug_ui_detached
2077
+ and self._transport_closed
2078
+ ):
2079
+ # Local Worker generations die with this kernel. The
2080
+ # authenticated server target and client have been closed.
2081
+ self._runtime_api_closed = True
2082
+ if (
2083
+ self._runtime_api_closed
2084
+ and self._debug_ui_detached
2085
+ and self._transport_closed
2086
+ and self._processes_closed
2087
+ ):
2088
+ self._closed = True
2089
+ if errors:
2090
+ raise ProtocolError(
2091
+ "ZUP demo cleanup failed: "
2092
+ + ", ".join(type(error).__name__ for error in errors)
2093
+ ) from errors[0]
2094
+
2095
+ def owned_process_snapshot(self) -> tuple[dict[str, object], ...]:
2096
+ """Return exact identities needed for crash-safe owner reconciliation."""
2097
+ identities: list[dict[str, object]] = []
2098
+ for role, owned in (
2099
+ ("dbgs", self._processes.debug_server),
2100
+ ("onec", self._processes.debuggee),
2101
+ ):
2102
+ if owned is None:
2103
+ continue
2104
+ process = psutil.Process(owned.pid)
2105
+ identities.append(
2106
+ {
2107
+ "role": role,
2108
+ "pid": owned.pid,
2109
+ "create_time": process.create_time(),
2110
+ "executable": str(Path(process.exe()).resolve()),
2111
+ }
2112
+ )
2113
+ expected_roles = {"onec"} if self.config.runtime.is_server_infobase else {"dbgs", "onec"}
2114
+ if {item["role"] for item in identities} != expected_roles:
2115
+ raise ProtocolError("ZUP runtime process ownership is incomplete")
2116
+ return tuple(identities)
2117
+
2118
+ def __enter__(self) -> "RuntimeSession":
2119
+ return self
2120
+
2121
+ def __exit__(self, *_args: object) -> None:
2122
+ self.close()
2123
+