Skip to content

serve: model registry with hot-swap and per-request model logging - #74

Open
hendrikras wants to merge 13 commits into
sqliteai:mainfrom
hendrikras:model-registry
Open

hendrikras wants to merge 13 commits into
sqliteai:mainfrom
hendrikras:model-registry

Conversation

@hendrikras

Copy link
Copy Markdown
Contributor

Add a swappable model registry 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.

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.

@marcobambini marcobambini left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — this is a useful feature and it is carefully done: the swap-order
rollback, the real-socket tests and the docs are all in the right shape.
A few things need to change before it can go in, mostly around what happens
to requests that overlap a swap and around the RAM ceiling, plus one
behavior change that reaches servers that never use --models.

  1. Requests racing a swap (server.py _chat / _completions): the
    engine is picked before its lock is taken, and the per-model state
    (chat_format, stop_tokens, default_thinking, model_info) is read
    from srv rather than from a snapshot, so a request that overlaps a swap
    can run on a closed engine, or on the new engine holding only the old
    engine's lock, or on one model's waste_ctx with the other model's
    format. Inline comments below.
  2. RAM budget: opening the new container before closing the old one
    with the startup engine_kwargs means that with the default
    --budget 0 both engines auto-size to ~3/4 of usable RAM, so a swap
    briefly asks for ~1.5x the machine. ram_budget_bytes is meant to be a
    hard ceiling for everything the process holds, and paging is the failure
    mode this engine exists to avoid.
  3. The 404 on an unknown model also applies without --models:
    servers that never opt into the registry start rejecting clients that
    send a fixed model name, including the "model":"waste" example in
    serve --help.

Smaller: --keep-previous's help text promises concurrent answering that
the code does not do; an explicit --usage hotlist is handed to every
container; a swap skips the startup diagnostics (chat_error in
particular).

🤖 Generated with Claude Code

Comment thread serve/server.py Outdated
def _chat(self):
body = self._read_body()
srv = self.server
engine = srv.engine

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

engine is taken here, before any lock, and the swap only holds the
previous engine's lock. Two interleavings that break:

  • Without --keep-previous: a request reads srv.engine (A) → a swap takes
    A's lock, opens B, closes A → the request takes A's lock and calls
    state_reset() / generate() on a closed engine. _check() raises
    instead of crashing, but the client gets a 500 for a request that arrived
    before the swap did.
  • In both modes: everything else the request reads — srv.chat_format
    (L593), srv.default_thinking (L591), srv.model_info (L597),
    srv.new_parser (L603), srv.stop_tokens — comes from srv at the time
    it is read, i.e. possibly from the model swapped in after engine was
    taken. With --keep-previous that means building an XTML prompt for a
    container that speaks chat.json (or vice versa) and generating it on the
    other model's waste_ctx.

_detect() also rebinds those attributes one at a time, so even a request
that does not race the lock can read a half-updated set.

Suggestion: make the per-model state one immutable object (engine,
model_id, model_info, chat_format, stop_tokens, default_thinking, parser
factory, chat_error), have _detect() build a new one and assign it in a
single step, and have each request take a snapshot once, then lock
that snapshot's engine and re-check it is still current (or at least not
closed) after acquiring the lock — retrying on the new current model or
answering 409/503 if it moved.

Comment thread serve/server.py
srv = self.server
self._log_model_from(body) # before check_model_request: a 404 or
# 409 line should still name the model that was refused
srv.check_model_request(body)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Anchored here because L812 is outside the diff: this is about with srv.engine.lock: at L812–835 in _completions.)

Same race, a bit sharper: srv.engine is read once to take the lock and
again on L813, L814 and L835. A request waiting on A's lock across a swap
wakes up holding A's lock and calls state_reset(), tokenize() and
generate() on B, while another request may be holding B's lock. Only
generate() takes the engine's RLock internally, so the reset/tokenize/
generate sequence of two requests can interleave on one waste_ctx — the
cross-request contamination the comment in _chat describes. Take the
engine into a local once, as _chat does, and apply the same snapshot
fix.

