dynamic-systems-testing-framework 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+ )