fix(custom-model): poll llama-swap readiness every 1s, check immediately, extend the cap

Reported: the "Loading..." banner stayed up past 2 minutes even though
llama-swap itself had already finished loading the model. Three fixes:

1. pollIntervalMs default 3000ms -> 1000ms (as asked).
2. The loop now checks readiness IMMEDIATELY on entry rather than sleeping
   a full interval first - a model that's already ready (a fast load, or a
   re-apply onto one already loaded) shouldn't sit on "Loading..." at all.
3. maxWaitMs default 120000ms (2 min) -> 300000ms (5 min): a large (20GB+)
   model reading from disk can genuinely take longer than 2 minutes, which
   would have looked identical to the reported symptom - "still stuck past
   the point it should have resolved" - except it would have actually
   flipped to a "still waiting" warning toast at the 2-minute mark rather
   than staying on "Loading" indefinitely, so this alone doesn't explain
   what was reported, but is a real, separate improvement worth making.

Also fixes a real, separate bug this surfaced while reasoning through the
report: _showCenterStatus's banner is ONE shared, reused DOM node. A second
call to _watchLlamaSwapLoading (e.g. switching models again before the
first switch's loop had finished) would take over that shared banner, but
the FIRST loop was still running and would eventually dismiss or overwrite
it once ITS OWN deadline or readiness check resolved - clobbering whatever
the second, current loop had put there. A generation counter
(_watchLlamaSwapGeneration) now lets each call recognise when it no longer
owns the banner and stop touching it silently, rather than only the last
call to actually start ever safely reading or writing it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
This commit is contained in:
Devvyn
2026-09-16 19:44:37 +08:00
co-authored by Claude Sonnet 5
parent 0929694012
commit 0af233c96c
2 changed files with 82 additions and 9 deletions
+24 -9
View File
@@ -928,36 +928,51 @@ Object.assign(CodemanApp.prototype, {
/**
* Polls llama-swap's own `/running` (via the read-only running-status route) until
* `modelId` reports `state: 'ready'`, showing a sticky toast the whole time so a slow
* `modelId` reports `state: 'ready'`, showing a sticky banner the whole time so a slow
* unload/reload (measured well over a minute for a large model) reads as "loading",
* never as silence or a wrong answer from whatever was loaded before. Bounded at 2
* minutes; still not ready by then gets a toast saying so rather than polling forever.
* never as silence or a wrong answer from whatever was loaded before. Checks immediately
* (a fast load, or a re-apply onto an already-ready model, shouldn't wait a full interval
* to say so), then every `pollIntervalMs`. Bounded at `maxWaitMs`; still not ready by then
* gets a toast saying so rather than polling forever — 5 minutes by default, since a large
* (20GB+) model reading from disk can genuinely take longer than the 2 minutes this used
* to allow.
*
* `_watchLlamaSwapGeneration` guards against two overlapping calls (a second launch
* started before the first one's loop finished) clobbering each other's banner:
* `_showCenterStatus` reuses one shared DOM node, so an older loop's `dismiss()`/message
* update firing after a newer one has already taken over the banner would otherwise hide
* or overwrite the WRONG one. Each call claims the counter as its own "generation" and
* checks it still owns it before touching the banner.
*
* `pollIntervalMs`/`maxWaitMs` exist to let a test drive this in milliseconds instead of
* minutes — real callers never pass them, which is what keeps the defaults live here
* rather than only in a test fixture.
*/
async _watchLlamaSwapLoading(endpointId, modelId, pollIntervalMs = 3000, maxWaitMs = 120000) {
async _watchLlamaSwapLoading(endpointId, modelId, pollIntervalMs = 1000, maxWaitMs = 300000) {
const generation = (this._watchLlamaSwapGeneration = (this._watchLlamaSwapGeneration || 0) + 1);
const isCurrent = () => this._watchLlamaSwapGeneration === generation;
// Prominent and screen-centred, not a corner toast — a real llama-swap model load can
// sit on screen for well over a minute, easy to mistake for nothing happening there.
const toast = this._showCenterStatus(`Loading ${modelId} on ${endpointId}… this can take a while`);
const deadline = Date.now() + maxWaitMs;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
const status = await this._apiJson(`/api/model-endpoints/${encodeURIComponent(endpointId)}/running-status`);
if (!status) continue; // transient failure — keep waiting rather than giving up early
if (!status.isLlamaSwap) {
if (!isCurrent()) return; // a newer launch took over the banner — this loop is done
if (!status) {
// transient failure — keep waiting rather than giving up early
} else if (!status.isLlamaSwap) {
// Endpoint changed under us, or wasn't llama-swap after all — nothing more to
// watch for, and not a failure worth a toast of its own.
toast?.dismiss();
return;
}
if (status.running.some((r) => r.model === modelId && r.state === 'ready')) {
} else if (status.running.some((r) => r.model === modelId && r.state === 'ready')) {
toast?.dismiss();
this.showToast(`${modelId} is ready`, 'success', { duration: 2500 });
return;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
if (!isCurrent()) return;
toast?.dismiss();
this.showToast(`Still waiting for ${modelId} to finish loading on ${endpointId} — check the llama-swap server`, 'warning');
},
+58
View File
@@ -676,6 +676,64 @@ describe('Custom Model Endpoint Profiles: _watchLlamaSwapLoading polling', () =>
expect(toastCalls.at(-1)).toMatch(/ready/i);
});
it('checks immediately rather than waiting a full interval before the first check', async () => {
// A model that is already ready by the time this runs (a fast load, or a re-apply
// onto one that was already loaded) shouldn't sit on "Loading..." for a whole
// pollIntervalMs before saying so.
const { app } = bootApp({});
app._showCenterStatus = () => ({ dismiss: () => {}, setMessage: () => {} });
let calls = 0;
app._apiJson = async () => {
calls += 1;
return { isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] };
};
// A huge interval that would time the test out if the function actually waited for
// it before the first check.
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 60000, 300000);
expect(calls).toBe(1);
});
it('a newer call takes over the shared banner — the older one neither dismisses nor overwrites it', async () => {
const { app } = bootApp({});
const bannerCalls: string[] = [];
const dismissCalls: string[] = [];
app._showCenterStatus = (message: string) => {
bannerCalls.push(message);
return { dismiss: () => dismissCalls.push(message), setMessage: () => {} };
};
app.showToast = () => {};
// The FIRST call never sees its target model ready, so it would otherwise run all
// the way to its own timeout and dismiss/warn — but a second call starts first.
let firstResolveApiJson: (() => void) | undefined;
const firstNeverReady = new Promise<void>((resolve) => {
firstResolveApiJson = resolve;
});
app._apiJson = async () => {
await firstNeverReady; // block the first loop's very first check indefinitely
return { isLlamaSwap: true, running: [] };
};
const firstCall = app._watchLlamaSwapLoading('llama-box', 'model-a', 5, 50);
// Second call, for a DIFFERENT model, starts while the first is still blocked on its
// very first status check — claims the banner as the newer generation.
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'model-b', state: 'ready' }] });
await app._watchLlamaSwapLoading('llama-box', 'model-b', 5, 200);
// Now let the first call's blocked check resolve and run to completion.
firstResolveApiJson?.();
await firstCall;
expect(bannerCalls).toEqual([
'Loading model-a on llama-box… this can take a while',
'Loading model-b on llama-box… this can take a while',
]);
// Only the CURRENT (second) call's own dismiss ever ran — the stale first call's
// late resolution recognised it no longer owns the banner and touched nothing.
expect(dismissCalls).toEqual([bannerCalls[1]]);
});
});
describe('Custom Model Endpoint Profiles: _confirmModelSwap (in-app modal, replaces a native confirm() popup)', () => {