Skip to content

serve: roll back an auto-registered id when its load is refused - #77

Closed
mfethe1 wants to merge 13 commits into
sqliteai:mainfrom
mfethe1:auto-register-rollback
Closed

mfethe1 wants to merge 13 commits into
sqliteai:mainfrom
mfethe1:auto-register-rollback

Conversation

@mfethe1

@mfethe1 mfethe1 commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Rolls back the id a refused --auto-register load leaves in the registry — the defect reported in #74 (comment of 2026-09-25), where register_path edits self.registry before load_model prices anything and nothing unwinds it. Branch is bc91574 (PR #74 head) plus this fix, per "By all means, open the PR."

The bug

A load that check_room refuses with 507 still registers the path. The leaked id then loads by name alone, with no path in the body — it never passes the --auto-register check a second time, because the gate is consulted on the request that failed, not the one that succeeds:

step 1  POST /v1/models/load {"path": ...}   -> 507
step 2  POST /v1/models/load {"model": "probe-model"}   (no path) -> 200   [before]

One refused request permanently widens the id namespace the server accepts. That matters precisely because --auto-register is the gate in front of "open an arbitrary file on this machine".

The fix

known_before diff keeps the rollback honest: only an id this request introduced is rolled back, so re-loading an operator's --models entry never deregisters it on a 507. Same rollback discipline the open-before-close order already gives the engines, extended to the registry.

+        registered = None                  # set if this request adds an id
...
+            known_before = set(srv.registry)
             mid = srv.register_path(path, mid)
+            if mid not in known_before:
+                registered = mid
...
-        previous = srv.load_model(mid)
+        try:
+            previous = srv.load_model(mid)
+        except BaseException:
+            if registered is not None:
+                srv.registry.pop(registered, None)
+            raise

BaseException so an engine crash mid-open cannot leak the id either.

Tests

TestAutoRegisterRefusedRollback (tests/serve/test_server.py, 4 tests; budget 10 × 2 > usable 15 so every swap is refused, FakeEngine harness):

  • refused path load → 507 and the id is gone from the registry (was: leaked)
  • the rolled-back id cannot be loaded by id alone → 404 (was: 200, the gate bypass)
  • an operator's --models entry survives a refused path load of the same id
  • gate-open + refused: registry byte-identical to before the request

Full suite on this branch: Ran 406 tests ... OK (skipped=83) — same invocation as tests/run.sh, nothing else changed.

Notes

hendrikras and others added 13 commits September 17, 2026 17:54
Add a swappable model registry to  so the model behind a
stable route name can change without reconfiguring clients.

- POST /v1/models/load swaps the loaded model; GET /v1/models lists the
  registry with loaded flags; generation requests are validated: unknown
  model is a 404, registered-but-not-loaded is a 409 pointing at the load
  endpoint instead of silently serving whatever is resident.
- CLI: --models PATH[=ID] registers swappable containers (repeatable),
  --keep-previous keeps a replaced model resident instead of unloading it.
- Swap opens the new container before closing the old one, so a failed
  load leaves the previous model serving; swap happens under the current
  engine's lock so a generation in flight finishes first.
- Request log lines now name the model: the one that served a success,
  the one refused on a 409/404, the one a load switched to. New
  --no-log-requests silences request logging.
- Tests over real sockets cover registry listing, swap, keep-previous,
  validation statuses, and the model names in the log; docs updated.
…no swap

A request used to read srv.engine before taking any lock, and read the
other per-model facts (chat_format, model_info, stop_tokens,
default_thinking, markers, chat_error) one attribute at a time. Two
interleavings broke: a request granted the old engine's lock only after
a swap had closed it called state_reset()/generate() on a dead ctx and
answered 500; and a request could see the new engine with the old chat
format — building an XTML prompt for a container that speaks chat.json
— because _detect() re-bound the attributes one at a time under a lock
the reader had not taken.

The per-model facts are now one immutable ModelSlot (engine, model_id,
model_info, markers, chat_format, chat_error, stop_tokens,
default_thinking, plus the parser factory), built whole by
ModelSlot.detect() and published in a single assignment. Each request
takes current_slot() once, locks that snapshot's engine, and re-checks
with check_engine() that the slot is still current before touching the
engine — answering 409 model_switched with the new current model id
otherwise. The same discipline covers /v1/completions, and the
streaming/blocking tails report stats and model from the slot rather
than re-reading the moving server mid-generation.
A swap opens the new container before closing the old one — the order that
makes a failed open leave the server serving what it was serving — so two
contexts are resident at once. docs/SERVE.md asked the operator to size
--budget so that moment fits, and nothing checked it. With the default
--budget 0 the moment was worse than "the sum of the two": each context
sizes itself to up to 3/4 of waste_usable_ram (waste.h,
waste_cfg.ram_budget_bytes), so a swap ran at ~1.5x what the process may
use — a paging run, not a slow one. With --keep-previous the total is not
two but every model ever loaded, which no startup check can price at all.

