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.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- 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
|