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:
2026-09-02 16:40:51 +02:00
co-authored by Claude Opus 5
parent 3e4224df53
commit 058f16ec1d
24 changed files with 686 additions and 169 deletions
+114 -12
View File
@@ -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.