Skip to content

Commit 4ec39bb

Browse files
authored
fix(watchdog): don't log optional Free() as an error when backend returns Unimplemented (#10602) (#10607)
* fix(watchdog): don't log optional Free() as an error when backend returns Unimplemented (#10602) When the watchdog evicts a model, deleteProcess calls the backend's gRPC Free() to release VRAM before stopping the process. Free is optional: backends that don't override it -- the generated UnimplementedBackendServer stub, many Python/external backends, or a federation proxy in distributed mode -- return gRPC Unimplemented. That is expected, not a failure: VRAM is reclaimed when the local process is stopped, or by the remote unloader for remote backends. Logging it as "WARN Error freeing GPU resources" made a benign, optional RPC look like a fault (the alarming line in #10602, seen in distributed mode where the model is remote and Free hits a stub). Treat gRPC Unimplemented from Free() as a no-op logged at Debug; genuine failures still Warn. Free() is still attempted for every backend, so any backend that does implement it is unaffected. Add a reusable grpcerrors.IsUnimplemented helper following the package's existing code-based detection idiom (prefer the typed status code, fall back to the message across non-gRPC boundaries), with table tests. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com> * fix(watchdog): log a non-Unimplemented Free() failure at error level Per review: now that the expected gRPC Unimplemented case is split out and logged at Debug, any remaining Free() error is a genuine failure to release VRAM, so surface it at error level instead of warn. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com> --------- Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com>
1 parent 25ecb9f commit 4ec39bb

3 files changed

Lines changed: 43 additions & 2 deletions

File tree

pkg/grpc/grpcerrors/errors.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,23 @@ func IsLiveTranscriptionUnsupported(err error) bool {
5858
return strings.Contains(strings.ToLower(err.Error()), "unimplemented")
5959
}
6060

61+
// IsUnimplemented reports whether err is a gRPC Unimplemented status — the
62+
// signal a backend gives for an RPC it does not implement. The generated
63+
// UnimplementedBackendServer stub returns exactly this for any RPC a backend
64+
// (e.g. a Python or external backend) has not overridden, so callers can treat
65+
// an optional RPC as a no-op rather than a failure. Prefers the typed status
66+
// code and falls back to the message for paths that lose the status (e.g. errors
67+
// wrapped across non-gRPC boundaries).
68+
func IsUnimplemented(err error) bool {
69+
if err == nil {
70+
return false
71+
}
72+
if status.Code(err) == codes.Unimplemented {
73+
return true
74+
}
75+
return strings.Contains(strings.ToLower(err.Error()), "unimplemented")
76+
}
77+
6178
// StreamTranscriptionUnsupported returns the canonical error a backend returns
6279
// when it (or the loaded model) cannot serve the server-streaming
6380
// AudioTranscriptionStream RPC. It carries codes.Unimplemented like the live

pkg/grpc/grpcerrors/errors_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ var _ = Describe("grpcerrors", func() {
5555
Expect(grpcerrors.IsModelNotLoaded(err)).To(BeFalse())
5656
})
5757

58+
DescribeTable("IsUnimplemented",
59+
func(err error, want bool) {
60+
Expect(grpcerrors.IsUnimplemented(err)).To(Equal(want))
61+
},
62+
Entry("nil", nil, false),
63+
Entry("typed code", status.Error(codes.Unimplemented, "method Free not implemented"), true),
64+
Entry("stale stub message (Unknown code)", errors.New("rpc error: code = Unimplemented desc = "), true),
65+
Entry("unrelated error", errors.New("context deadline exceeded"), false),
66+
Entry("unrelated grpc code", status.Error(codes.Unavailable, "connection refused"), false),
67+
Entry("model not loaded is NOT unimplemented", grpcerrors.ModelNotLoaded("parakeet-cpp"), false),
68+
)
69+
5870
It("StreamTranscriptionUnsupported carries Unimplemented and is not ModelNotLoaded", func() {
5971
err := grpcerrors.StreamTranscriptionUnsupported("parakeet-cpp", "not a streaming model")
6072
Expect(status.Code(err)).To(Equal(codes.Unimplemented))

pkg/model/process.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"time"
1212

1313
"github.com/hpcloud/tail"
14+
"github.com/mudler/LocalAI/pkg/grpc/grpcerrors"
1415
"github.com/mudler/LocalAI/pkg/signals"
1516
process "github.com/mudler/go-processmanager"
1617
"github.com/mudler/xlog"
@@ -52,10 +53,21 @@ func (ml *ModelLoader) deleteProcess(s string) error {
5253
hook(s)
5354
}
5455

55-
// Free GPU resources before stopping the process to ensure VRAM is released
56+
// Free GPU resources before stopping the process to ensure VRAM is released.
57+
// Free is optional: backends that don't override it (the generated stub, many
58+
// Python/external backends, or a federation proxy in distributed mode) return
59+
// gRPC Unimplemented. That is expected, not a failure — VRAM is reclaimed when
60+
// the process is stopped below, or by the remote unloader for remote backends —
61+
// so don't surface it as an error.
5662
xlog.Debug("Calling Free() to release GPU resources", "model", s)
5763
if err := model.GRPC(false, ml.wd).Free(context.Background()); err != nil {
58-
xlog.Warn("Error freeing GPU resources", "error", err, "model", s)
64+
if grpcerrors.IsUnimplemented(err) {
65+
xlog.Debug("Backend does not implement Free(); GPU release handled on process stop", "model", s)
66+
} else {
67+
// Now that the expected Unimplemented case is filtered out above, a
68+
// remaining error is a genuine failure to release VRAM — surface it.
69+
xlog.Error("Error freeing GPU resources", "error", err, "model", s)
70+
}
5971
}
6072

6173
process := model.Process()

0 commit comments

Comments
 (0)