Skip to content

Commit 1c0a462

Browse files
mudlerlocalai-org-maint-bot
authored andcommitted
feat(grpc): request cancellation for Go backends via the Cancellable capability
The llama.cpp C++ backend aborts generation when its gRPC context is cancelled (grpc-server.cpp polls context->IsCancelled() in the result loops), but Go backends served by pkg/grpc never observed context cancellation: a disconnected client left the generation running to completion. Add an optional Cancellable capability; the server registers context.AfterFunc on the request/stream context (after the Locking block so queued requests cannot abort the current owner) covering both rich and legacy paths. dllm implements it: measured cancel latency ~10ms vs ~10s of orphaned generation, and follow-up requests no longer queue behind cancelled ones (~220ms vs ~9s in the e2e proof). Assisted-by: Claude Code (Fable 5) Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent cd0ec09 commit 1c0a462

8 files changed

Lines changed: 349 additions & 17 deletions

File tree

.agents/dllm-backend.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ header comment has the full list):
5656
| PredictStreamRich | `dllm_capi_generate_stream`; per committed diffusion block → UTF-8 holdback → parser.Feed → one Reply per non-empty delta batch (channel closed by the CALLER, per `pkg/grpc/interface.go`) |
5757
| Predict / PredictStream | Legacy paths, delegate to the rich pair (legacy stream INVERTS channel ownership: the impl closes) |
5858
| TokenizeString | `dllm_capi_tokenize_json` (C side prepends BOS per `vocab.add_bos`) |
59-
| Cancel | `dllm_capi_cancel`; currently INERT in practice - the gRPC server does not hand the request/stream context to backends, so client disconnects never reach it (plumbing is future work) |
59+
| Cancel | `dllm_capi_cancel`, exposed as the `grpc.Cancellable` capability (`pkg/grpc/interface.go`): the gRPC server arms it via `context.AfterFunc` on the Predict/PredictStream context, so client disconnects/timeouts abort the in-flight generate - llama.cpp `IsCancelled()` parity for Go backends |
6060