Comment thread serve/server.py Outdated

The old engine's lock is held across the whole swap, so a
generation in flight finishes before the slot moves under it, and
no request that took the old lock can find its engine closed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"no request that took the old lock can find its engine closed" only holds
for a request that had already acquired the old lock. A request that
picked the old engine and is waiting for its lock gets it after the swap
has closed that engine (see L547). Either fix the race or correct the
docstring.

Comment thread serve/__main__.py
models=parse_registry(args.models),
keep_previous=args.keep_previous,
engine_kwargs={
"ram_budget_bytes": args.budget,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the default --budget 0, each Engine() sizes itself to ~3/4 of
waste_usable_ram(). Because the new container is opened before the old
one is closed, a swap has two contexts each planned against the whole
machine, ~1.5x usable RAM, and a fully-resident container also starts its
preload thread in that window. docs/SERVE.md:353 says to "size
--budget so that moment fits", but nothing enforces it and the default
does not satisfy it.

Options, roughly in order of preference:

  • Refuse --models without an explicit --budget, and check that
    2x budget fits under waste_usable_ram() (or plan the new container with
    plan_memory and refuse the swap with a 507/503 if it cannot fit next to
    the current one).
  • With --keep-previous the sum is unbounded, not "both budgets": every
    model ever loaded stays open. Cap the number of resident engines, or
    count their budgets against a total and refuse the load that would
    exceed it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, your suggestions have been implemented, however in addition I have opted to also add a new feature:

--auto-register (off by default, same posture as --allow-local-images) lets POST /v1/models/load take {"path": "..."} for a container it has never been told about, deriving the id the same way --models PATH[=ID] does.
register_path just edits the registry; load_model → check_room still does the real work, so the existing budget accounting, 507 refusal, and open-before-close rollback apply unchanged to a path loaded this way.
planned_budget now prices a no---budget load by floor_bytes rather than recommended_bytes, because that is the actual number that must fit when the engine is left to size itself to whatever the machine can spare.

I believe this achieves the same thing but is more flexible as it does not force a 2x budget at startup for models that might not be requested by a router during the session.

The two options both work side by side but we could also choose to drop one solution in favor of the other. Your choice.

Comment thread serve/server.py

# ---- the model registry ----------------------------------------------

def check_model_request(self, body: dict) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs even when no --models were given, so a single-container server
now 404s any model other than its id. Its id defaults to the file name
(k3.waste → k3), and many OpenAI clients send a fixed name they cannot
easily change; the serve --help example itself sends "model":"waste".
This is a breaking change for every existing deployment, not only for
those opting into the registry.

Suggestion: validate only when the registry has more than one entry, and
otherwise keep serving any name as today — or make strict validation its
own flag. Whichever way, it deserves a CHANGELOG line in the release that
ships it.

Comment thread serve/__main__.py Outdated
"contexts need together is the sum of their "
"budgets, and on the machines this engine targets "
"that is the difference between working and "
"paging. Both models can then answer at once — "

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Both models can then answer at once" — but check_model_request answers
409 for any model that is not the current one, resident or not, and
load_model's docstring says so ("other resident models answer after the
next load names them, not before"). The help should describe what the
code does: switching back is a slot move instead of a reopen.

Comment thread serve/server.py Outdated
# per-container facts are unknown until it is opened.
data = [api.model_object(srv.model_id, srv.started,
srv.model_info, loaded=True)]
data += [api.model_object(mid, srv.started, None, loaded=False)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With --keep-previous, a model that is still resident (in srv.engines)
is listed as "loaded": false, the same as one that was never opened. If
loaded means "current", call it that; if it means "resident", use
mid in srv.engines. The /v1/models/load response on L539 already
reports residency, so the two endpoints currently disagree.

Comment thread serve/__main__.py
"direct_io": not args.no_direct_io,
"vision": args.vision,
"verify_records": args.verify,
"usage_path": args.usage,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--usage is a learned hotlist of one container's experts (default
<model>/usage.waste). When given explicitly, it is passed to every
container a swap opens, so GLM would be opened with K3's hotlist. With the
default (None) each container finds its own and this is fine. Either drop
usage_path from engine_kwargs, or refuse --usage together with
--models.

Comment thread serve/server.py Outdated
# again on every load, and it keeps the old comment because every word
# of it still holds.

def _detect(self, engine: Engine, model_id: str) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At startup, __main__ reports what the container can do: the banner, the
chat_error warning ("/v1/chat/completions is unavailable for this
container"), the thinking/tools/images line. A swap re-runs detection but
reports none of it, so switching to a container without a usable chat
format produces 400s with no operator-visible note. At least log a line on
stderr per swap: which model, and chat_error if set.

…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.
@mfethe1

mfethe1 commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Nice PR — the registry/404/409 semantics, the open-before-close rollback ordering, and the budget pre-check are all well shaped, and check_engine is exactly the right answer to the queued-request problem.

One thing I think should block merge: a lock-order inversion that wedges the whole server.

load_model takes _slot_lock then the engine lock; the generation path takes them in the opposite order.

  • load_model — with self._slot_lock: (serve/server.py:559) → with previous_engine.lock: (:572)
  • generation — with engine.lock: (:882, :1121) → srv.check_engine(slot) → current_slot() → with self._slot_lock: (:322)

A POST /v1/models/load arriving while a generation is in flight closes the cycle: the load holds _slot_lock waiting on the engine lock the generation holds, and the generation's check_engine waits on _slot_lock. Since every request path reaches _slot_lock, the server wedges for all clients, not just the two involved.

Repro — deterministic, no timing race, on a clean clone of this PR head (1d2e287), stdlib only, using the repo's own FakeEngine:

Start a thread that holds engine.lock (as a generation does), call load_model("swap-a") from another and give it time to reach with previous_engine.lock:, then have the first thread call the real current_slot():

ARM 1 control  (no load in flight): returned in 0.0000s
ARM 2 test     (load in flight)   : HUNG >5s

Suggested fix: make the swap use the same order as everything else — read the slot, release _slot_lock, take previous_engine.lock, then re-take _slot_lock and re-check the slot did not move (retry if another swap won the race). With that applied both arms return in 0.0000s.

Regression check on the fix, python3 -m unittest discover -s tests/serve -t . (macOS arm64, py3.14.7):

result
this PR head, unpatched Ran 394 tests — failures=2, skipped=83
with the fix Ran 394 tests — failures=2, skipped=83

Identical, so the reorder costs nothing in the suite.


Separately, those 2 failures are pre-existing on this branch and look like a real second defect introduced by 1d2e287:

FAIL: TestGlmToolsFromChatJson.test_load_log_names_the_model_loaded                   404 != 200
FAIL: TestGlmToolsFromChatJson.test_load_log_stderr_reports_swap_and_chat_error_if_set 404 != 200

Both POST /v1/models/load {"model": "swap-a"}, both get 404. That class's setUp (tests/serve/test_server.py:1773) never passes models= to serve(), so the registry is empty and swap-a is a correct 404 — the two new tests were added to a fixture that has no registry. Adding "models": {"swap-a": ...} to its server_kwargs should be all it needs.

Also FYI GitHub currently reports this unmergeable — content conflicts with main in CHANGELOG.md and serve/server.py.

Happy to send the lock-order fix as a PR against this branch if that's useful.

@mfethe1

mfethe1 commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Heads-up: the merge of main at 4d16785 accidentally broke the serve integration suite on this branch — it is not a flake, and it is not from the model-registry code.

What happened. The conflict resolution in serve/server.py inserted a duplicate copy of the pre-#73 ChatServer class inside ModelSlot.detect's body: the file now has two top-level class ChatServer definitions, and the first 31 lines of the duplicate (starting class ChatServer(ThreadingHTTPServer):, ending self.tmpdir = self._tmp) sit between default_thinking = start_thinking and the try: that used to follow it. At module scope that terminates detect early, so ModelSlot.detect returns None, ChatServer.__init__ stores self._slot = None, and every request path (current_slot()) raises AttributeError: 'NoneType' object has no attribute 'engine' — the handler thread dies and the client sees a dropped connection.

Attribution (macOS arm64, suite run at each of the three revisions in a clean clone):

revision tests/serve/test_integration.py
main @ 09fcff3 12/12 OK
branch pre-merge head 1d2e287 12/12 OK
merged head 4d167855 1 failure + 10 errors — all TestRealStack requests RemoteDisconnected, server log shows the AttributeError above at serve/server.py _chat

CI agrees: on 4d16785 the macos-arm64 job fails exactly there (FAIL serve suite, eight TestRealStack errors). It only shows up on runners that build the library — where libwaste is absent the class skips, which is why the windows run check still passed.

Fix. Deleting the duplicated 31-line block (keep main's #73 ChatServer with request_queue_size = 128 as the only definition, and restore detect's original body from the branch side) takes the merged head back to 12/12 OK on the integration suite, and the full tests/serve tree back to exactly the two pre-existing TestGlmToolsFromChatJson 404 failures — which I confirmed also fail at 1d2e287, so the merge introduces no other serve regression.

Repro, no build needed:

python3 - <<'EOF'
from serve.server import ModelSlot
import inspect
src = inspect.getsource(ModelSlot.detect)
print("detect body length:", len(src.splitlines()))   # ~85 when intact; ~15 when truncated
EOF

(and grep -c '^class ChatServer' serve/server.py → 2 on the current head, 1 everywhere else.)

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
@mfethe1

mfethe1 commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

551a72f fixed the ModelSlot.detect breakage — the duplicated ChatServer block is gone and request_queue_size = 128 is back where it belongs. Confirmed against the diff 4d167855..551a72f9 (1 file, +7/-31).

CI is still red on linux-x86_64, linux-arm64, macos-arm64 and windows-x86_64 (native build + suite), but the merge is exonerated for what remains. The macos-arm64 job on 551a72f fails on exactly two tests and nothing else:

FAIL: tests.serve.test_server.TestGlmToolsFromChatJson.test_load_log_names_the_model_loaded
FAIL: tests.serve.test_server.TestGlmToolsFromChatJson.test_load_log_stderr_reports_swap_and_chat_error_if_set
AssertionError: 404 != 200

Attribution — copies of def test_load_log_names_the_model_loaded in tests/serve/test_server.py:

ref copies
b34cb47d strict model validation 1
1364101f loaded means residency 1
e0b73381 refusing --usage with --models 1
1d2e2873 serve: Logs a line on stderr per swap 2
4d167855 merge of main 2
551a72f9 current head 2

The second copy arrived with 1d2e2873, three commits before the merge.

Root cause. The block belongs to TestRequestLogs (line 918), which starts the server with --models so swap-a is in the registry. A second copy was also appended to the end of TestGlmToolsFromChatJson (line 1870), whose setUp builds a GLM chat.json container with no registry at all. POST /v1/models/load {"model": "swap-a"} is correctly a 404 there, so the copied assertEqual(status, 200) cannot pass. The trailing def test_get_log_carries_no_model(self): pass in the same block also silently overrides that case for the GLM class.

The same test names pass in their real home:

$ python -m unittest -v tests.serve.test_server.TestRequestLogs
test_load_log_names_the_model_loaded ... ok
test_get_log_carries_no_model ... ok
Ran 6 tests in 3.056s — OK

Fix — delete the 14-line block at the end of TestGlmToolsFromChatJson (1870–1883), keeping the TestRequestLogs copy.

Measured on a clean clone of pull/74/head @ 551a72f9, libwaste rebuilt from that tree (make libwaste.a libwaste.dylib libwastevq.dylib), same machine and same binary for both arms:

before: Ran 394 tests — FAILED (failures=2, skipped=26)
after:  Ran 391 tests — OK (skipped=26)          # 1 file changed, 14 deletions(-)

The two failures in the before arm are exactly the two the CI job reports, and the after arm is clean with no remaining failures. (My earlier pass of this reused a prebuilt libwaste.dylib from another checkout, which added a spurious test_version_matches_the_header 0.8.0 != 0.8.1 failure; rebuilding removed it, so it was mine, not yours.)

If you do want the swap-logging assertions covered for the GLM container too, that class's setUp needs a registry that actually holds swap-a — but TestRequestLogs already covers the behaviour, so deleting is the straightforward call.

Boundary: local macOS arm64, CPU-only, self-built libwaste, no GPU and no real checkpoint. These are pure Python tests/serve cases, which is why they reproduce identically on all four red runners.

@hendrikras

Copy link
Copy Markdown
Contributor Author

Thanks, the attribution table and the same binary before/after on a clean clone are exactly the right way to settle this, and the diagnosis is correct: the block belongs in TestRequestLogs, and the GLM class's setUp never gave its server a registry, so swap-a was always going to 404. I've verified against my tree: TestRequestLogs passes in its
real home (6/6), and both of the two failing tests now pass.

Where I diverted: instead of deleting lines 1870–1883, I completed the GLM class with what it was missing: a models={"swap-a": ...} registry entry, an engine factory, and the stderr-capture / logs() machinery TestRequestLogs already uses. That's the alternative your report names ("that class's setUp needs a registry that actually holds swap-a"),
and I took it because the tests then run rather than die:

  • test_load_log_names_the_model_loaded runs for real against the GLM server.
  • test_load_log_stderr_reports_swap_and_chat_error_if_set runs for real, and the "chat_error if set" half of its name is now actually exercised. The factory builds a GLM-family swap target whose chat.json cannot be resolved, so its slot carries a chat_error and the stderr line reports it.
  • The pass stub you flagged is filled with real assertions (GET /v1/models → 200, no [model= in the log) rather than left as a silent pass.

If you'd rather have the deletion for a smaller diff, it's a contained revert of the GLM-class completion, happy to switch.

@mfethe1

mfethe1 commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Ran the new head (67d8338) rather than reading it. Results, then one real defect in --auto-register, then your question.

The two load-log tests now pass, and I confirmed they are the ones that were failing. Same machine, same libwaste.dylib built once at 67d8338 and copied to both trees, so the binary is not a variable:

tree result
551a72f (before your fix) Ran 394 ... FAILED (failures=2, skipped=26) — test_load_log_names_the_model_loaded, test_load_log_stderr_reports_swap_and_chat_error_if_set
67d8338 (this head) Ran 394 ... OK (skipped=26)

Upstream CI agrees: 551a72f was red on linux-x86_64, linux-arm64, macos-arm64 and windows native; 67d8338 is 11/11 green. The four red runners I attributed earlier are resolved — that attribution is closed.

Defect: a refused auto-register load leaves the id in the registry.

register_path edits self.registry before load_model prices anything, and nothing unwinds it. So a load that check_room refuses with 507 still registers the path. Measured with your own ServerTestCase/FakeEngine harness at usable_ram=15, ram_budget_bytes=10, so the swap window never fits:

LOAD status: 507  (insufficient_memory)
BEFORE /v1/models: ['test-model']
AFTER  /v1/models: ['test-model', 'probe-model']
LEAKED_IDS: ['probe-model']

The consequence is not just a stale list entry. The id survives the refusal, so a later request can load it by id alone, with no path in the body — which means it never passes the --auto-register check a second time:

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

One refused request permanently widens the id namespace the server accepts. That matters precisely because --auto-register is the gate you put in front of "open an arbitrary file on this machine": the gate is consulted on the request that fails, not on the one that succeeds.

The 404-on-missing-file path is clean — register_path raises before touching the registry, and the registry is unchanged after it.

Fix I measured (against 67d8338; whole serve suite Ran 401 ... OK (skipped=26) with it applied, so it breaks none of your new coverage):

         mid = body.get("model")
         path = body.get("path")
+        registered = None                   # set if this request added an id
...
+            known_before = set(srv.registry)
             mid = srv.register_path(path, mid)
+            if mid not in known_before:
+                registered = mid
         elif not isinstance(mid, str) or not mid:
             raise api.APIError("'model' must be a non-empty string", param="model")
-        previous = srv.load_model(mid)
+        try:
+            previous = srv.load_model(mid)
+        except BaseException:
+            if registered is not None:
+                srv.registry.pop(registered, None)
+            raise

known_before is what keeps it honest: only an id this request introduced is rolled back, so re-loading an operator's existing --models entry never deregisters it on a 507. It is the same rollback discipline your open-before-close order already gives the engines, extended to the registry. Three probes flip from LEAKED to clean and step 2 goes 200 -> 404.

Two boundaries on the above, so you can discount them correctly. First, I did not find that the leaked id was reachable from /v1/chat/completions — chat with an unregistered model name returns 200 regardless, on both trees, because it falls through to the current slot. That is pre-existing behaviour unrelated to your patch and I am not claiming it as one. Second, this is the fake-engine harness, not a real container: it proves the registry bookkeeping, not anything about real load timing.

One note, not a defect: with the flag on, absent vs present paths are distinguishable (404 vs 507/200), so the endpoint can be used to test whether a path exists on the host. Off by default and behind an explicit flag, so I read that as inside the posture you documented — worth a line in the flag's help text rather than a code change, if you agree.

On your question — keep both, and I would not drop --models PATH=ID. They answer different questions: --models is the operator saying in advance which containers this server may ever touch, and --auto-register is the operator delegating that choice to whoever can reach the port. Dropping the first would make the second the only way to get a second model, which forces the permissive posture on deployments that currently do not need it. Dropping the second loses the 2x-budget-at-startup saving you are after. Your floor_bytes change for the no-budget case is the right number for the reason you give — that is what has to fit when the engine sizes itself.

Environment: macOS arm64, Python 3.14.7, libwaste.dylib built at 67d8338 via make -s libwaste.dylib, suite run as python3 -m unittest discover -s tests/serve -t . -p "test_*.py" (the same invocation tests/run.sh uses). No real model weights were loaded anywhere in this.

serve: fix lock order inversion
@mfethe1

mfethe1 commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Ran the lock-order fix at bc915745 rather than reading it. The ABBA wedge I reported is dynamically gone, and one earlier finding is still open at this head.

1. Deadlock: fixed, measured. Same frozen probe as before, predictions written down before the clone:

tree reader latency while a load is in flight
67d8338 wedged, >3s (probe timeout)
bc915745 0.00s

The decisive number is the reader: it returns instantly while load_model has not yet returned, which is exactly the proof that _slot_lock is no longer held across the wait on engine.lock. The shape is what the diagnosis called for — slot read under _slot_lock, lock released, with previous_engine.lock: and _slot_lock re-taken underneath, plus the slot re-check and continue for a concurrently-moved slot. Generation-path ordering untouched.

tests/serve/test_server.py: 165 passed (+3 subtests) in 95.5s here, including your new TestSwapWhileGenerating. Boundary, so you can discount it correctly: the full tests/run.sh did not complete on my machine this run — the C build hit my 420s cap under load, not a test failure. Upstream CI is 11/11 green at this head, so I am not claiming anything beyond the serve gates locally.

On the retry loop: it re-runs only when a competing swap wins engine.lock between the release and the re-take, and your wedge test asserts the load lands (loader.join(10)). I looked for a starvation path and did not find one. Not a blocker.

2. The auto-register registry leak is still present at this head, and I can say so without re-running it. I extracted the POST /v1/models/load handler and register_path from both revisions and compared them: both regions are byte-identical between 67d8338 and bc915745 (handler 2556 bytes, sha256 547f5e6eb3a3ac65…; register_path 1826 bytes, identical). Since the code that produced

LOAD status: 507  (insufficient_memory)
BEFORE /v1/models: ['test-model']
AFTER  /v1/models: ['test-model', 'probe-model']
LEAKED_IDS: ['probe-model']

is unchanged, that measurement carries forward unmodified — a refused load still widens the id namespace permanently, and step 2 ({"model": "probe-model"} with no path) still bypasses the --auto-register gate. The known_before rollback diff from my previous comment applies to this head as-is; I re-checked that none of the three anchor lines it touches have moved.

One question, so I do not step on your branch: the PR head is on your fork, so I cannot push to it. If you want the rollback as a commit rather than a diff in a comment, say so and I will open a PR into this branch (--base your PR branch) so it lands inside #74 instead of competing with it. If you would rather write it yourself, that is fine too — I only care that a 507 stops leaving the id behind.

Environment: macOS arm64, Python 3.14.7, libwaste.dylib built at this head via make -s libwaste.dylib, suite run as python3 -m unittest discover -s tests/serve -t . -p "test_*.py". No real model weights were loaded anywhere in this.

@hendrikras

Copy link
Copy Markdown
Contributor Author

By all means, open the PR. I wont have time to push changes untill coming monday.

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.
@mfethe1

mfethe1 commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

Follow-up on the explicit hotlist isolation review: I independently reproduced a remaining CLI loophole on head 957ae5c. --usage shared.waste --auto-register --plan is admitted, although auto-registration enables model switching just like --models. With real native startup/destination engines using the admitted kwargs, both select the same explicit hotlist; the destination save overwrites the startup bytes. This establishes file aliasing/overwrite, not a measured inference or performance regression.

I have a local focused candidate extending the existing refusal to args.usage and (args.models or args.auto_register), before container checks, with advice to omit --usage for each container’s default usage.waste. Single-model explicit usage remains accepted. Parent rerun: eight focused tests pass with no skips; the real-container reproducer returns 2 before startup/swap; serve-check runs 424 tests with 23 optional skips and no failures. Tests cover registered/pathname loads, retained-engine reuse and unload/reopen, and byte-for-byte isolation.

Publication is held: repository-wide lint/type gates remain red. Saved exact-base/candidate lint reports each contain 178 diagnostics; type reports are identical. I am not claiming a fully green repository or that all review items are resolved. No follow-up branch has been pushed.

@hendrikras

Copy link
Copy Markdown
Contributor Author

Do you require any action from my side to move this PR forward?

@mfethe1

mfethe1 commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

No blocker from my side. Status at 957ae5c:

  1. The auto-register rollback from serve: roll back an auto-registered id when its load is refused #77 is in your branch (957ae5c), so I'll close serve: roll back an auto-registered id when its load is refused #77 as superseded.
  2. One small item left from my 9/30 note: serve/__main__.py:318 still only refuses --usage with --models. --auto-register also enables switching, so --usage x --auto-register hands the same hotlist to every container. The fix is to make it if args.usage and (args.models or args.auto_register):. I can open that as a PR into model-registry if you'd like.
  3. The formal blocker is Marco's changes-requested review from 9/19 (requests racing a swap, the RAM budget during a swap, and the 404 without --models). Once you've addressed those, re-requesting his review is probably the next step.

CI is 11/11 green at this head.

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.

3 participants