Close eight open SDK tasks: the pidfile, the log, cards, names and a live curve
Each was a loose end recorded under `### SDK` in the notepad. `serve` takes its own pidfile down on SIGTERM. uvicorn restores the handler it found and re-raises the signal it stopped on, so the default handler ended the process without unwinding and the `finally` never ran — which is what a stop sends, and what left `serve.pid` behind. `serve.log` is cut back past 5 MB by the engine rather than by the screen that started it, so an adopted engine is bounded too. Gated on its own stdout being an appended regular file, which is what makes the cut safe: the kernel then puts the next write at the new end. Cards are counted from `/dev/nvidia[0-9]*`, so `FLOW_GPUS`/`--gpus` of 0 means "work it out" the way `FLOW_CPUS` always has. The engine counts, not the accountant — a remote worker builds one of those from its own inventory, and detecting there would hand it the engine host's cards. The worker counts last: what a batch job says it was granted still wins. `GET /runs/metrics/names` is the distinct over a selection that `--list` and the terminal's metric picker were approximating by reading the newest run that had measured anything, which missed a name only an older run ever wrote. `MetricSink` announces each batch it has written (`run_metric`, carrying the names). Not a per-point event: one covers up to 500 points or two seconds of them, and the rows stay the record. The terminal comparison fills in as the first readings land instead of staying blank until reopened, and the browser refetches the run and any comparison rather than the list behind them. `retry --group` pages the list route by `before` instead of stopping at 500. The terminal dashboard takes the terminal's colours (`ansi-dark`), and the web UI can re-pair from Settings without disconnecting first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PRQ9bmTvCbqCwXo9mxZzzV
This commit is contained in:
+114
-12
@@ -414,8 +414,13 @@ def test_an_export_is_parsed_with_its_selection() -> None:
|
||||
assert runs.format == "jsonl"
|
||||
|
||||
|
||||
def test_the_metric_names_are_asked_for_rather_than_guessed() -> None:
|
||||
"""A name is flow-qualified, so `--list` is what says what would match."""
|
||||
def test_the_metric_names_are_asked_for_rather_than_guessed(capsys) -> None:
|
||||
"""A name is flow-qualified, so `--list` is what says what would match.
|
||||
|
||||
The engine answers it over the whole selection, so a name only an older run
|
||||
ever recorded is listed — which reading the newest run that measured
|
||||
anything could not do.
|
||||
"""
|
||||
from fluksio.cli import _parser
|
||||
from fluksio.sdk.cli import _list_names
|
||||
|
||||
@@ -423,20 +428,19 @@ def test_the_metric_names_are_asked_for_rather_than_guessed() -> None:
|
||||
assert parser.parse_args(["export", "metrics"]).list_names is False
|
||||
assert parser.parse_args(["export", "metrics", "--list"]).list_names is True
|
||||
|
||||
class Engine:
|
||||
def runs(self, flow="", limit=0, **filters):
|
||||
assert filters == {"status": "ok"}
|
||||
return [{"id": "r-empty"}, {"id": "r-1"}]
|
||||
asked = {}
|
||||
|
||||
def metrics(self, run_id, name="", stride=1):
|
||||
# The newest run failed before it measured anything; the next one
|
||||
# carries the vocabulary.
|
||||
return [] if run_id == "r-empty" else [{"name": "train.train_loss"}]
|
||||
class Engine:
|
||||
def metric_names(self, ids=(), flow="", **filters):
|
||||
asked.update({"ids": list(ids), "flow": flow, **filters})
|
||||
return ["train.train_loss", "train.val_loss"]
|
||||
|
||||
args = parser.parse_args(
|
||||
["export", "metrics", "--flow", "train", "--status", "ok", "--list"]
|
||||
)
|
||||
assert _list_names(Engine(), args) == 0
|
||||
assert asked == {"ids": [], "flow": "train", "status": "ok"}
|
||||
assert capsys.readouterr().out.split() == ["train.train_loss", "train.val_loss"]
|
||||
|
||||
|
||||
def test_a_runs_artifact_is_listed_and_downloaded(tmp_path, monkeypatch) -> None:
|
||||
@@ -592,14 +596,19 @@ def test_serve_says_when_a_flow_wants_a_card_nobody_declared(
|
||||
) -> None:
|
||||
"""The clamp warning goes to the log; this is said while someone is reading.
|
||||
|
||||
Cards are declared rather than detected, so a fresh install that forgets
|
||||
`--gpus` clamps a GPU node to zero and runs them all at once.
|
||||
A card behind a driver whose device nodes are not NVIDIA's is not counted,
|
||||
so an install that forgets `--gpus` there clamps a GPU node to zero and
|
||||
runs them all at once. Detection is stubbed either way: whether this test
|
||||
says anything must not depend on the machine running it.
|
||||
"""
|
||||
from fluksio import cli
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.flow import resources
|
||||
from fluksio.flow.schemas import FlowDef, NodeDef, Resources
|
||||
from fluksio.flow.store import FlowStore
|
||||
|
||||
monkeypatch.setattr(resources, "machine_gpus", lambda: 0)
|
||||
|
||||
store = FlowStore(tmp_path / "flows")
|
||||
store.write_flow(
|
||||
FlowDef(
|
||||
@@ -621,6 +630,13 @@ def test_serve_says_when_a_flow_wants_a_card_nobody_declared(
|
||||
cli._mention_undeclared_cards()
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
# Nor when it found them itself, which is the ordinary case on a box with
|
||||
# NVIDIA's driver: nobody has to say what the device nodes already do.
|
||||
monkeypatch.setattr(settings, "FLOW_GPUS", 0)
|
||||
monkeypatch.setattr(resources, "machine_gpus", lambda: 1)
|
||||
cli._mention_undeclared_cards()
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_a_serve_limit_is_refused_as_a_flag_not_as_a_traceback(capsys) -> None:
|
||||
"""These are written into the environment before the settings are built."""
|
||||
@@ -745,6 +761,92 @@ def test_a_serving_engine_records_itself_until_it_stops(tmp_path) -> None:
|
||||
assert read_pidfile(tmp_path) is None
|
||||
|
||||
|
||||
def test_the_pidfile_goes_with_the_process_however_it_ends(tmp_path) -> None:
|
||||
"""A SIGTERM used to leave `serve.pid` behind, which is what a stop sends.
|
||||
|
||||
uvicorn puts back the handler it found and re-raises the signal it stopped
|
||||
on; the default handler ends the process without unwinding, so the `finally`
|
||||
that removes the file never ran. A stale file costs the dashboard a probe
|
||||
and, if a pid is ever recycled, points at a stranger.
|
||||
"""
|
||||
import signal
|
||||
|
||||
from fluksio.cli import _hold_pidfile, write_pidfile
|
||||
|
||||
was = signal.getsignal(signal.SIGTERM)
|
||||
try:
|
||||
written = write_pidfile(tmp_path, 8000)
|
||||
assert written.exists()
|
||||
with pytest.raises(SystemExit):
|
||||
_hold_pidfile(written, lambda: signal.raise_signal(signal.SIGTERM))
|
||||
assert not written.exists()
|
||||
|
||||
# And the ordinary way out, which always worked.
|
||||
written = write_pidfile(tmp_path, 8000)
|
||||
_hold_pidfile(written, lambda: None)
|
||||
assert not written.exists()
|
||||
finally:
|
||||
signal.signal(signal.SIGTERM, was)
|
||||
|
||||
|
||||
def test_the_engine_cuts_its_own_log_when_it_grows(tmp_path) -> None:
|
||||
"""The dashboard cut it only when *it* started the engine, so an adopted
|
||||
one — or one whose screen was closed — wrote without a bound.
|
||||
|
||||
Only an appended file is cut: the kernel then puts the next write at the
|
||||
new end, where a `>` redirect would keep its offset and leave a hole.
|
||||
"""
|
||||
from fluksio.cli import _appended_log, _trim_log
|
||||
|
||||
path = tmp_path / "serve.log"
|
||||
with path.open("ab", buffering=0) as handle:
|
||||
fd = handle.fileno()
|
||||
handle.write(b"0123456789")
|
||||
assert _appended_log(fd) is True
|
||||
assert _trim_log(fd, limit=100) is False
|
||||
assert path.stat().st_size == 10
|
||||
assert _trim_log(fd, limit=5) is True
|
||||
assert path.stat().st_size == 0
|
||||
# It keeps writing into the same descriptor afterwards.
|
||||
handle.write(b"after")
|
||||
assert path.read_bytes() == b"after"
|
||||
|
||||
with path.open("wb", buffering=0) as handle:
|
||||
# Not appended, so not this engine's to cut.
|
||||
handle.write(b"0123456789")
|
||||
assert _appended_log(handle.fileno()) is False
|
||||
|
||||
|
||||
def test_a_sweep_larger_than_a_page_is_retried_whole() -> None:
|
||||
"""`retry --group` read one page of the list route and stopped there.
|
||||
|
||||
Pages come newest first and `before` is the cursor, so the next page is
|
||||
taken from the last row's own timestamp rather than from an offset that
|
||||
shifts under a run submitted meanwhile.
|
||||
"""
|
||||
from fluksio.sdk.cli import PAGE, _group_runs
|
||||
|
||||
asked = []
|
||||
|
||||
class Engine:
|
||||
def runs(self, flow="", limit=0, **filters):
|
||||
asked.append(filters)
|
||||
page = 0 if "before" not in filters else 1
|
||||
if page:
|
||||
return [{"id": "last", "created_at": "2026-09-02T00:00:00Z"}]
|
||||
return [
|
||||
{"id": f"r{index}", "created_at": f"2026-09-02T00:00:{index:02d}Z"}
|
||||
for index in range(PAGE)
|
||||
]
|
||||
|
||||
rows = list(_group_runs(Engine(), "sweep-1"))
|
||||
|
||||
assert len(rows) == PAGE + 1
|
||||
assert asked[0] == {"group": "sweep-1"}
|
||||
# The second page starts where the first ended.
|
||||
assert asked[1]["before"] == rows[PAGE - 1]["created_at"]
|
||||
|
||||
|
||||
def test_who_holds_the_port_is_told_apart_by_the_token() -> None:
|
||||
"""Only this directory's own engine may be reported as already up.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user