6161
`n_threads` and `ctx_len` are accepted-but-ignored by the engine at the
6262
current pin (the context bound comes from GGUF `n_ctx_train`); they are sent
@@ -102,8 +102,8 @@ output is NOT gemma4-parsed (plain content, like any non-autoparsing backend).
102102
| Layer | Gate | What |
103103
|---|---|---|
104104
| `backend/go/dllm/*_test.go` (renderer/parser/wiring) | none - run in plain `go test ./backend/go/dllm/...` | Ginkgo specs over a fake `generator` seam; canonical renderer fixtures from transformers' `test_modeling_diffusion_gemma.py`, parser tables from the vLLM gemma4 parsers |
105-
| `backend/go/dllm/dllm_test.go` C-ABI smoke | `DLLM_TEST_LIBRARY` + `DLLM_TEST_TINY_MODEL` (dllm.cpp's `tests/fixtures/tiny_with_vocab.gguf`); Skips when unset | Drives the real `libdllm.so`: ABI check, load, tokenize `[2,18]`, deterministic generate, cancel |
106-
| `tests/e2e-backends/dllm_test.go` | `BACKEND_TEST_DLLM=1` + `BACKEND_BINARY` (packaged run.sh) + `BACKEND_TEST_MODEL_FILE` (tiny fixture) | Templated chat round trip (Messages + UseTokenizerTemplate) over the real gRPC binary, non-streaming + streaming |
105+
| `backend/go/dllm/dllm_test.go` C-ABI smoke | `DLLM_TEST_LIBRARY` + `DLLM_TEST_TINY_MODEL` (dllm.cpp's `tests/fixtures/tiny_with_vocab.gguf`); Skips when unset | Drives the real `libdllm.so`: ABI check, load, tokenize `[2,18]`, deterministic generate, cancel (incl. mid-stream `Dllm.Cancel` aborting a deliberately slow `eb_max_steps:256` run in ~10ms) |
106+
| `tests/e2e-backends/dllm_test.go` | `BACKEND_TEST_DLLM=1` + `BACKEND_BINARY` (packaged run.sh) + `BACKEND_TEST_MODEL_FILE` (tiny fixture) | Templated chat round trip (Messages + UseTokenizerTemplate) over the real gRPC binary, non-streaming + streaming; plus client-context cancellation mid-stream (proves the `Cancellable` server plumbing end to end) |
107107
| Real-model e2e | `BACKEND_TEST_DLLM_REAL_MODEL_FILE` (26B BF16, ~50 GB) + `BACKEND_TEST_DLLM_REAL_GPU_LAYERS` | CUDA-13-class hardware only |
108108

109109
Tool-call e2e is deliberately absent from the tiny-model spec: the fixture has
@@ -123,8 +123,11 @@ no ldd walk yet).
123123

124124
## Known limitations
125125

126-
- **Cancel is unwired**: nothing calls `Dllm.Cancel` on client disconnect
127-
until the gRPC server plumbs the request context through to backends.
126+
- **Cancel granularity**: the C-ABI cancel flag is per-ctx and resets on
127+
every generate entry, so a Cancel racing a NEW generate can be lost, and
128+
with requests queued on the worker it aborts whichever generate is
129+
currently running (acceptable: the server de-registers the hook on normal
130+
completion, one process serves one model).
128131
- **Throughput**: ~0.15 tok/s on the 26B at default settings (GB10) - every
129132
denoise step recomputes the full prompt+canvas. The upstream prefix-KV
130133
cache (dllm.cpp P3) is the fix; `kv_cache:on` errors until it lands

backend/go/dllm/dllm.go

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,18 @@ import (
2121
"sync"
2222
"unicode/utf8"
2323

24+
grpc "github.com/mudler/LocalAI/pkg/grpc"
2425
"github.com/mudler/LocalAI/pkg/grpc/base"
2526
"github.com/mudler/LocalAI/pkg/grpc/grpcerrors"
2627
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
2728
"github.com/mudler/xlog"
2829
)
2930

31+
// The gRPC server cancels in-flight generations on client disconnect only
32+
// for backends advertising the Cancellable capability; keep Dllm pinned to
33+
// it so a signature drift fails the build, not the disconnect path.
34+
var _ grpc.Cancellable = (*Dllm)(nil)
35+
3036
// generator is the seam between the backend wiring and the dllm.cpp C-ABI:
3137
// the real implementation (capiGenerator) wraps the cGenerate/cTokenizeJSON
3238
// family, while tests substitute a fake to exercise prompt construction,
@@ -181,18 +187,29 @@ func (d *Dllm) Free() error {
181187
return nil
182188
}
183189

184-
// Cancel requests cancellation of the in-flight generate. It deliberately
185-
// bypasses the worker queue: dllm_capi_cancel is the one call the C-ABI
186-
// allows from any goroutine mid-generate (it only flips an atomic).
190+
// Cancel requests cancellation of the in-flight generate (the
191+
// grpc.Cancellable capability). The gRPC server arms it via
192+
// context.AfterFunc on the request/stream context, so a client
193+
// disconnect or timeout aborts the generation server-side - the same
194+
// semantics the llama.cpp C++ backend gets from polling IsCancelled().
195+
// It deliberately bypasses the worker queue: dllm_capi_cancel is the one
196+
// call the C-ABI allows from any goroutine mid-generate (it only flips
197+
// an atomic).
187198
//
188-
// LIMITATION: nothing invokes this on client disconnect today. The gRPC
189-
// server (pkg/grpc/server.go) does not hand the request/stream context to
190-
// Predict/PredictStreamRich, so a dropped HTTP client cannot reach the
191-
// backend until that plumbing exists; the method is here so future server
192-
// wiring (or an admin RPC) has something to call. Note dllm_capi.h's
193-
// cancel-reset race: each generate resets the flag on entry, so a caller
194-
// racing a new generate should re-issue Cancel.
199+
// Note dllm_capi.h's cancel-reset race: each generate resets the flag on
200+
// entry, so a Cancel racing a NEW generate on the same ctx can be lost
201+
// (and, with requests queued on the worker, it aborts whichever generate
202+
// is currently running). The single-flag granularity is acceptable here
203+
// because the server de-registers the hook on normal completion and one
204+
// backend process serves one model.
195205
func (d *Dllm) Cancel() {
206+
// RLock so a server-side AfterFunc firing in the window between a
207+
// request finishing and a model unload cannot touch a freed C ctx
208+
// (Free holds the write lock while tearing gen down). cancel() is the
209+
// one C call that is safe concurrently with an in-flight generate, so
210+
// taking a read lock here cannot deadlock against request holders.
211+
d.genMu.RLock()
212+
defer d.genMu.RUnlock()
196213
if d.gen != nil {
197214
d.gen.cancel()
198215
}

backend/go/dllm/dllm_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,4 +768,40 @@ var _ = Describe("Dllm backend (real tiny model)", func() {
768768
}
769769
Expect(streamed).ToNot(BeEmpty())
770770
})
771+
772+
It("aborts an in-flight generation promptly on Cancel", func() {
773+
d := &Dllm{}
774+
// eb_max_steps inflates the per-block denoise loop so the full run
775+
// takes ~10s on the tiny fixture (vs ~40ms at engine defaults; 16
776+
// blocks, first block after ~0.7s) - long enough that a prompt
777+
// post-cancel return is distinguishable from the generation simply
778+
// finishing.
779+
Expect(d.Load(&pb.ModelOptions{
780+
ModelFile: os.Getenv("DLLM_TEST_TINY_MODEL"),
781+
Options: []string{"eb_max_steps:256"},
782+
})).To(Succeed())
783+
DeferCleanup(func() { Expect(d.Free()).To(Succeed()) })
784+
785+
ch := make(chan *pb.Reply, 64)
786+
errCh := make(chan error, 1)
787+
go func() {
788+
defer GinkgoRecover()
789+
errCh <- d.PredictStreamRich(&pb.PredictOptions{Prompt: "hello", Tokens: 256, Seed: 7}, ch)
790+
}()
791+
792+
// Cancel only once the first block proves the generate is in
793+
// flight: the C side resets the cancel flag on generate entry, so
794+
// an earlier Cancel would be swallowed (dllm_capi.h race note).
795+
Eventually(ch, "60s").Should(Receive())
796+
cancelAt := time.Now()
797+
d.Cancel()
798+
799+
// Uncancelled, ~10s of generation remain; the cancelled call must
800+
// come back in milliseconds (the flag is checked per denoise step).
801+
var genErr error
802+
Eventually(errCh, "5s").Should(Receive(&genErr))
803+
latency := time.Since(cancelAt)
804+
Expect(genErr).To(MatchError(ContainSubstring("cancelled")))
805+
GinkgoWriter.Printf("dllm cancel: PredictStreamRich returned %v after Cancel\n", latency)
806+
})
771807
})

