fix(model): stop listing a backend that exited on its own (#12497)

Assisted-by: Claude:claude-fable-5-1

Signed-off-by: Aniruddh Krovvidi <akrovvidi05@gmail.com>
This commit is contained in:
Aniruddh Krovvidi authored and GitHub committed 2026-10-07 09:28:49 +02:00
1 parent 5537f4b1ef
commit a3d555653c
2 files changed
+44

No files matched your search

+24
View File
@@ -399,11 +399,35 @@ func (ml *ModelLoader) startProcess(grpcProcess, id string, serverAddress string
}
runtime.cleanupScratch()
close(runtime.diagnosticsDone)
if !intentional {
ml.forgetExitedProcess(id, grpcControlProcess)
}
}()
return grpcControlProcess, nil
}
// forgetExitedProcess drops the model store entry of a backend that exited on
// its own, so it is no longer reported as loaded. It shares the lifecycle lock
// with loading and shutdown and matches the process identity, so a late exit
// notification cannot remove a replacement loaded under the same id.
func (ml *ModelLoader) forgetExitedProcess(id string, p *process.Process) {
release := ml.operations.acquire(id, true)
defer release()
ml.mu.Lock()
store := ml.store
hooks := append([]ModelUnloadHook(nil), ml.onUnloadHooks...)
ml.mu.Unlock()
if m, ok := store.Get(id); !ok || m.Process() != p {
return
}
for _, hook := range hooks {
hook(id)
}
store.Delete(id)
ml.cleanupProcessRuntime(p)
}
func (ml *ModelLoader) cleanupProcessRuntime(process *process.Process) {
if process == nil {
return
+20
View File
@@ -43,4 +43,24 @@ var _ = Describe("backend process exit diagnostics", func() {
loader.cleanupProcessRuntime(process)
Eventually(process.StateDir()).ShouldNot(BeADirectory())
})
It("stops listing a model whose backend exited on its own, but keeps a replacement", func() {
tmpDir := GinkgoT().TempDir()
GinkgoT().Setenv(backendTempDirEnv, filepath.Join(tmpDir, "backend-runtime"))
backendPath := filepath.Join(tmpDir, "crashing-backend")
Expect(os.WriteFile(backendPath, []byte("#!/bin/sh\nsleep 0.3\nexit 42\n"), 0o700)).To(Succeed())
loader := NewModelLoader(&system.SystemState{Model: system.Model{ModelsPath: tmpDir}})
crashed, err := loader.startProcess(backendPath, "crashed", "127.0.0.1:65535", nil)
Expect(err).ToNot(HaveOccurred())
loader.store.Set("crashed", NewModel("crashed", "127.0.0.1:65535", crashed))
replaced, err := loader.startProcess(backendPath, "replaced", "127.0.0.1:65534", nil)
Expect(err).ToNot(HaveOccurred())
loader.store.Set("replaced", NewModel("replaced", "127.0.0.1:65533", nil))
Eventually(crashed.Done()).Should(BeClosed())
Eventually(replaced.Done()).Should(BeClosed())
Eventually(func() bool { _, ok := loader.store.Get("crashed"); return ok }).Should(BeFalse())
Consistently(func() bool { _, ok := loader.store.Get("replaced"); return ok }, "300ms").Should(BeTrue())
})
})