phraser: gate every llm.Client call on the swap drain

The drain counted only the phrasing paths in internal/phraser. The router, the
replier, the mail extractor and the memory evaluator reach llama-server through
llm.Client, so quiesce could report zero requests in flight while the router was
mid-generation, and the old server was killed under it. The turn then finished
on the new model, which is the split turn the swap exists to prevent. llm.Client
now enters an optional Gate before every completion and LLMPhraser implements
it, so one counter covers every holder of the base URL.

A total failure also reported itself as a rollback. Swap set RolledBack on the
path where the rollback failed too, so the page rendered "rolled back to  — she
is still answering, with the old model" over an empty model name and a daemon
with no model at all. The total failure has its own flag now, LiveModel stops
naming a gguf that is not loaded, and the log says another attempt can recover
without a restart, which is true.

The swap also ran on the connection every other page shares. ipc.Client holds
its mutex for a whole roundtrip with no read deadline on either side, so a load
froze /dash, /history and /notifications for minutes. mavweb dials a second
connection for /models alone. POST /models joins the route table, and the load
settings no longer come off a form that renders no input for them.

Found in review of #68.
This commit is contained in:
kami
2026-08-01 14:15:58 +04:00
parent 810076451f
commit 3ff2a9340a
8 changed files with 312 additions and 17 deletions
+6
View File
@@ -58,6 +58,7 @@ func wireModelSwap(srv *ipc.Server, phr phraser.Phraser, cfg *config.Config) {
ModelPath: res.ModelPath,
BaseURL: res.BaseURL,
RolledBack: res.RolledBack,
NoBackend: res.NoBackend,
TookMs: res.Took.Milliseconds(),
}
if err != nil {
@@ -106,8 +107,13 @@ func wireModelSwap(srv *ipc.Server, phr phraser.Phraser, cfg *config.Config) {
// the port of a server that no longer exists, and the daemon would degrade to
// the classifier permanently after the first swap. The client is re-pointed, not
// rebuilt, so nothing that holds it has to know a swap happened.
// SetGate is the other half, and on the deploy shape it is the load-bearing one:
// llama-server is relaunched on the same fixed port, so SetBaseURL is usually a
// no-op, while the gate is what makes the swap's drain count these callers at
// all. Without it a swap can kill the server mid-routing-decision.
func llmClientFor(lp *phraser.LLMPhraser, timeout time.Duration) *llm.Client {
c := llm.New(lp.BaseURL(), timeout)
lp.OnSwap(func(base string) { c.SetBaseURL(base) })
c.SetGate(lp)
return c
}
+13 -1
View File
@@ -363,6 +363,11 @@ func main() {
flag.Parse()
var core ipc.CoreAPI
// swapConn — a second connection, for /models and nothing else. A model swap
// is a multi-minute IPC call and ipc.Client serialises everything on one
// mutex, so sharing the connection would freeze every other page for the
// length of the load. See handleModels.
var swapConn modelController
if *coreSock != "" {
c, err := ipc.DialWait(*coreSock, 60*time.Second)
if err != nil {
@@ -370,6 +375,12 @@ func main() {
}
defer c.Close()
core = c
if sc, err := ipc.Dial(*coreSock); err != nil {
log.Printf("models: second core connection failed (%v) — /models will share the main one and a swap will block the other pages", err)
} else {
defer sc.Close()
swapConn = sc
}
}
mux := http.NewServeMux()
@@ -499,13 +510,14 @@ func main() {
// /tools, and for a comparable reason: which model is loaded decides how every
// utterance is routed and how every reply is worded. GET is read-only.
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {
handleModels(w, r, core, stepUpSession, *requireStepUp)
handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp)
})
// State-changing routes on this server, and their gate (Vikunja #317):
//
// POST /tools step-up — defines argv that internal/tool executes
// POST /routines step-up — accepting schedules recurring firing
// POST /models step-up — replaces the model that routes and phrases
// POST /api/revert step-up — voids the latest fact for a key
// POST /api/chat step-up — reaches the router, LLM and the act path
// POST /api/signal none — appends a presence fact, no argv, no act
+23 -7
View File
@@ -36,6 +36,7 @@ var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(
const modelsHTML = `{{template "shellTop" "models"}}
<h1>Resident model</h1>
<p class=hint>swapping requires step-up — <a href=/auth/passkey>assert a passkey</a> first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.</p>
<p class=hint>a swap is not remembered. Nothing writes it down, so the next restart of the daemon — including the one <code>mavupdate</code> does — comes back on <code>phraser.model_path</code> from the config. Make it stick by editing that.</p>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
{{if .Off}}
@@ -80,12 +81,22 @@ type modelsPage struct {
// A failed swap is reported as a failure with the model that is still serving
// named, because that is the state the operator needs: the daemon rolled back
// and is answering turns, it just is not answering them with what he asked for.
func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
// swapConn, when non-nil, is a SECOND connection to the same core, used for
// nothing but this page. ipc.Client holds its mutex for a whole roundtrip and
// neither side sets a read deadline, so a swap on the shared connection blocks
// /dash, /history, /notifications and everything else for as long as the load
// takes: a 90s drain plus a 60s launch plus a 30s probe, doubled if it rolls
// back. No browser timeout frees them, because the server side keeps reading
// the reply. On its own connection the swap only blocks the swap.
func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swapConn modelController, session *webauthn.PasskeySession, requireStepUp bool) {
if core == nil {
http.Error(w, "models disabled (no -core)", http.StatusServiceUnavailable)
return
}
mc, ok := core.(modelController)
mc, ok := swapConn, swapConn != nil
if !ok {
mc, ok = core.(modelController)
}
if !ok {
http.Error(w, "models unavailable: core connection does not support model swap", http.StatusServiceUnavailable)
return
@@ -103,11 +114,13 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sess
http.Error(w, "model_path required", http.StatusBadRequest)
return
}
req := ipc.SwapModelReq{ModelPath: path}
if v, err := strconv.Atoi(r.FormValue("n_ctx")); err == nil {
req.NCtx = v
}
res, err := mc.SwapModel(ctx, req)
// Only the path comes off the form. n_ctx and n_gpu_layers are load
// settings the daemon keeps from what is live, and the resident model is
// a Thinking variant whose 4096-token window is sized for reasoning
// tokens (CLAUDE.md). A field nothing renders, that a hand-crafted POST
// could use to shrink the window under the router, is not worth having.
// Changing them is a config edit and a restart.
res, err := mc.SwapModel(ctx, ipc.SwapModelReq{ModelPath: path})
switch {
case err == nil:
page.Msg = "loaded " + res.Model + " (" + strconv.FormatInt(res.TookMs, 10) + "ms)"
@@ -118,6 +131,9 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sess
case errors.Is(err, ipc.ErrUnknownMethod):
http.Error(w, "swap not configured on this core", http.StatusServiceUnavailable)
return
case res.NoBackend:
page.Err = "swap failed AND the rollback failed — no model is loaded. She is answering from templates and routing on the classifier. Try loading a model again; a restart is not needed."
log.Printf("models: swap to %s failed and the rollback failed, no model loaded: %v", path, err)
case res.RolledBack:
page.Err = "swap failed, rolled back to " + res.Model + " — she is still answering, with the old model"
log.Printf("models: swap to %s failed, rolled back: %v", path, err)
+58 -2
View File
@@ -36,7 +36,7 @@ func (f *fakeModelCore) SwapModel(ctx context.Context, req ipc.SwapModelReq) (ip
func modelsGET(t *testing.T, core ipc.CoreAPI) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
handleModels(w, httptest.NewRequest(http.MethodGet, "/models", nil), core, nil, false)
handleModels(w, httptest.NewRequest(http.MethodGet, "/models", nil), core, nil, nil, false)
return w
}
@@ -45,7 +45,7 @@ func modelsPOST(t *testing.T, core ipc.CoreAPI, session *webauthn.PasskeySession
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path="+path))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handleModels(w, r, core, session, requireStepUp)
handleModels(w, r, core, nil, session, requireStepUp)
return w
}
@@ -148,3 +148,59 @@ func TestModels_CoreWithoutTheMethodsIs503(t *testing.T) {
type errBrokenModel struct{}
func (errBrokenModel) Error() string { return "llm: server did not start" }
func TestModels_TotalFailureDoesNotSaySheIsStillAnswering(t *testing.T) {
// The load failed and so did the rollback: nothing is loaded. The page used
// to branch on RolledBack first and render "rolled back to — she is still
// answering, with the old model" over an empty model name.
core := &fakeModelCore{
swapResp: ipc.SwapModelResp{NoBackend: true},
swapErr: errBrokenModel{},
status: ipc.ModelStatusResp{Model: "unknown"},
}
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
if w.Code != http.StatusOK {
t.Fatalf("POST /models after a total failure = %d; want 200 with the failure rendered", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "still answering") {
t.Errorf("the page claims she is still answering while no model is loaded:\n%s", body)
}
if !strings.Contains(body, "no model is loaded") {
t.Errorf("the page does not name the state the operator is in:\n%s", body)
}
}
func TestModels_POSTIgnoresLoadSettingsOffTheForm(t *testing.T) {
// n_ctx was read off a form that renders no such input, so only a
// hand-crafted POST could set it. The resident model is a Thinking variant
// whose window is sized for reasoning tokens; shrinking it from the wire is
// not a capability this page offers.
core := &fakeModelCore{swapResp: ipc.SwapModelResp{Model: "qwen3-cpt"}}
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path=/m/cpt.gguf&n_ctx=512&n_gpu_layers=0"))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handleModels(w, r, core, nil, nil, false)
if len(core.swapped) != 1 {
t.Fatalf("SwapModel calls = %v; want one", core.swapped)
}
if got := core.swapped[0]; got.NCtx != 0 || got.NGpuLayers != 0 {
t.Errorf("swap request = %+v; want the load settings left to the daemon", got)
}
}
func TestModels_SwapUsesItsOwnConnection(t *testing.T) {
// A swap is a multi-minute IPC call and ipc.Client serialises everything on
// one mutex, so it must not run on the connection every other page shares.
shared := &fakeModelCore{status: ipc.ModelStatusResp{Model: "qwen3"}}
swapConn := &fakeModelCore{swapResp: ipc.SwapModelResp{Model: "qwen3-cpt"}}
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path=/m/cpt.gguf"))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handleModels(httptest.NewRecorder(), r, shared, swapConn, nil, false)
if len(shared.swapped) != 0 {
t.Errorf("the swap went out on the shared connection: %v", shared.swapped)
}
if len(swapConn.swapped) != 1 {
t.Errorf("the swap did not use the dedicated connection: %v", swapConn.swapped)
}
}