- --models now requires an explicit --budget, and the server refuses to
  start unless 2 x budget fits, naming the largest budget that does; the
  same lines print under the startup banner and under --plan, which is
  where a budget gets chosen.
- The resident set is counted at every load — each engine's declared
  budget, or the floor plus expert cache waste_memory_used reports for one
  that chose its own — and a load that would not fit is refused with 507
  (insufficient_memory) *before* the container is opened: nothing is
  closed, nothing is half-loaded, the previous model keeps serving, and
  the message says what it needed next to what was already held. 507
  rather than 503 because asking again cannot help.
- A slot move to a model that is already resident is never refused: it
  allocates nothing. Evicting a resident model to make room is not done —
  changing what is resident behind a client's back is the failure
  --keep-previous exists to prevent.

serve/api.py gains human_bytes, shared by the banner and the refusal, so
the two cannot quote different figures for the same machine. A platform
that will not report its RAM is warned about, not refused: a limit nobody
can measure is not a limit.

Tests: 9 new server tests (144, from 135) and tests/serve/test_main.py
with 12 of its own — the arithmetic needs no container, no libwaste and no
particular machine. Checked end to end against real containers too: the
banner, /v1/models, a --keep-previous swap, and a 507 from the third
resident at a declared budget of 24G on a 64G machine.
The registry made check_model_request strict for every deployment: with
no --models the registry holds only the loaded model, so a request naming
anything else was a 404 — breaking every client that sends a fixed model
name it cannot easily change, including serve --help's own example of
"model": "waste". The server's id defaults to the container's file
name (k3.waste -> k3), so even the correct name was one most clients did
not know.

Validation now applies only when the registry names a swappable set —
len(registry) > 1, which is what --models gives — and a single-container
server answers any name with the one model it has, as it did before the
registry existed. Strict validation is unchanged where it is wanted: a
404 for a name the registry does not know, a 409 for one it knows but
has not loaded, on both chat completions and raw completions, with the
model-before-shape ordering the OpenAI API uses.

docs/SERVE.md no longer describes the 404 as the default behaviour, and
the CHANGELOG carries the break-and-restore under Unreleased.

Tests: the no-registry case is covered where it lives now (a foreign name
served, shape validation first), the registry case keeps its 404/409 and
ordering tests, and /v1/completions gets the coverage chat had. Suite is
149 server tests, all green.
With --keep-previous a model the server still holds an open waste_ctx
for was listed loaded:false on GET /v1/models and /v1/models/{id} -
indistinguishable from a container never opened - while POST
/v1/models/load counted it in its resident set and check_model_request
called it 'not loaded'. The flag is residency: mid in engines, the same
set the load response reports. The 409 for a resident-but-idle model
names residency too; the model_not_loaded type and status are unchanged.
The waste shape still travels only on the current entry - the per-model
facts move with the current slot and are re-derived when a swap makes a
resident model current again.
Refusing --usage when --models is provided rejects this invalid configuration upfront, explaining clearly that --usage is container-specific and cannot be shared across multiple models. At the same time, removing "usage_path": args.usage from engine_kwargs in serve/__main__.py ensures that swap operations always let each container find its own hotlist (the default None behavior), preventing any accidental propagation of a single hotlist to swappable models.
To ensure model swaps report container capabilities and any chat formatting limitations to the operator, we log a line to `sys.stderr` on each swap in `ChatServer.load_model`, reporting the model switched to and its `chat_error` when set.
the load endpoint may be given a pathname instead of (or alongside) a known id. The server derives the model's id from the path — the same convention --models PATH[=ID] already uses on the CLI (file name minus .waste, or an explicit id) — adds it to the registry
serve: fix lock order inversion
register_path edits the registry before load_model prices anything, so
a load that check_room refuses with 507 left its id behind. The id then
loaded by name alone, with no path in the body -- bypassing the
--auto-register gate on the one request that never passed it.

Roll back exactly the id this request introduced (known_before diff), so
a refused re-load of an operator's --models entry never deregisters it.
Full serve suite: 406 tests, 0 failures.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants