dynamic-systems-testing-framework 1.0.1__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 (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1811 @@
1
+ """The persistent-worker lane: the long-lived-process contract and the pool
2
+ orchestration that drives it.
3
+
4
+ :class:`Worker` is one simulator subprocess wrapped as a test-running unit —
5
+ it owns the bounded start, the watchdog, the timeout kill, the cancel and
6
+ the disk rescue as template methods, so a backend supplies hooks rather than
7
+ a copy of the loop. :class:`WorkerRunner` is what one dispatch thread does
8
+ with a worker for each test: the restart and its accounting.
9
+ :class:`PersistentBackendBase` owns ``run_tests``' dispatch of a fixed list:
10
+ parallel startup, the shared queue + sentinel dispatch. The simulation pool
11
+ (:mod:`dstf.backends.pool`) is that dispatch kept open, over the same
12
+ runners.
13
+
14
+ Split out of :mod:`dstf.backends.base` — the batch lane and the persistent
15
+ lane share only :class:`Backend` and the progress printers, both
16
+ imported from there.
17
+ """
18
+
19
+ import contextlib
20
+ import logging
21
+ import queue
22
+ import sys
23
+ import threading
24
+ import time
25
+ from abc import ABC, abstractmethod
26
+ from collections.abc import Callable
27
+ from concurrent.futures import ThreadPoolExecutor, as_completed
28
+ from contextlib import AbstractContextManager
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+ from ..config import Config
33
+ from ..discovery.library_state import LibraryChanges, LibraryState
34
+ from ..discovery.test_registry import TestModel
35
+ from ..storage.batch_manifest import BatchManifest
36
+ from .base import (
37
+ Backend,
38
+ ItemRunner,
39
+ _print_progress,
40
+ _print_run_header,
41
+ no_worker_message,
42
+ )
43
+ from .common.proc_kill import (
44
+ created_at,
45
+ kill_process_tree,
46
+ orphans_in,
47
+ process_gone,
48
+ snapshot_children,
49
+ )
50
+ from .types import TestRunResult, timeout_message
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Abstract persistent-worker interface
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ class LibraryReloadError(RuntimeError):
61
+ """The live tool could not read the library again (:meth:`Worker.reload`);
62
+ ``str()`` is the tool's own message. The test in hand fails with it and
63
+ the worker stays up, holding what it held — so the next test asks
64
+ again."""
65
+
66
+
67
+ class WorkerStartError(RuntimeError):
68
+ """A worker could not start. ``str()`` is the reason in the backend's own
69
+ words — what its ``start()`` raised, or what :meth:`Worker.start_within`
70
+ saw (the process gone, the bound passed) with
71
+ :meth:`Worker.process_detail` beside it."""
72
+
73
+
74
+ #: Why a start was given up when the test it was for was cancelled.
75
+ START_CANCELLED = "its start was given up: the test it was for was cancelled"
76
+
77
+ #: The ``testing.json`` key that bounds a worker's start, as a sentence that
78
+ #: ends every "did not start within" message: the bound is a guess about the
79
+ #: library, and the reader of the message is the one who can correct it.
80
+ STARTUP_TIMEOUT_HINT = (
81
+ '(raise "worker_startup_timeout" in testing.json if this library needs longer)'
82
+ )
83
+
84
+
85
+ def start_timed_out_message(bound: float, detail: str = "") -> str:
86
+ """``did not start within 600 s — <detail> (raise "worker_startup_timeout"
87
+ ...)``: what a start that outlived its bound is failed with. *detail* is
88
+ the backend's :meth:`Worker.process_detail` — whether the tool still
89
+ runs, and the step it is in."""
90
+ return (
91
+ f"did not start within {bound:g} s"
92
+ + (f" — {detail}" if detail else "")
93
+ + f" {STARTUP_TIMEOUT_HINT}"
94
+ )
95
+
96
+
97
+ class Worker(ABC):
98
+ """One long-lived simulator subprocess wrapped as a test-running unit.
99
+
100
+ Each persistent-worker backend (Dymola / OpenModelica / Julia today)
101
+ has its own concrete subclass that owns a real OS process — a Dymola
102
+ instance, an ``OMCSessionZMQ``, a ``julia --project=...`` subprocess.
103
+ The shape declared here is what :class:`PersistentBackendBase` (Phase 4)
104
+ will rely on to orchestrate them generically.
105
+
106
+ Subclasses extend ``__init__`` with backend-specific config (e.g.
107
+ ``DymolaConfig``, an interface class to instantiate). They must always
108
+ call ``super().__init__(worker_id, config)`` so the framework's
109
+ invariants (worker_id stable across restarts; ``self.config`` available
110
+ to dispatch logic; ``self.pids`` and the restart counter present for
111
+ the watchdog and the dispatch loop) hold.
112
+
113
+ Lifecycle contract:
114
+ ``__init__`` → ``start_within()`` → ``run_test_with_timeout()`` × N
115
+ → ``close()``
116
+ ``start_within`` is ``start()`` bounded (``worker_startup_timeout``): the
117
+ pool never calls ``start()`` bare, since a tool that died or stopped
118
+ answering while loading would be waited on for good.
119
+ ``close()`` MUST be idempotent — :class:`PersistentBackendBase`'s teardown
120
+ calls it on every worker including ones that never started successfully.
121
+
122
+ On failure, ``start()`` raises (typically :class:`RuntimeError`); the
123
+ backend records the failure and excludes that worker from the live pool.
124
+ Inside the dispatch loop, ``run_test_with_timeout`` MUST NOT raise on
125
+ test-level failures — return a ``TestRunResult`` with ``success=False``
126
+ instead. Reserve raises for worker-level catastrophes (subprocess died,
127
+ pipe broken) so the caller's restart logic can intervene.
128
+
129
+ ``run_test_with_timeout`` and ``close`` are template methods implemented
130
+ here: the watchdog thread, the timeout kill, the disk rescue and the
131
+ restart-accounting flags are identical across backends and only the
132
+ backend-specific pieces are hooks (``run_test``, ``_evaluate_from_disk``,
133
+ ``_shutdown_interface``, ``_detach_interface``, ``_quiet``). A backend
134
+ whose execution model doesn't fit a blocking call on a thread (Julia
135
+ polls a response queue against a deadline) overrides
136
+ ``run_test_with_timeout`` wholesale but still uses the shared
137
+ ``close`` / ``_kill_tracked_pids``.
138
+ """
139
+
140
+ #: PID name guard for :func:`kill_process_tree` — a tracked PID may have
141
+ #: been recycled by an unrelated process, so nothing is killed unless the
142
+ #: name still matches. ``None`` disables the guard.
143
+ kill_name_hint: str | None = None
144
+ #: Grace given to the graceful shutdown on the *timeout* path, before
145
+ #: PIDs are killed. Backends that need their log files flushed raise it.
146
+ timeout_kill_grace: float = 1.0
147
+ #: Prefix for the watchdog's exec-thread name (``<prefix>-exec-<id>``).
148
+ exec_thread_prefix: str = "worker"
149
+ #: How often ``close()`` re-walks the process tree while the graceful
150
+ #: shutdown runs, looking for children spawned inside that window.
151
+ _ORPHAN_POLL_SECONDS: float = 0.25
152
+ #: Seconds a start gets when ``testing.json`` sets no
153
+ #: ``worker_startup_timeout``. The bound is for a tool that hangs: one
154
+ #: that DIED is failed at once, whatever this says (:meth:`start_within`
155
+ #: watches the process). So it is generous — a large library is minutes
156
+ #: of ``loadFile`` in a live omc, and a bound below that fails a run that
157
+ #: only had to wait. A backend with a reason for another sets its own.
158
+ startup_timeout: float = 600.0
159
+ #: How often :meth:`start_within` looks at a start in flight.
160
+ _START_POLL_SECONDS: float = 0.25
161
+ #: A start given up with NO process on record may be inside its launch
162
+ #: call (OMPython launches omc and waits for its port file in one call,
163
+ #: ~0.1 s): how long the give-up waits for that call to hand the process
164
+ #: over — to :meth:`_track_launch`, which kills it — before it returns.
165
+ #: Without the wait, a caller that exits right after the give-up takes
166
+ #: the start's thread with it and leaves the tool running.
167
+ launch_grace: float = 2.0
168
+ #: How often the watchdog looks at the tracked process while a test runs:
169
+ #: is it still there, and which children does it have. A walk of the
170
+ #: process table per tick and worker (tens of ms on Windows), so not the
171
+ #: 0.25 s of the close path; a death is noticed within one tick, and a
172
+ #: child born after the last one is on no record (see
173
+ #: :meth:`_record_orphans` for what finds it all the same).
174
+ _CHILD_WATCH_SECONDS: float = 0.5
175
+ #: After :meth:`cancel_running` said the tool will come back on its own:
176
+ #: how long it gets before the worker is killed instead.
177
+ cancel_grace: float = 10.0
178
+
179
+ def __init__(self, worker_id: int, config: Config):
180
+ self.worker_id = worker_id
181
+ self.config = config
182
+ #: PIDs attributed to this worker's simulator process.
183
+ self.pids: set[int] = set()
184
+ #: ``(pid, create_time)`` of descendants snapshotted before a
185
+ #: graceful shutdown — the parent may exit and orphan them, putting
186
+ #: them out of reach of a tree walk. The create time is what makes
187
+ #: the recorded PID safe to kill afterwards.
188
+ self._orphan_children: set[tuple[int, float]] = set()
189
+ #: Consecutive restarts charged against this worker by the dispatch
190
+ #: loop's budget; reset when a test succeeds without a kill.
191
+ self._n_restarts: int = 0
192
+ #: The test the watchdog is running, the cancel asked of it ("soft" /
193
+ #: "hard") and the event that wakes the watchdog — one lock, since
194
+ #: :meth:`request_cancel` comes from another thread.
195
+ self._run_lock = threading.Lock()
196
+ self._running_key: str | None = None
197
+ self._cancel: str | None = None
198
+ self._wake: threading.Event | None = None
199
+ #: Set once a start was given up while its thread was still inside
200
+ #: ``start()``: that thread may still write this object's state, so
201
+ #: it is never started again.
202
+ self._start_abandoned = False
203
+ self._start_abort = threading.Event()
204
+ #: The tool call in flight, in the tool's own spelling
205
+ #: (``loadModel(Modelica)``) — what :meth:`process_detail` says the
206
+ #: process died or stalled during, and :meth:`starting` the phase of
207
+ #: a start. A backend sets it as it goes.
208
+ self._step: str = ""
209
+ #: ``time.monotonic()`` of the :meth:`start_within` in flight.
210
+ self._start_began: float | None = None
211
+ #: ``time.monotonic()`` of the :meth:`reload` in flight in the live
212
+ #: tool.
213
+ self._reload_began: float | None = None
214
+
215
+ @abstractmethod
216
+ def start(self) -> None:
217
+ """Bring the worker process up and apply startup (library loads,
218
+ framework settings). Raise on failure.
219
+ """
220
+
221
+ @abstractmethod
222
+ def is_alive(self) -> bool:
223
+ """True iff the worker can accept another test: it holds its tool's
224
+ handle AND that tool still runs (``not self.tool_gone()``). The
225
+ handle alone says only that nobody closed the worker — a tool that
226
+ died while the worker sat idle leaves it in place, and the next
227
+ test would be failed for a death it had no part in. Backends
228
+ without watchable subprocess state can implement this as
229
+ ``self._handle is not None``.
230
+ """
231
+
232
+ def tool_gone(self) -> bool:
233
+ """True when the process this worker tracks is no longer its tool:
234
+ it exited, or its PID is another process's by now
235
+ (:attr:`kill_name_hint`, the guard of the kill). ``False`` for a
236
+ worker that tracks none — closed, or its PID never learnt."""
237
+ return process_gone(self.pids, name_hint=self.kill_name_hint)
238
+
239
+ # -- template-method hooks ----------------------------------------
240
+
241
+ def run_test(
242
+ self,
243
+ test: TestModel,
244
+ test_key: str,
245
+ progress: Optional["object"] = None,
246
+ ) -> "TestRunResult":
247
+ """Run one test to completion in this worker, blocking.
248
+
249
+ The hook the shared ``run_test_with_timeout`` executes on its
250
+ watchdog thread. Backends that override ``run_test_with_timeout``
251
+ wholesale never reach this.
252
+
253
+ *progress* is the optional :class:`ProgressReporter` so the worker
254
+ can emit ``on_phase`` updates (translation vs simulation) for the
255
+ live dashboard. Backends that don't have meaningful sub-phases
256
+ accept and ignore the parameter.
257
+ """
258
+ raise NotImplementedError(
259
+ f"{type(self).__name__} must implement run_test() or override "
260
+ f"run_test_with_timeout()."
261
+ )
262
+
263
+ def reload(self, changes: LibraryChanges | None) -> None:
264
+ """Make this worker hold the library as it is on disk now. Asked
265
+ before a test queued against a state of the source files newer than
266
+ the one this worker read (the tool's queue); *changes* are the
267
+ files that differ from what it read, ``None`` when that is not
268
+ known.
269
+
270
+ The default is right for any tool: restart it — a start reads
271
+ everything. It runs on the dispatch thread, as the restart of a
272
+ worker that died does: bounded by the start's own bound and given
273
+ up by :meth:`abandon_start`, not by the test's watchdog (which
274
+ watches the very process this replaces).
275
+
276
+ A backend whose live tool can read files again OVERRIDES this, and
277
+ an override is a promise to leave the process in place: it is
278
+ called on the test's exec thread, under its watchdog (``before`` of
279
+ :meth:`run_test_with_timeout`) — a reload that hangs is the test's
280
+ timeout, a cancel during it is the test's. It raises
281
+ :class:`LibraryReloadError` with the tool's message when the
282
+ library does not load: the test fails with the message, and since
283
+ nobody then knows which of the changed files the tool took, the
284
+ next reload is asked with ``None`` — everything, whatever has
285
+ changed by then (:meth:`WorkerRunner._reload`). An override
286
+ therefore keeps no account of its own of a reload that failed. It
287
+ sets ``self._step`` as it goes — what :meth:`reloading` shows."""
288
+ self.close()
289
+ self.start_within()
290
+
291
+ @property
292
+ def reloads_in_place(self) -> bool:
293
+ """Whether :meth:`reload` is this backend's own: the live tool
294
+ reads the files again, and the process stays."""
295
+ return type(self).reload is not Worker.reload
296
+
297
+ def reloading(self) -> tuple[str, float] | None:
298
+ """While an in-place :meth:`reload` is in flight: the step it is
299
+ in, in the tool's own spelling, and the seconds since it began —
300
+ as :meth:`starting`. ``None`` otherwise. Read from any thread."""
301
+ began = self._reload_began
302
+ if began is None:
303
+ return None
304
+ return self._step, time.monotonic() - began
305
+
306
+ def _evaluate_from_disk(
307
+ self,
308
+ test: TestModel,
309
+ test_key: str,
310
+ elapsed: float,
311
+ ) -> "TestRunResult | None":
312
+ """Rebuild a result from on-disk artifacts after the in-process flow
313
+ couldn't finish (timeout / worker exception).
314
+
315
+ A simulation often completes and writes its result file just before
316
+ (or during) the kill, so the watchdog trusts disk over the abandoned
317
+ call. ``None`` means the backend has no disk evidence to offer.
318
+ """
319
+ return None
320
+
321
+ def _detach_interface(self) -> object | None:
322
+ """Clear the live-handle attribute and return what it held.
323
+
324
+ Runs synchronously inside ``close()`` — before the graceful attempt
325
+ — so ``is_alive()`` reports dead the instant close() is entered and
326
+ an abandoned exec thread can tell "my interface" from the restarted
327
+ worker's. ``None`` means there was nothing live to shut down.
328
+ """
329
+ return None
330
+
331
+ def _shutdown_interface(self, handle: object, grace: float) -> None:
332
+ """Ask *handle* to shut down gracefully (``close()``, ``quit()``, …).
333
+
334
+ Runs on a daemon thread that ``close()`` abandons after *grace*
335
+ seconds, so it may still be in-flight when the PID kill lands.
336
+ Only reached when ``_detach_interface`` handed back a handle, so a
337
+ backend that overrides one owes the other.
338
+ """
339
+ raise NotImplementedError(
340
+ f"{type(self).__name__} detached a live handle but implements no "
341
+ f"_shutdown_interface()."
342
+ )
343
+
344
+ def _quiet(self) -> "AbstractContextManager[object]":
345
+ """Context manager wrapping the watchdog's teardown calls.
346
+
347
+ Backends whose interface spews connection noise while its process
348
+ is being killed return a suppressor here.
349
+ """
350
+ return contextlib.nullcontext()
351
+
352
+ def process_detail(self) -> str:
353
+ """What the backend itself can say about its process when a start or
354
+ a test did not come back: its exit code or that it still runs, the
355
+ step it was in, its log's tail. Read BEFORE the kill. ``""`` when it
356
+ has nothing to add.
357
+ """
358
+ return ""
359
+
360
+ def cancel_running(self, test_key: str) -> bool:
361
+ """Stop only the simulation of the running test, leaving the tool
362
+ alive: ``True`` when that was asked of it and ``run_test`` will
363
+ return on its own (the watchdog gives it :attr:`cancel_grace`),
364
+ ``False`` when there is no such stop right now — the watchdog then
365
+ kills the worker, and the dispatch loop restarts it uncharged.
366
+
367
+ Called on the watchdog's thread while ``run_test`` is parked on the
368
+ exec thread. Only reached on a backend that declares
369
+ :attr:`Capability.CANCEL`.
370
+ """
371
+ return False
372
+
373
+ # -- template methods ---------------------------------------------
374
+
375
+ def startup_bound(self) -> float:
376
+ """Seconds a start of this worker gets: ``worker_startup_timeout``
377
+ from ``testing.json``, else the class's :attr:`startup_timeout`."""
378
+ configured = getattr(self.config, "worker_startup_timeout", None)
379
+ return float(configured) if configured is not None else self.startup_timeout
380
+
381
+ def start_within(self, timeout: float | None = None) -> None:
382
+ """:meth:`start`, bounded by *timeout* seconds (default
383
+ :meth:`startup_bound`). Raises :class:`WorkerStartError` — and leaves
384
+ the worker closed — when ``start()`` raises, when the tracked process
385
+ is gone while ``start()`` still waits on it (at once, not at the
386
+ bound), or when the bound passes.
387
+
388
+ ``start()`` runs on a daemon thread. One given up on is closed again
389
+ if it ever returns, and this object is not started a second time: a
390
+ thread still inside ``start()`` writes its state. The process it
391
+ launched is killed at the give-up when it is on record by then, by
392
+ :meth:`_track_launch` when it comes on record later — which the
393
+ give-up waits for, :attr:`launch_grace` at most.
394
+ """
395
+ if self._start_abandoned:
396
+ raise WorkerStartError("its previous start never returned")
397
+ bound = self.startup_bound() if timeout is None else float(timeout)
398
+ self._start_began = time.monotonic()
399
+ try:
400
+ self._start_bounded(bound)
401
+ finally:
402
+ self._start_began = None
403
+
404
+ def starting(self) -> tuple[str, float] | None:
405
+ """While a :meth:`start_within` is in flight: the step it is in, in
406
+ the tool's own spelling (``loadFile("...")``; ``""`` before the
407
+ backend named one), and the seconds since it began. ``None`` when no
408
+ start is in flight. Read from any thread — it is what a queue shows
409
+ for a worker that is slow to come up."""
410
+ began = self._start_began
411
+ if began is None:
412
+ return None
413
+ return self._step, time.monotonic() - began
414
+
415
+ def _start_bounded(self, bound: float) -> None:
416
+ done = threading.Event()
417
+ lock = threading.Lock()
418
+ box: dict[str, BaseException] = {}
419
+ abandoned = [False]
420
+
421
+ def _body() -> None:
422
+ try:
423
+ self.start()
424
+ except BaseException as exc:
425
+ box["exc"] = exc
426
+ finally:
427
+ with lock:
428
+ done.set()
429
+ orphaned = abandoned[0]
430
+ if orphaned:
431
+ # Nobody waits for this start any more: whatever it
432
+ # brought up is nobody's.
433
+ self._close_quietly()
434
+
435
+ threading.Thread(
436
+ target=_body,
437
+ daemon=True,
438
+ name=f"{self.exec_thread_prefix}-start-{self.worker_id}",
439
+ ).start()
440
+ deadline = time.monotonic() + bound
441
+ reason: str | None = None
442
+ while reason is None:
443
+ remaining = deadline - time.monotonic()
444
+ if done.wait(max(0.0, min(self._START_POLL_SECONDS, remaining))):
445
+ break
446
+ if self._start_abort.is_set():
447
+ reason = "its start was abandoned"
448
+ elif self._cancel is not None:
449
+ # The test this (re)start is for was cancelled
450
+ # (:meth:`request_cancel`; armed before the restart, so the
451
+ # request is on record): nobody waits for the tool any
452
+ # more, and the start's own bound is minutes.
453
+ reason = START_CANCELLED
454
+ elif process_gone(self.pids):
455
+ reason = self._safe_detail() or "its process exited during start"
456
+ elif remaining <= 0:
457
+ reason = start_timed_out_message(bound, self._safe_detail())
458
+ if reason is not None:
459
+ with lock:
460
+ abandoned[0] = not done.is_set()
461
+ if abandoned[0]:
462
+ self._start_abandoned = True
463
+ launched = bool(self.pids)
464
+ self._close_quietly()
465
+ if not launched:
466
+ done.wait(self.launch_grace)
467
+ raise WorkerStartError(reason)
468
+ # It returned in the same instant: judged by its own outcome.
469
+ exc = box.get("exc")
470
+ if exc is None:
471
+ return
472
+ # review 2026-07-06 (finding 24): a worker that failed mid-start may
473
+ # already have spawned its subprocess; close it so the process (and
474
+ # its license seat) doesn't leak. close() is contractually
475
+ # idempotent + safe on never-started workers.
476
+ self._close_quietly()
477
+ if not isinstance(exc, Exception):
478
+ raise exc
479
+ raise WorkerStartError(str(exc)) from exc
480
+
481
+ def _track_launch(self, pid: int | None) -> None:
482
+ """The process this start launched EXISTS: record it. ``start()``
483
+ calls this the moment it does — before the first wait on the tool,
484
+ not when the launch call hands back a finished session — because a
485
+ start is given up from another thread, and the give-up can only
486
+ kill what is on record.
487
+
488
+ And the other way round: when the start was given up BEFORE the
489
+ process existed, nobody is left to kill it, so this kills it and
490
+ raises :class:`WorkerStartError`. (``_start_abandoned`` is set
491
+ before the give-up reads ``pids``, and ``pids`` is written here
492
+ before the flag is read: one of the two always sees the process.)
493
+ *pid* is ``None`` when the backend could not learn it.
494
+ """
495
+ self.pids = {int(pid)} if pid else set()
496
+ if self._start_abandoned:
497
+ if pid:
498
+ # No name guard: the launch call handed this PID over a
499
+ # moment ago and still holds its handle — it is not reused.
500
+ kill_process_tree(int(pid))
501
+ raise WorkerStartError("its start was given up")
502
+
503
+ def abandon_start(self) -> None:
504
+ """Give up this worker's :meth:`start_within`, from another thread
505
+ (the pool closing): the one in flight, and any later one — it is not
506
+ taken back."""
507
+ self._start_abort.set()
508
+
509
+ def arm(self, test_key: str) -> None:
510
+ """*test_key* is about to run here: :meth:`request_cancel` for it is
511
+ heard from now on, before the watchdog is up. The dispatch step arms
512
+ first, so a cancel that arrives during a restart is not lost."""
513
+ with self._run_lock:
514
+ if self._running_key != test_key:
515
+ self._running_key, self._cancel = test_key, None
516
+ self._wake = threading.Event()
517
+
518
+ def disarm(self) -> None:
519
+ with self._run_lock:
520
+ self._running_key = self._wake = self._cancel = None
521
+
522
+ def _safe_detail(self) -> str:
523
+ try:
524
+ return self.process_detail()
525
+ except Exception:
526
+ logger.debug("process_detail raised", exc_info=True)
527
+ return ""
528
+
529
+ def _close_quietly(self) -> None:
530
+ """:meth:`close` for a worker being given up on: short grace, the
531
+ teardown noise muted, and a raise logged rather than passed on."""
532
+ try:
533
+ with self._quiet():
534
+ self.close(grace=1.0)
535
+ except Exception:
536
+ logger.debug(
537
+ "Worker %s: close() of a given-up worker raised",
538
+ self.worker_id,
539
+ exc_info=True,
540
+ )
541
+
542
+ def request_cancel(self, test_key: str, *, hard: bool = False) -> bool:
543
+ """Ask the watchdog to stop *test_key*, from any thread. ``False``
544
+ when this worker is not running it (nothing is asked). *hard* skips
545
+ :meth:`cancel_running` and kills the worker.
546
+
547
+ The test's result then comes back ``cancelled`` from
548
+ :meth:`run_test_with_timeout` — unless the simulation finished
549
+ anyway, which stays the result it is.
550
+ """
551
+ with self._run_lock:
552
+ if self._running_key != test_key or self._wake is None:
553
+ return False
554
+ if self._cancel != "hard":
555
+ self._cancel = "hard" if hard else "soft"
556
+ self._wake.set()
557
+ return True
558
+
559
+ def close(self, grace: float = 5.0) -> None:
560
+ """Tear the worker down: graceful first, then kill tracked PIDs.
561
+
562
+ The graceful attempt runs on a daemon thread — a wedged interface
563
+ must not block the pool's teardown — and is abandoned after *grace*
564
+ seconds. Idempotent: ``_detach_interface`` hands back the handle
565
+ once, and killing an already-dead PID is a no-op.
566
+ """
567
+ handle = self._detach_interface()
568
+ if handle is not None:
569
+ if type(self)._shutdown_interface is Worker._shutdown_interface:
570
+ # Caught here rather than inside the daemon thread, whose
571
+ # exceptions are logged and dropped: a worker that detaches
572
+ # a handle and never shuts it down would silently skip every
573
+ # graceful close and hard-kill its way through the run.
574
+ raise TypeError(
575
+ f"{type(self).__name__} overrides _detach_interface() but "
576
+ f"not _shutdown_interface(handle, grace) — a detached "
577
+ f"handle has no graceful shutdown."
578
+ )
579
+ # Record descendants while the parent is still up: a graceful
580
+ # shutdown that succeeds orphans them, and an orphan can't be
581
+ # reached from the (now gone) parent afterwards.
582
+ self._snapshot_orphan_children()
583
+ done = threading.Event()
584
+
585
+ def _graceful():
586
+ try:
587
+ self._shutdown_interface(handle, grace)
588
+ except Exception as exc:
589
+ # Named, never swallowed — but the stack stays at DEBUG.
590
+ # A raise here can be routine (a REQ socket still awaiting
591
+ # its reply refuses the send with EFSM, "operation cannot
592
+ # be accomplished in current state"), and a 12-line
593
+ # traceback per timed-out test is noise the run is
594
+ # supposed to be free of.
595
+ logger.warning(
596
+ "Worker %s: graceful shutdown raised (%s: %s); "
597
+ "falling back to the PID kill",
598
+ self.worker_id,
599
+ type(exc).__name__,
600
+ exc,
601
+ )
602
+ logger.debug(
603
+ "Worker %s: graceful-shutdown traceback",
604
+ self.worker_id,
605
+ exc_info=True,
606
+ )
607
+ finally:
608
+ done.set()
609
+
610
+ t = threading.Thread(target=_graceful, daemon=True)
611
+ t.start()
612
+ # Keep sampling the tree while the graceful attempt runs. One
613
+ # snapshot up front is not enough: a timeout that lands during
614
+ # *translation* sees a tree with no dymosim in it, and Dymola
615
+ # then starts dymosim inside this window — from the
616
+ # simulateModel call the abandoned exec thread already issued.
617
+ # That child is only reachable while its parent is alive, which
618
+ # may be for a fraction of this window (measured live,
619
+ # 2026-09-01: two surviving `sh -c ./dymosim` processes).
620
+ # *grace* is the whole budget, walks included: each tick waits
621
+ # only what is left of it, so a slow psutil walk (tens of ms per
622
+ # recursive children() call on Windows, ~20 of them in a 5s
623
+ # Dymola grace) eats into the wait rather than extending the
624
+ # teardown past it.
625
+ deadline = time.monotonic() + grace
626
+ while True:
627
+ remaining = deadline - time.monotonic()
628
+ if remaining <= 0:
629
+ break
630
+ if done.wait(min(self._ORPHAN_POLL_SECONDS, remaining)):
631
+ break
632
+ self._snapshot_orphan_children()
633
+ self._snapshot_orphan_children()
634
+ self._kill_tracked_pids()
635
+
636
+ def _snapshot_orphan_children(self) -> None:
637
+ """Record the tracked processes' current descendants for later."""
638
+ self._orphan_children |= self._live_descendants()
639
+
640
+ def _live_descendants(self) -> set[tuple[int, float]]:
641
+ found: set[tuple[int, float]] = set()
642
+ for pid in list(self.pids):
643
+ found |= snapshot_children(pid, name_hint=self.kill_name_hint)
644
+ return found
645
+
646
+ def _record_orphans(self, test_key: str, tool_born: float | None) -> None:
647
+ """The tool died by itself while it ran *test_key*: add what it left
648
+ behind to the record :meth:`_kill_tracked_pids` kills.
649
+
650
+ The ticks' record misses a child born after the last one — dymosim
651
+ appears and the tool is killed within the same half second. On
652
+ Linux such a child is still found, by the mark of
653
+ :func:`~.common.proc_kill.orphans_in`: parentless, born after this
654
+ worker's tool (*tool_born*), running in this test's own directory.
655
+ A process that fails any part is never touched.
656
+
657
+ What remains out of reach: on Linux, a child born after the last
658
+ tick that has left the test's directory; elsewhere, any child born
659
+ after the last tick (:attr:`_CHILD_WATCH_SECONDS`).
660
+ """
661
+ work_dir = getattr(self.config, "work_dir", None)
662
+ if tool_born is None or work_dir is None:
663
+ return
664
+ self._orphan_children |= orphans_in(str(Path(work_dir) / test_key), tool_born)
665
+
666
+ def _kill_tracked_pids(self) -> None:
667
+ """Force-kill the process trees attributed to this worker.
668
+
669
+ The tree, not the tracked PID alone — the process doing the
670
+ simulating is a child (dymosim under dymola, the compiled model
671
+ binary under omc) and outlives a parent-only kill.
672
+ """
673
+ for pid in list(self.pids):
674
+ kill_process_tree(pid, name_hint=self.kill_name_hint)
675
+ # Orphans were recorded seconds ago, so PID reuse applies to them
676
+ # exactly as it does to a tracked parent — their create times are
677
+ # the guard. The name hint is not: an orphan is the *child*
678
+ # (dymosim, the compiled model binary), which the hint would
679
+ # wrongly exclude.
680
+ for pid, create_time in list(self._orphan_children):
681
+ kill_process_tree(pid, create_time=create_time)
682
+ self.pids = set()
683
+ self._orphan_children = set()
684
+
685
+ def run_test_with_timeout(
686
+ self,
687
+ test: TestModel,
688
+ test_key: str,
689
+ timeout: float,
690
+ progress: Optional["object"] = None,
691
+ before: Callable[[], None] | None = None,
692
+ ) -> "TestRunResult":
693
+ """Run *test* with a watchdog. On timeout, hard-kill the worker
694
+ process and return ``TestRunResult(timed_out=True, success=False)``.
695
+ Test-level failures (sim error, missing variable) return a
696
+ ``success=False`` result, not a raise. Worker-level catastrophes
697
+ (subprocess died, pipe broken) MAY raise so the caller restarts.
698
+
699
+ *before*, when given, runs first — on the thread the test runs on and
700
+ under the same watchdog, so one that hangs is the test's timeout and
701
+ a cancel during it is the test's. A :class:`LibraryReloadError` from
702
+ it is the test's failure and nothing else: the test does not run and
703
+ the worker is as it was. An override of this method owes that call
704
+ too (:meth:`_run_before`).
705
+
706
+ ``run_test`` executes on a daemon thread the watchdog abandons on
707
+ expiry; ``self`` is closed and its PIDs killed, then
708
+ ``_evaluate_from_disk`` gets a chance to rescue a simulation that
709
+ finished just before the kill. Either way the worker is dead, so
710
+ the result carries ``worker_killed=True``: the dispatch loop keys
711
+ its benign-restart accounting on ``timed_out or worker_killed``,
712
+ and we killed a *healthy* worker because the test was slow.
713
+
714
+ Two more things end the wait. A cancel (:meth:`request_cancel`)
715
+ returns a ``cancelled`` result: after :meth:`cancel_running` and the
716
+ tool's own return when the backend has a soft stop (the worker is
717
+ reused), else after the same kill as a timeout's — with no disk
718
+ rescue, since a cancelled run is not a result whatever it left. And
719
+ the tracked process being gone (looked at every
720
+ :attr:`_CHILD_WATCH_SECONDS`) fails the test and closes the worker,
721
+ which kills the children recorded while it was alive: a tool that
722
+ dies by itself orphans its simulation process, and nothing reaches
723
+ an orphan through a parent that no longer exists. A child born
724
+ after the last look is found by its mark where the platform allows
725
+ (:meth:`_record_orphans`, which also says what stays out of reach).
726
+ """
727
+ result_box: list[TestRunResult | None] = [None]
728
+ exc_box: list[BaseException | None] = [None]
729
+ start_ts = time.monotonic()
730
+ done = threading.Event()
731
+ # Set when the watchdog gives the body up: one still inside
732
+ # *before* then must not go on to run the test — by the time it
733
+ # comes back, this worker is closed, or a new tool on its next test.
734
+ left = threading.Event()
735
+ self.arm(test_key)
736
+ with self._run_lock:
737
+ wake = self._wake
738
+ cancelled_early = self._cancel is not None
739
+ assert wake is not None # armed just above
740
+ if cancelled_early:
741
+ # Asked to stop before it started: nothing ran, the worker is
742
+ # as it was.
743
+ self.disarm()
744
+ return TestRunResult(
745
+ model_id=test.model_id,
746
+ test_key=test_key,
747
+ success=False,
748
+ error_message="Cancelled",
749
+ cancelled=True,
750
+ )
751
+
752
+ def _worker_body():
753
+ try:
754
+ if before is not None:
755
+ result_box[0] = self._run_before(before, test, test_key)
756
+ if result_box[0] is not None or left.is_set():
757
+ return
758
+ if self._cancel is not None:
759
+ # Asked to stop while *before* ran, and the tool
760
+ # came through it: the test is not started.
761
+ result_box[0] = TestRunResult(
762
+ model_id=test.model_id,
763
+ test_key=test_key,
764
+ success=False,
765
+ elapsed=time.monotonic() - start_ts,
766
+ error_message="Cancelled",
767
+ cancelled=True,
768
+ )
769
+ return
770
+ result_box[0] = self.run_test(test, test_key, progress=progress)
771
+ except BaseException as e:
772
+ exc_box[0] = e
773
+ finally:
774
+ done.set()
775
+ wake.set()
776
+
777
+ t = threading.Thread(
778
+ target=_worker_body,
779
+ daemon=True,
780
+ name=f"{self.exec_thread_prefix}-exec-{self.worker_id}",
781
+ )
782
+ # Read while the tool is certain to be there: what it leaves behind
783
+ # if it dies is told by having been born after it.
784
+ tool_born = created_at(self.pids)
785
+ t.start()
786
+ try:
787
+ return self._watch_test(
788
+ test,
789
+ test_key,
790
+ timeout,
791
+ start_ts,
792
+ t,
793
+ done,
794
+ wake,
795
+ left,
796
+ result_box,
797
+ exc_box,
798
+ tool_born,
799
+ )
800
+ finally:
801
+ left.set()
802
+ self.disarm()
803
+ # The step was this test's: an idle tool is in no call, and a
804
+ # death is never said to have happened "during" an earlier test.
805
+ self._step = ""
806
+
807
+ def _run_before(
808
+ self, before: Callable[[], None], test: TestModel, test_key: str
809
+ ) -> "TestRunResult | None":
810
+ """Call *before* of :meth:`run_test_with_timeout`: ``None`` when the
811
+ test is to run, the test's failed result when the library did not
812
+ load."""
813
+ began = time.monotonic()
814
+ try:
815
+ before()
816
+ except LibraryReloadError as e:
817
+ return TestRunResult(
818
+ model_id=test.model_id,
819
+ test_key=test_key,
820
+ success=False,
821
+ elapsed=time.monotonic() - began,
822
+ error_message=str(e),
823
+ )
824
+ return None
825
+
826
+ def _watch_test(
827
+ self,
828
+ test: TestModel,
829
+ test_key: str,
830
+ timeout: float,
831
+ start_ts: float,
832
+ t: threading.Thread,
833
+ done: threading.Event,
834
+ wake: threading.Event,
835
+ left: threading.Event,
836
+ result_box: list,
837
+ exc_box: list,
838
+ tool_born: float | None,
839
+ ) -> "TestRunResult":
840
+ """The watchdog's wait, and what each way out of it returns."""
841
+
842
+ def _cancelled(worker_killed: bool) -> TestRunResult:
843
+ return TestRunResult(
844
+ model_id=test.model_id,
845
+ test_key=test_key,
846
+ success=False,
847
+ elapsed=time.monotonic() - start_ts,
848
+ error_message="Cancelled",
849
+ cancelled=True,
850
+ worker_killed=worker_killed,
851
+ )
852
+
853
+ deadline = start_ts + timeout
854
+ gone = False
855
+ while not done.is_set() and self._cancel is None:
856
+ remaining = deadline - time.monotonic()
857
+ if remaining <= 0:
858
+ break
859
+ wake.wait(min(self._CHILD_WATCH_SECONDS, remaining))
860
+ if done.is_set() or self._cancel is not None:
861
+ break
862
+ # Walk first, look second: a tool that dies between the two
863
+ # leaves an empty walk, which must not replace the record of
864
+ # the tick before — that record is all there is of its
865
+ # children.
866
+ children = self._live_descendants()
867
+ if process_gone(self.pids):
868
+ self._orphan_children |= children
869
+ gone = True
870
+ break
871
+ # The tree as it is NOW, not accumulated: a test spawns and
872
+ # reaps compilers for minutes, and only what is alive when the
873
+ # parent dies is worth a kill.
874
+ self._orphan_children = children
875
+
876
+ if self._cancel is not None and not done.is_set():
877
+ if self._cancel == "soft" and self._soft_cancel(test_key):
878
+ grace_end = time.monotonic() + self.cancel_grace
879
+ while self._cancel == "soft" and time.monotonic() < grace_end:
880
+ if done.wait(0.05):
881
+ break
882
+ if not done.is_set():
883
+ # A body still in *before* sees the cancel when it comes
884
+ # back, and starts no test.
885
+ with self._quiet():
886
+ self.close(grace=1.0)
887
+ t.join(0.5)
888
+ return _cancelled(worker_killed=True)
889
+
890
+ if gone and not done.is_set():
891
+ left.set()
892
+ # Read before the kill: the exit code and the log are the
893
+ # backend's, and close() drops the handle they hang off.
894
+ detail = self._safe_detail()
895
+ elapsed = time.monotonic() - start_ts
896
+ self._record_orphans(test_key, tool_born)
897
+ disk_result = self._evaluate_from_disk(test, test_key, elapsed)
898
+ with self._quiet():
899
+ self.close(grace=1.0)
900
+ t.join(0.5)
901
+ if disk_result is not None and disk_result.success:
902
+ return disk_result
903
+ # worker_killed stays False: nobody killed it on purpose, so the
904
+ # restart is charged like any other worker-side malfunction.
905
+ return TestRunResult(
906
+ model_id=test.model_id,
907
+ test_key=test_key,
908
+ success=False,
909
+ elapsed=elapsed,
910
+ error_message="Worker process exited during the test"
911
+ + (f" — {detail}" if detail else ""),
912
+ )
913
+
914
+ if not done.is_set():
915
+ left.set()
916
+ with self._quiet():
917
+ self.close(grace=self.timeout_kill_grace)
918
+ t.join(0.5)
919
+ elapsed = time.monotonic() - start_ts
920
+ disk_result = self._evaluate_from_disk(test, test_key, elapsed)
921
+ if disk_result is not None and disk_result.success:
922
+ disk_result.worker_killed = True
923
+ return disk_result
924
+ return TestRunResult(
925
+ model_id=test.model_id,
926
+ test_key=test_key,
927
+ success=False,
928
+ elapsed=elapsed,
929
+ error_message=timeout_message(f"Timed out after {timeout:.0f}s"),
930
+ timed_out=True,
931
+ worker_killed=True,
932
+ )
933
+
934
+ if exc_box[0] is not None:
935
+ # Worker exceptions DO count against the restart budget — they
936
+ # signal a real worker-side malfunction (RPC dropped, session
937
+ # wedged), not a slow test, so ``worker_killed`` stays False and
938
+ # the dispatch loop ticks the budget on the next restart. The
939
+ # simulation may still have completed before the interface died,
940
+ # so disk gets its chance here too.
941
+ elapsed = time.monotonic() - start_ts
942
+ if process_gone(self.pids):
943
+ self._record_orphans(test_key, tool_born)
944
+ disk_result = self._evaluate_from_disk(test, test_key, elapsed)
945
+ with self._quiet():
946
+ self.close(grace=1.0)
947
+ if disk_result is not None and disk_result.success:
948
+ return disk_result
949
+ if self._cancel is not None:
950
+ return _cancelled(worker_killed=True)
951
+ exc = exc_box[0]
952
+ return TestRunResult(
953
+ model_id=test.model_id,
954
+ test_key=test_key,
955
+ success=False,
956
+ elapsed=elapsed,
957
+ error_message=f"Worker exception: {type(exc).__name__}: {exc}",
958
+ )
959
+
960
+ result: TestRunResult = result_box[0]
961
+ if process_gone(self.pids):
962
+ # The tool died and run_test still returned (an interface that
963
+ # turns a lost connection into a failed result): close here,
964
+ # while the test is known, so the children it left are found
965
+ # (its directory is their mark) and go with it.
966
+ self._record_orphans(test_key, tool_born)
967
+ self._close_quietly()
968
+ if self._cancel is not None and not result.success:
969
+ # What a stopped simulation reports ("stopped early", a failed
970
+ # record) is the cancel's doing, not a failure of the test.
971
+ return _cancelled(worker_killed=not self.is_alive())
972
+ return result
973
+
974
+ def _soft_cancel(self, test_key: str) -> bool:
975
+ try:
976
+ return bool(self.cancel_running(test_key))
977
+ except Exception:
978
+ logger.exception("Worker %s: cancel_running raised", self.worker_id)
979
+ return False
980
+
981
+ def export_fmu(self, test: TestModel, output_dir: "Path") -> "Path":
982
+ """Export *test*'s model as an FMU into *output_dir*.
983
+
984
+ Backends that declare :attr:`Capability.FMU_EXPORT` on their
985
+ :class:`Backend` MUST override this; the default raises
986
+ :class:`NotImplementedError`. Used by 4.B's cross-backend chain
987
+ (e.g. Dymola export → FMPy simulate as a second baseline).
988
+ """
989
+ raise NotImplementedError(
990
+ f"{type(self).__name__} does not support FMU export. "
991
+ f"Backend must declare Capability.FMU_EXPORT and override export_fmu()."
992
+ )
993
+
994
+
995
+ # ---------------------------------------------------------------------------
996
+ # One worker's dispatch state
997
+ # ---------------------------------------------------------------------------
998
+
999
+
1000
+ class WorkerRunner(ItemRunner):
1001
+ """What a dispatch thread does for each test it takes: bring a dead worker
1002
+ back within its restart budget, run the test under the watchdog, keep the
1003
+ restart accounting.
1004
+
1005
+ One per worker, living as long as its dispatch thread — a thread of
1006
+ ``run_tests``' fixed-list dispatch or of the simulation pool, which is
1007
+ what makes a test fail, retry and restart the same way in both.
1008
+ ``retired`` is the sentence the worker was given up with; the test in
1009
+ hand has already been failed with it, and the thread holding this runner
1010
+ takes no more tests.
1011
+ """
1012
+
1013
+ backend: "PersistentBackendBase"
1014
+
1015
+ def __init__(self, backend: "PersistentBackendBase", worker: "Worker"):
1016
+ super().__init__(backend, worker.worker_id)
1017
+ self.worker = worker
1018
+ # Whether the previous test left the worker dead for a benign reason
1019
+ # (we deliberately hard-killed it after a per-test timeout, or it was
1020
+ # disk-rescued post-timeout). Such restarts shouldn't count against
1021
+ # the worker's permanent-broken budget — the worker was healthy, the
1022
+ # test was slow.
1023
+ self._prev_killed_worker = False
1024
+ #: Why the worker's last (re)start failed, in the backend's own
1025
+ #: words; None once one succeeded. What a worker is retired with
1026
+ #: (:meth:`_dead`): "restart exhausted" alone sent the reader to
1027
+ #: stderr for the reason — no licence, a library that no longer
1028
+ #: loads — and the tool has no stderr to read.
1029
+ self._start_failure: str | None = None
1030
+
1031
+ def start(self) -> None:
1032
+ self.worker.start_within()
1033
+ self.backend.note_versions([self.worker])
1034
+
1035
+ def starting(self) -> tuple[str, float] | None:
1036
+ return self.worker.starting()
1037
+
1038
+ def reloading(self) -> tuple[str, float] | None:
1039
+ return self.worker.reloading()
1040
+
1041
+ def expect(self, test_key: str, token: object) -> None:
1042
+ super().expect(test_key, token)
1043
+ self.worker.arm(test_key)
1044
+
1045
+ def _stop_test(self, test_key: str) -> bool:
1046
+ return self.worker.request_cancel(test_key)
1047
+
1048
+ def stop(self, test_key: str | None) -> None:
1049
+ # A (re)start in flight is given up; a test in flight is ended by
1050
+ # the kill, not by the backend's soft stop and its grace.
1051
+ self.worker.abandon_start()
1052
+ if test_key is not None:
1053
+ self.worker.request_cancel(test_key, hard=True)
1054
+
1055
+ def close(self) -> None:
1056
+ self.worker.close()
1057
+
1058
+ def _run(
1059
+ self, test: TestModel, test_key: str, library: LibraryState | None
1060
+ ) -> TestRunResult:
1061
+ """Run *test* on this worker. Never raises: a worker that cannot be
1062
+ restarted, and one that raises, are both a failed result."""
1063
+ # Armed before the restart below, so a cancel that arrives while the
1064
+ # worker is being brought back is heard when the test would start.
1065
+ self.worker.arm(test_key)
1066
+ try:
1067
+ return self._run_armed(test, test_key, library)
1068
+ finally:
1069
+ self.worker.disarm()
1070
+
1071
+ def _newer(self, library: LibraryState | None) -> bool:
1072
+ """Whether *library* is a state the worker has not read yet."""
1073
+ if library is None:
1074
+ return False
1075
+ return self.library is None or library.serial > self.library.serial
1076
+
1077
+ def _reload(self, library: LibraryState) -> Callable[[], None] | None:
1078
+ """The live worker read the source files before *library* was
1079
+ swept: make it read them again. Returns what does so under the
1080
+ test's watchdog, for a worker that reads them in place; ``None``
1081
+ for one that is restarted instead — done here, or failed here and
1082
+ left closed for the restart of a dead worker to pick up."""
1083
+ w = self.worker
1084
+ held = self.library
1085
+ # Every file that differs from what this worker read, across
1086
+ # however many clicks it sat out; all of it when nobody knows what
1087
+ # it holds — it was never told, or its last reload failed.
1088
+ changes = library.changes_since(held) if held is not None else None
1089
+ if not w.reloads_in_place:
1090
+ try:
1091
+ w.reload(changes)
1092
+ except Exception as exc:
1093
+ self._start_failure = str(exc)
1094
+ if not self.backend.quiet:
1095
+ print(
1096
+ f" Worker {w.worker_id}: restart to read the library "
1097
+ f"again FAILED — {exc}",
1098
+ file=sys.stderr,
1099
+ )
1100
+ w._close_quietly()
1101
+ else:
1102
+ self._start_failure = None
1103
+ return None
1104
+
1105
+ def reload() -> None:
1106
+ w._reload_began = time.monotonic()
1107
+ # From here until the tool has read all of it, what it holds
1108
+ # is UNKNOWN: a reload that fails part-way has taken some of
1109
+ # the changed files and not the rest, and no later list of
1110
+ # changes — computed against a state it no longer holds — says
1111
+ # which. So a reload that does not come back whole leaves
1112
+ # ``library`` at None, and the worker's next one is asked for
1113
+ # everything (``changes`` None), whatever changed.
1114
+ self.library = None
1115
+ try:
1116
+ w.reload(changes)
1117
+ finally:
1118
+ w._reload_began = None
1119
+ self.library = library
1120
+
1121
+ return reload
1122
+
1123
+ def _run_armed(
1124
+ self, test: TestModel, test_key: str, library: LibraryState | None
1125
+ ) -> TestRunResult:
1126
+ w, backend = self.worker, self.backend
1127
+ before: Callable[[], None] | None = None
1128
+ if library is not None and self._newer(library) and w.is_alive():
1129
+ before = self._reload(library)
1130
+ if not w.is_alive():
1131
+ if w.tool_gone():
1132
+ # Not closed by the test before: its tool died while it sat
1133
+ # idle. This test had no part in that, so it is not failed
1134
+ # for it — what is left of the tool (its handle, the
1135
+ # children on record) is closed, and the restart below runs
1136
+ # the test on a new one.
1137
+ detail = w._safe_detail()
1138
+ if not backend.quiet:
1139
+ print(
1140
+ f" Worker {w.worker_id}: its process was gone before "
1141
+ f"{test.model_id}" + (f" — {detail}" if detail else ""),
1142
+ file=sys.stderr,
1143
+ )
1144
+ w._close_quietly()
1145
+ if not self._restart(count_against_budget=not self._prev_killed_worker):
1146
+ if w._cancel is not None:
1147
+ return self._cancelled_while_restarting(test, test_key)
1148
+ self.retired = self._dead()
1149
+ return TestRunResult(
1150
+ model_id=test.model_id,
1151
+ test_key=test_key,
1152
+ success=False,
1153
+ elapsed=0.0,
1154
+ error_message=self.retired,
1155
+ )
1156
+ self._prev_killed_worker = False # consumed by restart
1157
+ if before is None and self._newer(library):
1158
+ # A tool started just now — for this test, or back from a death
1159
+ # — read the files after the click that swept *library*.
1160
+ self.library = library
1161
+ if backend.progress is not None:
1162
+ backend.progress.on_start(test_key, worker_id=w.worker_id)
1163
+ timeout = float(
1164
+ test.timeout if test.timeout is not None else backend.config.timeout
1165
+ )
1166
+ try:
1167
+ tr = w.run_test_with_timeout(
1168
+ test,
1169
+ test_key,
1170
+ timeout,
1171
+ progress=backend.progress,
1172
+ before=before,
1173
+ )
1174
+ except Exception as exc:
1175
+ # review 2026-07-06 (finding 21): the Worker ABC contract permits
1176
+ # raises for worker-level catastrophes. A raise must be recorded —
1177
+ # not kill the dispatch thread and silently drop the in-flight
1178
+ # test (plus, at --parallel 1, the whole remaining queue). The
1179
+ # next test's is_alive()/restart logic handles the possibly-dead
1180
+ # worker.
1181
+ logger.exception(
1182
+ "Worker %s raised while running %s",
1183
+ w.worker_id,
1184
+ test.model_id,
1185
+ )
1186
+ self._prev_killed_worker = False
1187
+ return TestRunResult(
1188
+ model_id=test.model_id,
1189
+ test_key=test_key,
1190
+ success=False,
1191
+ elapsed=0.0,
1192
+ error_message=f"worker exception: {exc!r}",
1193
+ )
1194
+ # Reset the restart counter on a successful test — the worker has
1195
+ # demonstrably recovered. Consecutive *real* worker deaths
1196
+ # (translation broke / RPC dead) still accumulate to
1197
+ # ``max_restarts_per_worker`` and trip restart-exhausted. Benign
1198
+ # worker kills (timeout, post-timeout disk-rescue) flow through
1199
+ # _prev_killed_worker so they don't poison the counter.
1200
+ if tr.success and not tr.worker_killed:
1201
+ w._n_restarts = 0
1202
+ self._prev_killed_worker = bool(tr.timed_out or tr.worker_killed)
1203
+ return tr
1204
+
1205
+ def _cancelled_while_restarting(
1206
+ self, test: TestModel, test_key: str
1207
+ ) -> TestRunResult:
1208
+ """The test was cancelled while its worker was being brought back,
1209
+ and the worker is not up: the test ends CANCELLED — at once, where
1210
+ the cancel gave up a start in flight (:data:`START_CANCELLED`), not
1211
+ at that start's bound — and the worker is NOT retired for it.
1212
+
1213
+ A :class:`Worker` whose start was given up is spent: its
1214
+ ``start()`` may still be parked on a thread that writes its state,
1215
+ so it is never started again (:meth:`Worker.start_within`). The
1216
+ runner takes a new one for its slot — with a restart budget of its
1217
+ own — and the old one closes whatever its start still brings up
1218
+ (``_start_bounded``, ``_track_launch``). A start that failed by
1219
+ itself while the cancel was on record leaves the worker as any
1220
+ failed restart does: charged, and tried again by the next test."""
1221
+ w = self.worker
1222
+ if w._start_abandoned:
1223
+ if not self.backend.quiet:
1224
+ print(
1225
+ f" Worker {w.worker_id}: its start was given up for the "
1226
+ f"cancel of {test.model_id}",
1227
+ file=sys.stderr,
1228
+ )
1229
+ self.worker = self.backend.make_worker(w.worker_id)
1230
+ self._start_failure = None
1231
+ return TestRunResult(
1232
+ model_id=test.model_id,
1233
+ test_key=test_key,
1234
+ success=False,
1235
+ elapsed=0.0,
1236
+ error_message="Cancelled",
1237
+ cancelled=True,
1238
+ )
1239
+
1240
+ def _dead(self) -> str:
1241
+ """The sentence a worker that could not be brought back is retired
1242
+ with — the test in hand's failure, and what the pool says of every
1243
+ test still waiting (``No <Backend> worker could start — worker 0:
1244
+ …``): that it is dead, and the reason its last start gave."""
1245
+ said = "Worker dead; restart exhausted"
1246
+ return f"{said} — {self._start_failure}" if self._start_failure else said
1247
+
1248
+ def _restart(self, count_against_budget: bool = True) -> bool:
1249
+ """Bring the worker back up after a death.
1250
+
1251
+ ``count_against_budget`` distinguishes two scenarios:
1252
+
1253
+ * **True** (default): worker died because something is genuinely
1254
+ wrong with it (translation returned False with no diagnostic
1255
+ log; RPC error; start() failure on a previous iteration). The
1256
+ counter increments; after ``max_restarts_per_worker`` such
1257
+ consecutive failures, we declare the worker permanently dead.
1258
+ * **False**: worker died because the dispatch loop hard-killed
1259
+ it after a per-test *timeout*. The worker was healthy when
1260
+ the test started — it actively translated/simulated for the
1261
+ full timeout budget. Restart is just to bring up a fresh
1262
+ process; the count of "consecutive worker deaths" should not
1263
+ tick up. This avoids a known cascade where 3 slow tests in
1264
+ a row exhaust the budget and synthesize ~290 fake failures
1265
+ for the rest of the queue.
1266
+ """
1267
+ w, backend = self.worker, self.backend
1268
+ if w._start_abandoned:
1269
+ # Its last start never returned (a reload's restart, given up at
1270
+ # its bound): this object is not started again, and the reason
1271
+ # is the one that start was given up with — not the "previous
1272
+ # start never returned" a second try would add over it.
1273
+ return False
1274
+ n_restarts = w._n_restarts
1275
+ if count_against_budget and n_restarts >= backend.max_restarts_per_worker:
1276
+ return False
1277
+ if count_against_budget:
1278
+ n_restarts += 1
1279
+ w._n_restarts = n_restarts
1280
+ t0 = time.monotonic()
1281
+ # Exactly one retry, after a short pause. A start() that fails is
1282
+ # usually transient (licence seat not yet released by the process
1283
+ # we just killed); retiring the dispatch thread on the first
1284
+ # failure cost the whole remaining queue at --parallel 1. The
1285
+ # retry is NOT a second charge against the restart budget — it is
1286
+ # the same restart attempt, tried twice.
1287
+ backoff = backend.restart_retry_backoff_seconds
1288
+ last_exc: Exception | None = None
1289
+ for attempt in (1, 2):
1290
+ if attempt == 2:
1291
+ if not backend.quiet:
1292
+ print(
1293
+ f" Worker {w.worker_id}: retrying restart in "
1294
+ f"{backoff:.0f}s (one retry, then the worker is retired)",
1295
+ file=sys.stderr,
1296
+ )
1297
+ time.sleep(backoff)
1298
+ t0 = time.monotonic()
1299
+ try:
1300
+ w.start_within()
1301
+ except Exception as exc:
1302
+ last_exc = exc
1303
+ self._start_failure = str(exc)
1304
+ if not backend.quiet:
1305
+ print(
1306
+ f" Worker {w.worker_id}: restart FAILED — {exc}",
1307
+ file=sys.stderr,
1308
+ )
1309
+ if w._start_abandoned:
1310
+ # Not a blip: its start is still parked somewhere, and
1311
+ # this object cannot be started again.
1312
+ if not backend.quiet:
1313
+ print(
1314
+ f" Worker {w.worker_id}: its start never "
1315
+ f"returned — retiring the worker",
1316
+ file=sys.stderr,
1317
+ )
1318
+ return False
1319
+ if w._cancel is not None:
1320
+ # The test this restart is for was cancelled: no pause
1321
+ # and no second attempt for a test nobody waits for.
1322
+ return False
1323
+ continue
1324
+ if not backend.quiet:
1325
+ if count_against_budget:
1326
+ print(
1327
+ f" Worker {w.worker_id}: restarted "
1328
+ f"({time.monotonic() - t0:.1f}s, attempt {n_restarts}/"
1329
+ f"{backend.max_restarts_per_worker})",
1330
+ file=sys.stderr,
1331
+ )
1332
+ else:
1333
+ print(
1334
+ f" Worker {w.worker_id}: restarted post-timeout "
1335
+ f"({time.monotonic() - t0:.1f}s — not counted)",
1336
+ file=sys.stderr,
1337
+ )
1338
+ self._start_failure = None
1339
+ return True
1340
+ if not backend.quiet:
1341
+ print(
1342
+ f" Worker {w.worker_id}: restart failed twice "
1343
+ f"({type(last_exc).__name__}) — retiring the worker",
1344
+ file=sys.stderr,
1345
+ )
1346
+ return False
1347
+
1348
+
1349
+ # ---------------------------------------------------------------------------
1350
+ # Persistent-worker template
1351
+ # ---------------------------------------------------------------------------
1352
+
1353
+
1354
+ class PersistentBackendBase(Backend):
1355
+ """Template-method class for persistent-worker backends.
1356
+
1357
+ Owns the orchestration shared by Dymola / OpenModelica / Julia persistent
1358
+ backends — so adding a new persistent backend is a one-class job, not a
1359
+ 250-line copy-paste of the dispatch loop.
1360
+
1361
+ Subclass contract:
1362
+
1363
+ - Set ``worker_cls`` to your :class:`Worker` subclass.
1364
+ - Set ``backend_label`` (e.g. ``"Dymola"``) — used in headers and
1365
+ thread names. Read by both the run-header line and the final
1366
+ completion message.
1367
+ - Override :meth:`make_worker` if your worker constructor needs
1368
+ backend-specific args beyond ``(worker_id, config)``. Use
1369
+ :meth:`setup_before_workers` to populate any class state your
1370
+ ``make_worker`` reads.
1371
+ - Override :meth:`setup_before_workers` if your backend has runtime
1372
+ patches that must apply before any worker spawns (Dymola: log
1373
+ filter + parallel-startup lock).
1374
+ - Override :meth:`preflight` (already declared on ``Backend``)
1375
+ to add a cheap dependency probe — see :func:`backends.select_backend`.
1376
+
1377
+ The :meth:`run_tests` template enforces the lifecycle:
1378
+
1379
+ setup_before_workers → _prepare_run (progress + keys + manifest)
1380
+ → make_worker × N → start in parallel
1381
+ → filter live workers (raise if zero) → dispatch with restart
1382
+ → close all workers → finalize progress
1383
+
1384
+ Subclasses generally don't need to touch :meth:`run_tests` itself.
1385
+ """
1386
+
1387
+ #: Worker subclass to instantiate per slot. MUST be set by subclasses.
1388
+ worker_cls: type["Worker"] = None # type: ignore[assignment]
1389
+
1390
+ #: Human-readable backend name for headers and thread names.
1391
+ backend_label: str = ""
1392
+
1393
+ #: Per-worker restart budget. After a timeout or worker-level exception
1394
+ #: the dispatch loop will try to ``start()`` the worker again up to this
1395
+ #: many times; on exhaustion the in-flight test gets a synthetic
1396
+ #: ``Worker dead; restart exhausted`` failure result.
1397
+ max_restarts_per_worker: int = 3
1398
+
1399
+ #: Seconds to wait before the single retry of a failed ``start()``.
1400
+ #: A restart fails for transient reasons far more often than permanent
1401
+ #: ones — a licence server that hasn't yet released the seat of the
1402
+ #: process we just killed, a port still in TIME_WAIT — and at
1403
+ #: ``--parallel 1`` a single such blip used to retire the only dispatch
1404
+ #: thread and fail-mark the entire remaining queue. One retry after a
1405
+ #: short pause covers the transient case without turning a genuinely
1406
+ #: broken backend into a long stall.
1407
+ restart_retry_backoff_seconds: float = 5.0
1408
+
1409
+ #: What a live worker named for run provenance (:meth:`note_versions`).
1410
+ #: On the class so a subclass with its own ``__init__`` chain reads one.
1411
+ _worker_versions: dict = {}
1412
+
1413
+ @classmethod
1414
+ def persistent_backend_cls(cls):
1415
+ return cls # we ARE the persistent variant; stop recursing
1416
+
1417
+ def setup_before_workers(self) -> None:
1418
+ """Hook for runtime patches that must take effect before any worker
1419
+ spawns. Default no-op. Dymola uses this to install its log filter
1420
+ and narrow the parallel-startup lock; OpenModelica uses it to
1421
+ cache its session class; Julia today needs nothing here.
1422
+ """
1423
+ return None
1424
+
1425
+ def make_worker(self, worker_id: int) -> "Worker":
1426
+ """Construct one :class:`Worker`. Default calls
1427
+ ``self.worker_cls(worker_id, self.config)``. Backends with extra
1428
+ construction args (e.g. ``DymolaConfig`` + interface class) override
1429
+ this — typically reading state stashed by
1430
+ :meth:`setup_before_workers`.
1431
+ """
1432
+ if self.worker_cls is None:
1433
+ raise NotImplementedError(
1434
+ f"{type(self).__name__} must set the `worker_cls` class attr "
1435
+ f"or override `make_worker`."
1436
+ )
1437
+ return self.worker_cls(worker_id, self.config)
1438
+
1439
+ def starting_workers_message(self, n_workers: int) -> str:
1440
+ """The "Starting N <backend> worker(s)..." line printed before
1441
+ worker startup. Override to add backend-specific guidance — e.g.
1442
+ Julia's warmup notice that the first run does several minutes of
1443
+ JIT compilation. Default uses :attr:`backend_label`.
1444
+ """
1445
+ return f"Starting {n_workers} {self.backend_label} worker(s)..."
1446
+
1447
+ def prepare_item_runners(self) -> None:
1448
+ self.setup_before_workers()
1449
+
1450
+ def item_runner(self, worker_id: int) -> WorkerRunner:
1451
+ return WorkerRunner(self, self.make_worker(worker_id))
1452
+
1453
+ def note_versions(self, live_workers: list["Worker"]) -> dict:
1454
+ """Ask a live, idle worker for the tool and library versions and
1455
+ keep what it names for :meth:`run_versions`; returns what was new.
1456
+ Best-effort — a failed probe leaves the configured backend label as
1457
+ the shown version and omits the library line."""
1458
+ updates: dict = {}
1459
+ version = self._safe_probe_worker_version(live_workers)
1460
+ if version:
1461
+ updates["tool_version"] = version
1462
+ libs = self._safe_probe_library_versions(live_workers)
1463
+ if libs:
1464
+ updates["library_versions"] = libs
1465
+ self._worker_versions = {**self._worker_versions, **updates}
1466
+ return updates
1467
+
1468
+ def run_versions(self) -> dict:
1469
+ return {**super().run_versions(), **self._worker_versions}
1470
+
1471
+ def _probe_worker_version(self, live_workers: list["Worker"]) -> str | None:
1472
+ """Best-effort actual tool version, asked of a live worker.
1473
+
1474
+ The persistent template calls this once workers are up (before
1475
+ dispatch) to stamp run provenance. Default returns ``None``; backends
1476
+ whose session can name its version (Dymola's ``DymolaVersion()``,
1477
+ omc's ``getVersion()``) override this. MUST NOT raise — the template
1478
+ wraps it in :meth:`_safe_probe_worker_version`.
1479
+ """
1480
+ return None
1481
+
1482
+ def _safe_probe_worker_version(self, live_workers: list["Worker"]) -> str | None:
1483
+ """Guarded :meth:`_probe_worker_version` — a probe failure degrades to
1484
+ ``None`` and never disrupts the run."""
1485
+ if not live_workers:
1486
+ return None
1487
+ try:
1488
+ return self._probe_worker_version(live_workers)
1489
+ except Exception:
1490
+ logger.debug("worker version probe failed", exc_info=True)
1491
+ return None
1492
+
1493
+ def _probe_library_versions(self, live_workers: list["Worker"]) -> dict | None:
1494
+ """Best-effort loaded-library versions (e.g. ``{"Modelica": "4.1.0"}``)
1495
+ asked of a live worker. Default ``None``; Modelica backends override
1496
+ (Dymola reads the bundled MSL under ``$DYMOLA``; omc uses
1497
+ ``getVersion(Modelica)``). The MSL version is the second drift suspect
1498
+ after the tool version itself. MUST NOT raise — wrapped by
1499
+ :meth:`_safe_probe_library_versions`."""
1500
+ return None
1501
+
1502
+ def _safe_probe_library_versions(self, live_workers: list["Worker"]) -> dict | None:
1503
+ """Guarded :meth:`_probe_library_versions`."""
1504
+ if not live_workers:
1505
+ return None
1506
+ try:
1507
+ return self._probe_library_versions(live_workers)
1508
+ except Exception:
1509
+ logger.debug("worker library-version probe failed", exc_info=True)
1510
+ return None
1511
+
1512
+ # ------------------------------------------------------------------
1513
+ # The template
1514
+ # ------------------------------------------------------------------
1515
+
1516
+ def run_tests(self, tests: list[TestModel]) -> list[BatchManifest]:
1517
+ if not tests:
1518
+ return []
1519
+
1520
+ # First, so a failing backend precondition aborts the run before
1521
+ # status.json or the manifest are written.
1522
+ self.setup_before_workers()
1523
+
1524
+ # Persistent backends usually can't name their version until a worker
1525
+ # is live, so tool_version starts None and is patched in after startup
1526
+ # via _probe_worker_version (see below).
1527
+ setup = self._prepare_run(tests)
1528
+ total = setup.total
1529
+ manifest = setup.manifest
1530
+ test_items = setup.keyed_tests
1531
+
1532
+ n_workers = max(1, self.config.parallel)
1533
+ _print_run_header(
1534
+ total,
1535
+ f"via persistent {self.backend_label} workers",
1536
+ n_workers,
1537
+ self.config.timeout,
1538
+ self.config.work_dir,
1539
+ timeout_per_test=True,
1540
+ metadata=setup.run_metadata,
1541
+ quiet=self.quiet,
1542
+ )
1543
+
1544
+ workers = [self.make_worker(i) for i in range(n_workers)]
1545
+
1546
+ start_all = time.monotonic()
1547
+ if not self.quiet:
1548
+ print(self.starting_workers_message(n_workers), file=sys.stderr)
1549
+
1550
+ live_workers, start_failures = self._start_workers_parallel(workers)
1551
+ if not live_workers:
1552
+ # review 2026-07-06 (finding 24): close every worker before the
1553
+ # raise — start()-failed workers already close themselves in
1554
+ # start_within, but close() is idempotent and this guarantees
1555
+ # no orphaned simulator process (license seat) survives the abort.
1556
+ for w in workers:
1557
+ try:
1558
+ w.close()
1559
+ except Exception:
1560
+ logger.debug(
1561
+ "Worker %s: close() during startup-failure cleanup raised",
1562
+ w.worker_id,
1563
+ exc_info=True,
1564
+ )
1565
+ # Every test fails with the backend's own words: the run is
1566
+ # over, and a test left "queued" with the reason printed above
1567
+ # — or not printed at all, under ``quiet`` — says nothing.
1568
+ message = no_worker_message(self.backend_label, start_failures)
1569
+ for test, test_key in test_items:
1570
+ manifest.record_result(
1571
+ TestRunResult(
1572
+ model_id=test.model_id,
1573
+ test_key=test_key,
1574
+ success=False,
1575
+ elapsed=0.0,
1576
+ error_message=message,
1577
+ )
1578
+ )
1579
+ setup.progress.on_finish(
1580
+ test_key, success=False, elapsed=0.0, detail=message
1581
+ )
1582
+ setup.progress.finalize()
1583
+ raise RuntimeError(f"{message}. Try re-running with --batch.")
1584
+ if not self.quiet:
1585
+ print(
1586
+ f" {len(live_workers)}/{n_workers} workers ready in "
1587
+ f"{time.monotonic() - start_all:.1f}s",
1588
+ file=sys.stderr,
1589
+ )
1590
+
1591
+ # Now that a worker is live, ask it for the real tool version and the
1592
+ # loaded library versions (e.g. MSL) and stamp them into the run
1593
+ # provenance.
1594
+ updates = self.note_versions(live_workers)
1595
+ if updates:
1596
+ setup.progress.update_metadata(**updates)
1597
+
1598
+ results = self._dispatch_with_restart(live_workers, test_items, total, manifest)
1599
+
1600
+ for w in workers:
1601
+ w.close()
1602
+
1603
+ wall = time.monotonic() - start_all
1604
+ self._print_run_summary(results, total, wall)
1605
+
1606
+ manifest.results = results
1607
+ setup.progress.finalize()
1608
+ return [manifest]
1609
+
1610
+ # ------------------------------------------------------------------
1611
+ # Internal building blocks
1612
+ # ------------------------------------------------------------------
1613
+
1614
+ def _start_workers_parallel(
1615
+ self, workers: list["Worker"]
1616
+ ) -> tuple[list["Worker"], dict[int, str]]:
1617
+ """Spawn every worker concurrently, each start bounded
1618
+ (:meth:`Worker.start_within`). Returns the subset that started and,
1619
+ by worker id, why each of the others did not; failures are printed
1620
+ per worker. Caller decides what to do on zero live workers (the
1621
+ template fails every test with the reasons and raises).
1622
+ """
1623
+ failures: dict[int, str] = {}
1624
+ failures_lock = threading.Lock()
1625
+
1626
+ def _start_one(w: "Worker"):
1627
+ t0 = time.monotonic()
1628
+ try:
1629
+ w.start_within()
1630
+ dt = time.monotonic() - t0
1631
+ if not self.quiet:
1632
+ print(f" Worker {w.worker_id}: ready ({dt:.1f}s)", file=sys.stderr)
1633
+ except Exception as exc:
1634
+ with failures_lock:
1635
+ failures[w.worker_id] = str(exc)
1636
+ if not self.quiet:
1637
+ print(
1638
+ f" Worker {w.worker_id}: start FAILED — {exc}",
1639
+ file=sys.stderr,
1640
+ )
1641
+
1642
+ with ThreadPoolExecutor(max_workers=len(workers)) as pool:
1643
+ futures = [pool.submit(_start_one, w) for w in workers]
1644
+ for f in as_completed(futures):
1645
+ f.result() # propagate unexpected errors; start errors already printed
1646
+
1647
+ return [w for w in workers if w.worker_id not in failures], failures
1648
+
1649
+ def _dispatch_with_restart(
1650
+ self,
1651
+ live_workers: list["Worker"],
1652
+ test_items: list,
1653
+ total: int,
1654
+ manifest: BatchManifest,
1655
+ ) -> list[TestRunResult]:
1656
+ """Shared queue + sentinel + per-worker restart dispatch.
1657
+
1658
+ Each test goes once into ``work_queue``; each live worker gets one
1659
+ sentinel ``None`` to signal "drain done." Workers that die mid-run
1660
+ get up to :attr:`max_restarts_per_worker` restart attempts; on
1661
+ exhaustion the in-flight test gets a synthetic failure result.
1662
+
1663
+ *manifest* receives each verdict as it lands — this loop is the one
1664
+ place every persistent result (real, restart-exhausted, drained)
1665
+ passes through, and a Ctrl-C here would otherwise leave the run's
1666
+ outcomes unrecorded.
1667
+ """
1668
+ work_queue: queue.Queue[tuple | None] = queue.Queue()
1669
+ for item in test_items:
1670
+ work_queue.put(item)
1671
+ for _ in live_workers:
1672
+ work_queue.put(None)
1673
+
1674
+ results: list[TestRunResult] = []
1675
+ results_lock = threading.Lock()
1676
+ completed = [0]
1677
+
1678
+ def _record(test, test_key, tr: TestRunResult) -> None:
1679
+ with results_lock:
1680
+ results.append(tr)
1681
+ completed[0] += 1
1682
+ idx = completed[0]
1683
+ # Persist before anything else can fail: this is the abort-proof
1684
+ # half of the run-verdict record (BatchManifest.record_result).
1685
+ manifest.record_result(tr)
1686
+ label = f"{test_key} {test.model_id.rsplit('.', 1)[-1]}"
1687
+ status = "ok" if tr.success else ("TIMEOUT" if tr.timed_out else "FAIL")
1688
+ parts = []
1689
+ if getattr(tr, "translation_wall", None) is not None:
1690
+ parts.append(f"xlate {tr.translation_wall:.1f}s")
1691
+ if getattr(tr, "sim_wall", None) is not None:
1692
+ parts.append(f"sim {tr.sim_wall:.1f}s")
1693
+ if tr.success and parts:
1694
+ detail_str: str | None = ", ".join(parts)
1695
+ elif not tr.success:
1696
+ # A timeout's message carries the per-test-override hint
1697
+ # (types.TIMEOUT_OVERRIDE_HINT) and truncating at 80 would cut
1698
+ # exactly the part the user needs, so timeouts print in full.
1699
+ detail_str = (tr.error_message or "")[: None if tr.timed_out else 80]
1700
+ else:
1701
+ detail_str = None
1702
+ _print_progress(
1703
+ idx,
1704
+ total,
1705
+ label,
1706
+ status,
1707
+ elapsed=tr.elapsed,
1708
+ detail=detail_str,
1709
+ quiet=self.quiet,
1710
+ )
1711
+ if self.progress is not None:
1712
+ self.progress.on_finish(
1713
+ test_key,
1714
+ success=tr.success,
1715
+ elapsed=tr.elapsed,
1716
+ # Same timeout-conditional lift as the progress line
1717
+ # above: the Julia timeout message runs past 120 chars and
1718
+ # the clip landed mid-hint, so the dashboard showed a
1719
+ # truncated "simulation.tim…".
1720
+ detail=None
1721
+ if tr.success
1722
+ else (tr.error_message or "")[: None if tr.timed_out else 120],
1723
+ timed_out=tr.timed_out,
1724
+ )
1725
+
1726
+ # review 2026-07-06 (finding 20): dispatch-thread liveness registry.
1727
+ # A worker whose restart failed permanently must NOT stay in the
1728
+ # dispatch loop — it would tight-loop on the shared queue, instantly
1729
+ # fail-marking tests that healthy workers could have run. Its thread
1730
+ # exits instead — unless it is the LAST live thread, in which case it
1731
+ # drains the queue and fail-marks the remainder so every test gets a
1732
+ # recorded result and nothing hangs. (There is no work_queue.join()
1733
+ # anywhere — the thread joins below are the only sync point — so an
1734
+ # early thread exit cannot deadlock; leftover sentinels are inert.)
1735
+ live_threads = [len(live_workers)]
1736
+ live_threads_lock = threading.Lock()
1737
+
1738
+ def _fail_dead(test, test_key: str, message: str) -> None:
1739
+ _record(
1740
+ test,
1741
+ test_key,
1742
+ TestRunResult(
1743
+ model_id=test.model_id,
1744
+ test_key=test_key,
1745
+ success=False,
1746
+ elapsed=0.0,
1747
+ error_message=message,
1748
+ ),
1749
+ )
1750
+
1751
+ def _drain_remaining(reason: str) -> None:
1752
+ while True:
1753
+ try:
1754
+ item = work_queue.get_nowait()
1755
+ except queue.Empty:
1756
+ return
1757
+ if item is not None:
1758
+ test, test_key = item
1759
+ _fail_dead(test, test_key, reason)
1760
+ work_queue.task_done()
1761
+
1762
+ def _worker_loop(runner: WorkerRunner):
1763
+ try:
1764
+ while True:
1765
+ item = work_queue.get()
1766
+ if item is None:
1767
+ work_queue.task_done()
1768
+ return
1769
+ test, test_key = item
1770
+ try:
1771
+ _record(test, test_key, runner.run(test, test_key))
1772
+ finally:
1773
+ work_queue.task_done()
1774
+ if runner.retired:
1775
+ # review 2026-07-06 (finding 20): the item this thread
1776
+ # held is fail-marked (by the runner); exit so the
1777
+ # remaining tests go to live workers.
1778
+ return
1779
+ finally:
1780
+ # review 2026-07-06 (finding 20): last thread out drains the
1781
+ # queue so no test is left unrecorded and no consumer hangs.
1782
+ with live_threads_lock:
1783
+ live_threads[0] -= 1
1784
+ is_last = live_threads[0] == 0
1785
+ if is_last:
1786
+ _drain_remaining(
1787
+ "Worker dead; restart exhausted (no live workers remaining)"
1788
+ )
1789
+
1790
+ thread_prefix = self.backend_label.lower().replace(" ", "-") or "pworker"
1791
+ threads = [
1792
+ threading.Thread(
1793
+ target=_worker_loop,
1794
+ args=(WorkerRunner(self, w),),
1795
+ name=f"{thread_prefix}-pworker-{w.worker_id}",
1796
+ )
1797
+ for w in live_workers
1798
+ ]
1799
+ for t in threads:
1800
+ t.start()
1801
+ for t in threads:
1802
+ t.join()
1803
+ return results
1804
+
1805
+ @property
1806
+ def run_summary_phrase(self) -> str:
1807
+ return f"Persistent {self.backend_label} run complete"
1808
+
1809
+ @property
1810
+ def label(self) -> str:
1811
+ return self.backend_label or super().label