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,541 @@
|
|
|
1
|
+
"""Persistent-worker Julia backend (D78).
|
|
2
|
+
|
|
3
|
+
Each worker is a long-lived ``julia --project=<dir> run_persistent.jl``
|
|
4
|
+
subprocess that reads JSON-per-line test requests from stdin and writes
|
|
5
|
+
JSON responses to stdout. Package load (``using ModelingToolkit``,
|
|
6
|
+
``OrdinaryDiffEq``, ``JSON3``) happens **once per worker** at startup.
|
|
7
|
+
Per-test cost becomes just ``include`` + ``structural_simplify`` + ``solve``.
|
|
8
|
+
|
|
9
|
+
Why this matters — the batch path (one subprocess per test) pays:
|
|
10
|
+
* Julia startup + `using` cost: 20-40s each time (cached .ji reads).
|
|
11
|
+
* Per-model JIT codegen: 10-60s each time.
|
|
12
|
+
|
|
13
|
+
Persistent workers reclaim both. Matches the Dymola / OpenModelica
|
|
14
|
+
persistent-worker pattern (shared queue + dispatch-thread-per-worker
|
|
15
|
+
+ watchdog + up-to-3 worker restarts on catastrophic failure).
|
|
16
|
+
|
|
17
|
+
A warmup phase runs before any test wall-time starts: the first worker
|
|
18
|
+
is kept waiting until its ``ready`` event fires on stdout, signalling
|
|
19
|
+
that ``using`` has completed. Only then do we treat the next line as
|
|
20
|
+
a test response. This way the per-test timing reported in the dashboard
|
|
21
|
+
is just compile/solve of the user model, not the one-time package load
|
|
22
|
+
that would have happened regardless of the test set.
|
|
23
|
+
|
|
24
|
+
This is the default backend; pass ``--batch`` to opt out. There is no
|
|
25
|
+
automatic fallback: if Julia's dependencies aren't ready (no Manifest.toml,
|
|
26
|
+
failed `using`), ``preflight`` raises RuntimeError and the CLI aborts with
|
|
27
|
+
a message naming both fixes — same pattern as Dymola and OpenModelica (see
|
|
28
|
+
``backends.select_backend``).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
import queue
|
|
36
|
+
import subprocess
|
|
37
|
+
import threading
|
|
38
|
+
import time
|
|
39
|
+
from collections.abc import Callable
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from ...config import Config
|
|
43
|
+
from ...discovery.test_registry import TestModel
|
|
44
|
+
from ..common.json_driver import resolve_source_file
|
|
45
|
+
from ..persistent import STARTUP_TIMEOUT_HINT, PersistentBackendBase, Worker
|
|
46
|
+
from ..types import Capability, TestRunResult, timeout_message
|
|
47
|
+
from .backend import JuliaBackend, JuliaConfig
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
_PERSISTENT_DRIVER_PATH = Path(__file__).resolve().parent / "run_persistent.jl"
|
|
52
|
+
|
|
53
|
+
# Max seconds to wait for a hard-killed worker to actually exit before we
|
|
54
|
+
# stop caring — the handle is dropped either way.
|
|
55
|
+
_KILL_EXIT_TIMEOUT = 5.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _close_pipes(proc: subprocess.Popen) -> None:
|
|
59
|
+
"""Close a finished worker's pipes. Best-effort: the reader threads may
|
|
60
|
+
still be unwinding on the other end."""
|
|
61
|
+
for stream in (proc.stdin, proc.stdout, proc.stderr):
|
|
62
|
+
if stream:
|
|
63
|
+
try:
|
|
64
|
+
stream.close()
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class JuliaWorker(Worker):
|
|
70
|
+
"""One long-lived Julia subprocess.
|
|
71
|
+
|
|
72
|
+
Owns a subprocess + a pair of reader threads (stdout / stderr) that
|
|
73
|
+
push lines onto internal Queues. ``run_test_with_timeout`` writes a
|
|
74
|
+
request, waits for the response with a timeout, and hard-kills the
|
|
75
|
+
worker if it hangs.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
kill_name_hint = "julia"
|
|
79
|
+
|
|
80
|
+
def __init__(self, worker_id: int, config: Config, julia_cfg: JuliaConfig):
|
|
81
|
+
super().__init__(worker_id, config)
|
|
82
|
+
self.julia_cfg = julia_cfg
|
|
83
|
+
self.proc: subprocess.Popen | None = None
|
|
84
|
+
self._stdout_q: queue.Queue[str] = queue.Queue()
|
|
85
|
+
self._stderr_buf: list[str] = []
|
|
86
|
+
self._stdout_thread: threading.Thread | None = None
|
|
87
|
+
self._stderr_thread: threading.Thread | None = None
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def start(self) -> None:
|
|
92
|
+
"""Spawn the subprocess, read the initial 'ready' pulse."""
|
|
93
|
+
cmd = [
|
|
94
|
+
str(self.julia_cfg.julia_binary),
|
|
95
|
+
f"--project={self.julia_cfg.project_dir}",
|
|
96
|
+
"--startup-file=no",
|
|
97
|
+
"--color=no",
|
|
98
|
+
str(_PERSISTENT_DRIVER_PATH),
|
|
99
|
+
]
|
|
100
|
+
logger.debug("Worker %d cmd: %s", self.worker_id, " ".join(cmd))
|
|
101
|
+
# Fresh queue per process. start() also runs on restart, and the killed
|
|
102
|
+
# process's reader thread may have left lines behind — including its
|
|
103
|
+
# 'ready' pulse, which would satisfy _wait_for_ready before the new
|
|
104
|
+
# process has even loaded. The old reader keeps its own reference to
|
|
105
|
+
# the orphaned queue, so it can't contaminate this one either.
|
|
106
|
+
self._stdout_q = queue.Queue()
|
|
107
|
+
self.proc = subprocess.Popen(
|
|
108
|
+
cmd,
|
|
109
|
+
stdin=subprocess.PIPE,
|
|
110
|
+
stdout=subprocess.PIPE,
|
|
111
|
+
stderr=subprocess.PIPE,
|
|
112
|
+
text=True,
|
|
113
|
+
# review 2026-07-06 (finding 75): pin utf-8 + replace — on cp1252
|
|
114
|
+
# Windows a UTF-8 Julia backtrace killed the reader thread and
|
|
115
|
+
# every subsequent test on this worker then timed out.
|
|
116
|
+
encoding="utf-8",
|
|
117
|
+
errors="replace",
|
|
118
|
+
bufsize=1, # line-buffered
|
|
119
|
+
cwd=str(self.julia_cfg.project_dir),
|
|
120
|
+
)
|
|
121
|
+
self._track_launch(self.proc.pid)
|
|
122
|
+
# MTK + OrdinaryDiffEq: ~15-40 s from the precompiled cache, minutes
|
|
123
|
+
# on a first run.
|
|
124
|
+
self._step = "its package load"
|
|
125
|
+
|
|
126
|
+
self._stdout_thread = threading.Thread(
|
|
127
|
+
target=self._reader_loop,
|
|
128
|
+
args=(self.proc.stdout, self._stdout_q),
|
|
129
|
+
daemon=True,
|
|
130
|
+
)
|
|
131
|
+
self._stdout_thread.start()
|
|
132
|
+
self._stderr_thread = threading.Thread(
|
|
133
|
+
target=self._stderr_drain,
|
|
134
|
+
args=(self.proc.stderr,),
|
|
135
|
+
daemon=True,
|
|
136
|
+
)
|
|
137
|
+
self._stderr_thread.start()
|
|
138
|
+
|
|
139
|
+
# Wait for the 'ready' event. This is the worker's warmup —
|
|
140
|
+
# `using` is finishing. Dispatching tests before this point would
|
|
141
|
+
# queue them behind the package load and bill it to the first test.
|
|
142
|
+
bound = self.startup_bound()
|
|
143
|
+
ready = self._wait_for_ready(timeout=bound)
|
|
144
|
+
if not ready:
|
|
145
|
+
# Leave the stderr buffer around so the caller can surface it.
|
|
146
|
+
self.close(grace=0.5)
|
|
147
|
+
raise RuntimeError(
|
|
148
|
+
f"Julia worker {self.worker_id} did not emit ready within "
|
|
149
|
+
f"{bound:g} s {STARTUP_TIMEOUT_HINT}. stderr tail: "
|
|
150
|
+
f"{self._stderr_tail(6)}"
|
|
151
|
+
)
|
|
152
|
+
self._step = ""
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def run_test_with_timeout(
|
|
157
|
+
self,
|
|
158
|
+
test: TestModel,
|
|
159
|
+
test_key: str,
|
|
160
|
+
timeout: float,
|
|
161
|
+
progress: object | None = None,
|
|
162
|
+
before: Callable[[], None] | None = None,
|
|
163
|
+
) -> TestRunResult:
|
|
164
|
+
"""Dispatch one test, wait ``timeout`` seconds for the response.
|
|
165
|
+
|
|
166
|
+
``progress`` is accepted for shape compatibility with the other
|
|
167
|
+
backends' workers (Dymola/OM emit ``on_phase`` updates from inside
|
|
168
|
+
the worker). Julia today reports phases via the dispatch loop's
|
|
169
|
+
``_record`` helper instead, so this argument is currently ignored.
|
|
170
|
+
``before`` runs first, as the base's does; nothing bounds it here —
|
|
171
|
+
the deadline below starts with the request.
|
|
172
|
+
|
|
173
|
+
Overrides the base watchdog: the response arrives on a queue, so a
|
|
174
|
+
deadline poll reads it directly instead of parking a blocking call
|
|
175
|
+
on an exec thread the watchdog would then have to abandon. The kill
|
|
176
|
+
and the restart-accounting flags are the shared ones, and so is the
|
|
177
|
+
cancel (``Worker.request_cancel``): the poll sees it within a tick.
|
|
178
|
+
The driver has no stop short of its process, so a cancel is the
|
|
179
|
+
timeout's kill, uncharged.
|
|
180
|
+
"""
|
|
181
|
+
if self.proc is None or self.proc.poll() is not None:
|
|
182
|
+
return TestRunResult(
|
|
183
|
+
model_id=test.model_id,
|
|
184
|
+
test_key=test_key,
|
|
185
|
+
success=False,
|
|
186
|
+
error_message="Julia worker not running",
|
|
187
|
+
)
|
|
188
|
+
if before is not None:
|
|
189
|
+
failed = self._run_before(before, test, test_key)
|
|
190
|
+
if failed is not None:
|
|
191
|
+
return failed
|
|
192
|
+
user_file = resolve_source_file(test, self.config, JuliaBackend.source_suffix)
|
|
193
|
+
if user_file is None or not user_file.exists():
|
|
194
|
+
return TestRunResult(
|
|
195
|
+
model_id=test.model_id,
|
|
196
|
+
test_key=test_key,
|
|
197
|
+
success=False,
|
|
198
|
+
error_message=(
|
|
199
|
+
f"Julia source not found for {test.model_id}. "
|
|
200
|
+
f"Ensure the test_spec.json 'source' field resolves to a .jl file."
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
test_dir = self.config.work_dir / test_key
|
|
205
|
+
test_dir.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
result_path = test_dir / "result.json"
|
|
207
|
+
|
|
208
|
+
request = {
|
|
209
|
+
"user_file": str(user_file.resolve()),
|
|
210
|
+
"stop_time": float(test.stop_time),
|
|
211
|
+
"tolerance": float(test.tolerance),
|
|
212
|
+
"result_path": str(result_path),
|
|
213
|
+
"test_key": test_key,
|
|
214
|
+
}
|
|
215
|
+
wall_start = time.monotonic()
|
|
216
|
+
try:
|
|
217
|
+
assert self.proc.stdin is not None
|
|
218
|
+
self.proc.stdin.write(json.dumps(request) + "\n")
|
|
219
|
+
self.proc.stdin.flush()
|
|
220
|
+
except (BrokenPipeError, OSError) as exc:
|
|
221
|
+
return TestRunResult(
|
|
222
|
+
model_id=test.model_id,
|
|
223
|
+
test_key=test_key,
|
|
224
|
+
success=False,
|
|
225
|
+
error_message=f"Julia worker stdin broken: {exc}",
|
|
226
|
+
elapsed=time.monotonic() - wall_start,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
deadline = wall_start + timeout
|
|
230
|
+
while True:
|
|
231
|
+
remaining = deadline - time.monotonic()
|
|
232
|
+
if self._cancel is not None:
|
|
233
|
+
self._kill_tracked_pids()
|
|
234
|
+
self._await_killed_process()
|
|
235
|
+
elapsed = time.monotonic() - wall_start
|
|
236
|
+
return TestRunResult(
|
|
237
|
+
model_id=test.model_id,
|
|
238
|
+
test_key=test_key,
|
|
239
|
+
success=False,
|
|
240
|
+
elapsed=elapsed,
|
|
241
|
+
sim_wall=elapsed,
|
|
242
|
+
error_message="Cancelled",
|
|
243
|
+
cancelled=True,
|
|
244
|
+
worker_killed=True,
|
|
245
|
+
)
|
|
246
|
+
if remaining <= 0:
|
|
247
|
+
self._kill_tracked_pids()
|
|
248
|
+
self._await_killed_process()
|
|
249
|
+
elapsed = time.monotonic() - wall_start
|
|
250
|
+
# Persist the stderr tail for diagnosis.
|
|
251
|
+
(test_dir / "julia_stderr.txt").write_text(
|
|
252
|
+
self._stderr_tail(30),
|
|
253
|
+
encoding="utf-8",
|
|
254
|
+
)
|
|
255
|
+
return TestRunResult(
|
|
256
|
+
model_id=test.model_id,
|
|
257
|
+
test_key=test_key,
|
|
258
|
+
success=False,
|
|
259
|
+
elapsed=elapsed,
|
|
260
|
+
sim_wall=elapsed,
|
|
261
|
+
error_message=timeout_message(
|
|
262
|
+
f"Julia simulation exceeded {timeout}s timeout"
|
|
263
|
+
),
|
|
264
|
+
timed_out=True,
|
|
265
|
+
# We killed a *healthy* worker because the test was slow;
|
|
266
|
+
# the dispatch loop's benign-restart accounting keys on
|
|
267
|
+
# ``timed_out or worker_killed``. Same semantics as the
|
|
268
|
+
# shared watchdog's timeout branch.
|
|
269
|
+
worker_killed=True,
|
|
270
|
+
)
|
|
271
|
+
try:
|
|
272
|
+
line = self._stdout_q.get(timeout=min(remaining, 0.5))
|
|
273
|
+
except queue.Empty:
|
|
274
|
+
# Subprocess might have crashed; check its liveness.
|
|
275
|
+
if self.proc.poll() is not None:
|
|
276
|
+
elapsed = time.monotonic() - wall_start
|
|
277
|
+
(test_dir / "julia_stderr.txt").write_text(
|
|
278
|
+
self._stderr_tail(30),
|
|
279
|
+
encoding="utf-8",
|
|
280
|
+
)
|
|
281
|
+
return TestRunResult(
|
|
282
|
+
model_id=test.model_id,
|
|
283
|
+
test_key=test_key,
|
|
284
|
+
success=False,
|
|
285
|
+
elapsed=elapsed,
|
|
286
|
+
sim_wall=elapsed,
|
|
287
|
+
error_message="Julia worker crashed mid-test",
|
|
288
|
+
)
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
# Skip non-test events (stray 'ready' pulses from "cmd:ready").
|
|
292
|
+
try:
|
|
293
|
+
resp = json.loads(line)
|
|
294
|
+
except json.JSONDecodeError:
|
|
295
|
+
logger.debug("worker %d: non-JSON stdout: %r", self.worker_id, line)
|
|
296
|
+
continue
|
|
297
|
+
if "event" in resp and "test_key" not in resp:
|
|
298
|
+
continue
|
|
299
|
+
# Response for this test.
|
|
300
|
+
if resp.get("test_key") != test_key:
|
|
301
|
+
logger.debug(
|
|
302
|
+
"worker %d: test_key mismatch %s != %s; ignoring",
|
|
303
|
+
self.worker_id,
|
|
304
|
+
resp.get("test_key"),
|
|
305
|
+
test_key,
|
|
306
|
+
)
|
|
307
|
+
continue
|
|
308
|
+
elapsed = time.monotonic() - wall_start
|
|
309
|
+
sim_wall = float(resp.get("elapsed", elapsed))
|
|
310
|
+
status = resp.get("status")
|
|
311
|
+
if status == "ok":
|
|
312
|
+
# Persist a minimal stdout artifact for report parity.
|
|
313
|
+
(test_dir / "julia_stdout.txt").write_text(
|
|
314
|
+
f"// PersistentJuliaBackend worker={self.worker_id}\n"
|
|
315
|
+
f"test_key={test_key}\nelapsed={sim_wall:.3f}s\n"
|
|
316
|
+
f"n_vars={resp.get('n_vars', 0)}\n"
|
|
317
|
+
f"n_time={resp.get('n_time', 0)}\n",
|
|
318
|
+
encoding="utf-8",
|
|
319
|
+
)
|
|
320
|
+
return TestRunResult(
|
|
321
|
+
model_id=test.model_id,
|
|
322
|
+
test_key=test_key,
|
|
323
|
+
success=True,
|
|
324
|
+
elapsed=elapsed,
|
|
325
|
+
sim_wall=sim_wall,
|
|
326
|
+
statistics={"simulation": {"wall_time": sim_wall}},
|
|
327
|
+
)
|
|
328
|
+
# status == "fail" (or anything else)
|
|
329
|
+
err = resp.get("error", "unknown error")
|
|
330
|
+
(test_dir / "julia_stderr.txt").write_text(
|
|
331
|
+
err + "\n\n--- worker stderr ---\n" + self._stderr_tail(30),
|
|
332
|
+
encoding="utf-8",
|
|
333
|
+
)
|
|
334
|
+
return TestRunResult(
|
|
335
|
+
model_id=test.model_id,
|
|
336
|
+
test_key=test_key,
|
|
337
|
+
success=False,
|
|
338
|
+
elapsed=elapsed,
|
|
339
|
+
sim_wall=sim_wall,
|
|
340
|
+
error_message=(
|
|
341
|
+
err.splitlines()[0] if err else "Julia simulation failed"
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# ---------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
def is_alive(self) -> bool:
|
|
348
|
+
return self.proc is not None and self.proc.poll() is None
|
|
349
|
+
|
|
350
|
+
def _await_killed_process(self) -> None:
|
|
351
|
+
"""Wait out the kill, then drop the handle.
|
|
352
|
+
|
|
353
|
+
``kill_process_tree`` returns before the OS has torn the process
|
|
354
|
+
down, so ``proc.poll()`` can still read alive for a moment after
|
|
355
|
+
it. A worker that reads alive gets the next test dispatched
|
|
356
|
+
straight to it: that test then fails "crashed mid-test" — a
|
|
357
|
+
result carrying no ``worker_killed`` flag, so the restart it
|
|
358
|
+
forces is charged against ``max_restarts_per_worker``. Reaping
|
|
359
|
+
the process here, and detaching it either way, makes
|
|
360
|
+
``is_alive()`` False before we return so the dispatch loop
|
|
361
|
+
restarts *before* handing us more work.
|
|
362
|
+
"""
|
|
363
|
+
proc = self.proc
|
|
364
|
+
self.proc = None
|
|
365
|
+
if proc is None:
|
|
366
|
+
return
|
|
367
|
+
if proc.poll() is None:
|
|
368
|
+
# Belt and braces: kill_process_tree is a no-op whenever psutil
|
|
369
|
+
# is missing, the name guard misses, or the walk hits
|
|
370
|
+
# AccessDenied. We own this Popen — use it.
|
|
371
|
+
try:
|
|
372
|
+
proc.kill()
|
|
373
|
+
except (ProcessLookupError, OSError):
|
|
374
|
+
pass
|
|
375
|
+
try:
|
|
376
|
+
proc.wait(timeout=_KILL_EXIT_TIMEOUT)
|
|
377
|
+
except subprocess.TimeoutExpired:
|
|
378
|
+
logger.warning(
|
|
379
|
+
"Worker %d: killed julia process %d has not exited after %.0fs",
|
|
380
|
+
self.worker_id,
|
|
381
|
+
proc.pid,
|
|
382
|
+
_KILL_EXIT_TIMEOUT,
|
|
383
|
+
)
|
|
384
|
+
# Only stdin. The reader threads are parked in readline() holding
|
|
385
|
+
# the BufferedReader lock, so closing stdout/stderr here blocks
|
|
386
|
+
# the dispatch thread until the child finally exits — at
|
|
387
|
+
# --parallel 1 that is the whole run hanging on the one process
|
|
388
|
+
# we already failed to kill. The readers close their own ends
|
|
389
|
+
# when the pipe EOFs.
|
|
390
|
+
if proc.stdin:
|
|
391
|
+
try:
|
|
392
|
+
proc.stdin.close()
|
|
393
|
+
except Exception:
|
|
394
|
+
pass
|
|
395
|
+
return
|
|
396
|
+
_close_pipes(proc)
|
|
397
|
+
|
|
398
|
+
def _detach_interface(self) -> object | None:
|
|
399
|
+
proc = self.proc
|
|
400
|
+
self.proc = None
|
|
401
|
+
return proc
|
|
402
|
+
|
|
403
|
+
def _shutdown_interface(self, handle: object, grace: float) -> None:
|
|
404
|
+
"""Ask the driver to quit, wait for it, then close the pipes.
|
|
405
|
+
|
|
406
|
+
A driver that misses *grace* is killed here through the Popen we
|
|
407
|
+
own: the template's PID kill is the only other teardown, and it is
|
|
408
|
+
a no-op whenever psutil is missing or the process name no longer
|
|
409
|
+
matches ``kill_name_hint``.
|
|
410
|
+
"""
|
|
411
|
+
proc: subprocess.Popen = handle # type: ignore[assignment]
|
|
412
|
+
if proc.poll() is None and proc.stdin is not None:
|
|
413
|
+
try:
|
|
414
|
+
proc.stdin.write('{"cmd": "quit"}\n')
|
|
415
|
+
proc.stdin.flush()
|
|
416
|
+
except (BrokenPipeError, OSError):
|
|
417
|
+
pass
|
|
418
|
+
try:
|
|
419
|
+
proc.wait(timeout=grace)
|
|
420
|
+
except subprocess.TimeoutExpired:
|
|
421
|
+
try:
|
|
422
|
+
proc.kill()
|
|
423
|
+
except (ProcessLookupError, OSError):
|
|
424
|
+
pass
|
|
425
|
+
_close_pipes(proc)
|
|
426
|
+
|
|
427
|
+
# ---------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
def _reader_loop(self, stream, q: queue.Queue) -> None:
|
|
430
|
+
# The reader owns this end: it holds the BufferedReader lock inside
|
|
431
|
+
# readline(), so nobody else can safely close it. Closing here on
|
|
432
|
+
# EOF is what lets the teardown paths leave it alone.
|
|
433
|
+
try:
|
|
434
|
+
for line in iter(stream.readline, ""):
|
|
435
|
+
q.put(line.strip())
|
|
436
|
+
except Exception as exc:
|
|
437
|
+
logger.debug("worker %d reader error: %s", self.worker_id, exc)
|
|
438
|
+
finally:
|
|
439
|
+
try:
|
|
440
|
+
stream.close()
|
|
441
|
+
except Exception:
|
|
442
|
+
pass
|
|
443
|
+
|
|
444
|
+
def _stderr_drain(self, stream) -> None:
|
|
445
|
+
try:
|
|
446
|
+
for line in iter(stream.readline, ""):
|
|
447
|
+
line = line.rstrip("\n")
|
|
448
|
+
# Keep a bounded tail for diagnostics.
|
|
449
|
+
self._stderr_buf.append(line)
|
|
450
|
+
if len(self._stderr_buf) > 200:
|
|
451
|
+
self._stderr_buf = self._stderr_buf[-200:]
|
|
452
|
+
except Exception:
|
|
453
|
+
pass
|
|
454
|
+
finally:
|
|
455
|
+
try:
|
|
456
|
+
stream.close() # see _reader_loop: the reader owns this end
|
|
457
|
+
except Exception:
|
|
458
|
+
pass
|
|
459
|
+
|
|
460
|
+
def _stderr_tail(self, n: int = 20) -> str:
|
|
461
|
+
return "\n".join(self._stderr_buf[-n:])
|
|
462
|
+
|
|
463
|
+
def _wait_for_ready(self, timeout: float) -> bool:
|
|
464
|
+
deadline = time.monotonic() + timeout
|
|
465
|
+
while time.monotonic() < deadline:
|
|
466
|
+
try:
|
|
467
|
+
line = self._stdout_q.get(timeout=0.5)
|
|
468
|
+
except queue.Empty:
|
|
469
|
+
if self.proc and self.proc.poll() is not None:
|
|
470
|
+
return False
|
|
471
|
+
continue
|
|
472
|
+
try:
|
|
473
|
+
msg = json.loads(line)
|
|
474
|
+
except json.JSONDecodeError:
|
|
475
|
+
continue
|
|
476
|
+
if msg.get("event") == "ready":
|
|
477
|
+
return True
|
|
478
|
+
return False
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# ---------------------------------------------------------------------------
|
|
482
|
+
# Backend
|
|
483
|
+
# ---------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class PersistentJuliaBackend(PersistentBackendBase, JuliaBackend):
|
|
487
|
+
"""Persistent-worker Julia backend.
|
|
488
|
+
|
|
489
|
+
Inherits dispatch-loop machinery from :class:`PersistentBackendBase`
|
|
490
|
+
and ``read_result`` + config resolution from the batch
|
|
491
|
+
:class:`JuliaBackend`. Order matters — ``PersistentBackendBase`` first so
|
|
492
|
+
its ``run_tests`` template wins over the batch backend's per-test
|
|
493
|
+
override.
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
capabilities = frozenset(
|
|
497
|
+
{
|
|
498
|
+
Capability.PERSISTENT_WORKERS,
|
|
499
|
+
Capability.BATCH_FALLBACK,
|
|
500
|
+
# The worker's: its poll loop honours Worker.request_cancel.
|
|
501
|
+
Capability.CANCEL,
|
|
502
|
+
}
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
worker_cls = JuliaWorker
|
|
506
|
+
backend_label = "Julia"
|
|
507
|
+
|
|
508
|
+
@classmethod
|
|
509
|
+
def preflight(cls, config) -> None:
|
|
510
|
+
# Cheap probe: does `julia` resolve on PATH? Subsequent failures
|
|
511
|
+
# (toml errors, missing packages, MTK compile failures) still
|
|
512
|
+
# bubble up from worker.start() inside run_tests.
|
|
513
|
+
import shutil
|
|
514
|
+
|
|
515
|
+
if not shutil.which("julia"):
|
|
516
|
+
raise RuntimeError(
|
|
517
|
+
"Julia binary not found on PATH. The persistent-worker Julia "
|
|
518
|
+
"backend spawns a long-lived `julia --project=...` subprocess "
|
|
519
|
+
"and needs `julia` available.\n"
|
|
520
|
+
"\n"
|
|
521
|
+
"Install Julia from https://julialang.org/downloads/ "
|
|
522
|
+
"(or use a version manager like `juliaup`)."
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
def make_worker(self, worker_id: int) -> JuliaWorker:
|
|
526
|
+
return JuliaWorker(worker_id, self.config, self.julia_config)
|
|
527
|
+
|
|
528
|
+
# No _probe_worker_version override: the julia version is a property of
|
|
529
|
+
# the configured binary, not of a live session, so JuliaBackend.
|
|
530
|
+
# describe_tool_version answers it and the shared prologue stamps it
|
|
531
|
+
# before the workers start.
|
|
532
|
+
|
|
533
|
+
def starting_workers_message(self, n_workers: int) -> str:
|
|
534
|
+
# First runs hit MTK + OrdinaryDiffEq JIT compile, which is
|
|
535
|
+
# multi-minute. Surfacing this at start time so users don't think
|
|
536
|
+
# the worker is hung. Subsequent runs hit the cache and start in
|
|
537
|
+
# seconds, but the message is still accurate as a worst case.
|
|
538
|
+
return (
|
|
539
|
+
f"Warming up {n_workers} Julia worker(s) — first run may take "
|
|
540
|
+
f"2-3 min while MTK + OrdinaryDiffEq load from cache..."
|
|
541
|
+
)
|