docs/content/features/text-generation.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,7 @@ This backend is **experimental**, and the engine does not yet have a prompt-KV p
700700
- [📖 Text generation (GPT)]({{%relref "features/text-generation" %}})
701701
- [🔥 OpenAI functions]({{%relref "features/openai-functions" %}}) - tool calls are parsed natively by the backend (gemma4 `<|tool_call>` markers), not by LocalAI's grammar/regex fallback
702702
- Reasoning - opt-in thinking streams as `reasoning_content` (see below)
703+
- Request cancellation - disconnecting the client (or a request timeout) aborts the in-flight generation server-side, so an abandoned slow run does not keep the GPU busy
703704

704705
#### Supported platforms
705706

pkg/grpc/cancel_test.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package grpc
2+
3+
import (
4+
"context"
5+
"errors"
6+
"sync"
7+
"sync/atomic"
8+
"time"
9+
10+
"github.com/mudler/LocalAI/pkg/grpc/base"
11+
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
12+
. "github.com/onsi/ginkgo/v2"
13+
. "github.com/onsi/gomega"
14+
)
15+
16+
var errGenCancelled = errors.New("generation cancelled")
17+
18+
// cancellableBackend implements AIModel + AIModelRich + Cancellable. Its
19+
// rich predict paths optionally block until Cancel fires (blockUntilCancel),
20+
// which lets the specs prove the server's context.AfterFunc plumbing: a
21+
// cancelled request context must reach Cancel and unblock the generation.
22+
type cancellableBackend struct {
23+
base.SingleThread
24+
25+
blockUntilCancel bool
26+
27+
started chan struct{} // closed when a predict call is in flight
28+
startOnce sync.Once
29+
cancelled chan struct{} // closed by Cancel
30+
cancelOnce sync.Once
31+
cancelCalls atomic.Int32
32+
}
33+
34+
func newCancellableBackend(blockUntilCancel bool) *cancellableBackend {
35+
return &cancellableBackend{
36+
blockUntilCancel: blockUntilCancel,
37+
started: make(chan struct{}),
38+
cancelled: make(chan struct{}),
39+
}
40+
}
41+
42+
func (c *cancellableBackend) Cancel() {
43+
c.cancelCalls.Add(1)
44+
c.cancelOnce.Do(func() { close(c.cancelled) })
45+
}
46+
47+
func (c *cancellableBackend) run() error {
48+
c.startOnce.Do(func() { close(c.started) })
49+
if !c.blockUntilCancel {
50+
return nil
51+
}
52+
select {
53+
case <-c.cancelled:
54+
return errGenCancelled
55+
case <-time.After(30 * time.Second):
56+
// Backstop so a regression (Cancel never wired) fails the spec
57+
// instead of hanging the suite.
58+
return errors.New("cancellableBackend: Cancel never fired")
59+
}
60+
}
61+
62+
func (c *cancellableBackend) PredictRich(*pb.PredictOptions) (*pb.Reply, error) {
63+
if err := c.run(); err != nil {
64+
return nil, err
65+
}
66+
return &pb.Reply{Message: []byte("done")}, nil
67+
}
68+
69+
func (c *cancellableBackend) PredictStreamRich(_ *pb.PredictOptions, out chan<- *pb.Reply) error {
70+
out <- &pb.Reply{Message: []byte("first")}
71+
return c.run()
72+
}
73+
74+
func (c *cancellableBackend) Predict(*pb.PredictOptions) (string, error) {
75+
return "", errors.New("cancellableBackend: legacy Predict should not have been called")
76+
}
77+
78+
func (c *cancellableBackend) PredictStream(*pb.PredictOptions, chan string) error {
79+
return errors.New("cancellableBackend: legacy PredictStream should not have been called")
80+
}
81+
82+
var _ AIModelRich = (*cancellableBackend)(nil)
83+
var _ Cancellable = (*cancellableBackend)(nil)
84+
85+
var _ = Describe("Cancellable capability", func() {
86+
It("PredictStream: cancelling the request context fires Cancel and ends the stream with the backend's error", func() {
87+
backend := newCancellableBackend(true)
88+
addr := "test://cancel-stream"
89+
Provide(addr, backend)
90+
c := NewClient(addr, true, nil, false)
91+
92+
ctx, cancel := context.WithCancel(context.Background())
93+
defer cancel()
94+
95+
errCh := make(chan error, 1)
96+
go func() {
97+
defer GinkgoRecover()
98+
errCh <- c.PredictStream(ctx, &pb.PredictOptions{}, func(*pb.Reply) {})
99+
}()
100+
101+
// Only cancel once the generation is provably in flight; cancelling
102+
// earlier would race the AfterFunc registration in the server.
103+
Eventually(backend.started, "5s").Should(BeClosed())
104+
cancel()
105+
106+
var err error
107+
Eventually(errCh, "5s").Should(Receive(&err))
108+
Expect(err).To(MatchError(errGenCancelled))
109+
Expect(backend.cancelCalls.Load()).To(BeNumerically(">=", 1))
110+
})
111+
112+
It("Predict: cancelling the request context fires Cancel and unblocks the call", func() {
113+
backend := newCancellableBackend(true)
114+
addr := "test://cancel-predict"
115+
Provide(addr, backend)
116+
c := NewClient(addr, true, nil, false)
117+
118+
ctx, cancel := context.WithCancel(context.Background())
119+
defer cancel()
120+
121+
errCh := make(chan error, 1)
122+
go func() {
123+
defer GinkgoRecover()
124+
_, err := c.Predict(ctx, &pb.PredictOptions{})
125+
errCh <- err
126+
}()
127+
128+
Eventually(backend.started, "5s").Should(BeClosed())
129+
cancel()
130+
131+
var err error
132+
Eventually(errCh, "5s").Should(Receive(&err))
133+
Expect(err).To(MatchError(errGenCancelled))
134+
Expect(backend.cancelCalls.Load()).To(BeNumerically(">=", 1))
135+
})
136+
137+
It("does not call Cancel when the request completes normally", func() {
138+
backend := newCancellableBackend(false)
139+
addr := "test://cancel-clean"
140+
Provide(addr, backend)
141+
c := NewClient(addr, true, nil, false)
142+
143+
ctx, cancel := context.WithCancel(context.Background())
144+
145+
var replies []*pb.Reply
146+
err := c.PredictStream(ctx, &pb.PredictOptions{}, func(r *pb.Reply) {
147+
replies = append(replies, r)
148+
})
149+
Expect(err).ToNot(HaveOccurred())
150+
Expect(replies).To(HaveLen(1))
151+
152+
// Cancelling AFTER completion must not reach the backend: the
153+
// deferred AfterFunc stop de-registered the hook, so a shared or
154+
// reused context cannot abort someone else's later generation.
155+
cancel()
156+
Consistently(backend.cancelCalls.Load, "200ms").Should(BeZero())
157+
})
158+
})

pkg/grpc/interface.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,19 @@ func newReply(s string) *pb.Reply {
7676
return &pb.Reply{Message: []byte(s)}
7777
}
7878

79+
// Cancellable is an optional capability: backends that can abort an
80+
// in-flight generation implement it. The server calls Cancel when the
81+
// request's gRPC context is cancelled (client disconnect/timeout),
82+
// giving Go backends the same semantics the llama.cpp C++ backend gets
83+
// from polling context->IsCancelled() in its result loops.
84+
//
85+
// Cancel may be invoked from an arbitrary goroutine while the
86+
// generation is running, so implementations must make it safe to call
87+
// concurrently with Predict/PredictStream (and their rich variants).
88+
type Cancellable interface {
89+
Cancel()
90+
}
91+
7992
// AIModelRich is an optional extension to AIModel for backends that
8093
// can produce a full *pb.Reply — including tool-call deltas and
8194
// usage tokens — rather than just a content string. The gRPC server

pkg/grpc/server.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,24 @@ func (s *server) LoadModel(ctx context.Context, in *pb.ModelOptions) (*pb.Result
120120
return &pb.Result{Message: "Loading succeeded", Success: true}, nil
121121
}
122122

123+
// cancelOnDone arms the optional Cancellable capability: when ctx is
124+
// cancelled (client disconnect/timeout) the backend's Cancel fires so it
125+
// can abort the in-flight generation - the Go-backend equivalent of the
126+
// llama.cpp C++ server polling context->IsCancelled() in its result loops.
127+
// Callers MUST defer the returned stop so a normally-completed request
128+
// de-registers the hook before returning; otherwise a later cancellation
129+
// of the same ctx would abort an unrelated in-flight generation.
130+
//
131+
// Arm it AFTER the Locking() block: for serialized backends a request
132+
// queued on the lock is not generating yet, and cancelling it must not
133+
// abort whichever request currently owns the backend.
134+
func (s *server) cancelOnDone(ctx context.Context) (stop func() bool) {
135+
if c, ok := s.llm.(Cancellable); ok {
136+
return context.AfterFunc(ctx, c.Cancel)
137+
}
138+
return func() bool { return false }
139+
}
140+
123141
func (s *server) Predict(ctx context.Context, in *pb.PredictOptions) (*pb.Reply, error) {
124142
if err := s.checkModelIdentity(in); err != nil {
125143
return nil, err
@@ -128,6 +146,9 @@ func (s *server) Predict(ctx context.Context, in *pb.PredictOptions) (*pb.Reply,
128146
s.llm.Lock()
129147
defer s.llm.Unlock()
130148
}
149+
// One registration covers both the rich and the legacy branch below.
150+
stop := s.cancelOnDone(ctx)
151+
defer stop()
131152
if rich, ok := s.llm.(AIModelRich); ok {
132153
return rich.PredictRich(in)
133154
}
@@ -483,6 +504,10 @@ func (s *server) PredictStream(in *pb.PredictOptions, stream pb.Backend_PredictS
483504
defer s.llm.Unlock()
484505
}
485506

507+
// One registration covers both the rich and the legacy branch below.
508+
stop := s.cancelOnDone(stream.Context())
509+
defer stop()
510+
486511
if rich, ok := s.llm.(AIModelRich); ok {
487512
replyChan := make(chan *pb.Reply)
488513
done := make(chan bool)

0 commit comments

Comments
 (0)