Skip to content

Commit a0c8d17

Browse files
authored
Fix panic in SGLang proxy handling of concurrent requests (llm-d#632)
* Fix panic in SGLang proxy handling of concurrent requests Signed-off-by: YANG LI <yangligt@google.com> * Add concurrency unit test for SGLang context logic Signed-off-by: YANG LI <yangligt@google.com> --------- Signed-off-by: YANG LI <yangligt@google.com>
1 parent e294cfe commit a0c8d17

2 files changed

Lines changed: 189 additions & 4 deletions

File tree

pkg/sidecar/proxy/connector_sglang.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package proxy
1818

1919
import (
2020
"bytes"
21+
"context"
2122
"encoding/json"
2223
"fmt"
2324
"io"
@@ -77,8 +78,10 @@ func (s *Server) runSGLangProtocol(w http.ResponseWriter, r *http.Request, prefi
7778

7879
func (s *Server) sendSGLangConcurrentRequests(w http.ResponseWriter, r *http.Request, body []byte, prefillHost string) {
7980
// Create separate requests for prefill and decode
80-
prefillReq := cloneWithJSONBody(r, body)
81-
decodeReq := cloneWithJSONBody(r, body)
81+
// Use context.WithoutCancel for prefillReq to prevent it from being aborted
82+
// if the main HTTP handler (which serves decodeReq) finishes first.
83+
prefillReq := cloneWithJSONBody(context.WithoutCancel(r.Context()), r, body)
84+
decodeReq := cloneWithJSONBody(r.Context(), r, body)
8285

8386
prefillHandler, err := s.prefillerProxyHandler(prefillHost)
8487
if err != nil {
@@ -90,6 +93,11 @@ func (s *Server) sendSGLangConcurrentRequests(w http.ResponseWriter, r *http.Req
9093

9194
// Send prefill request asynchronously
9295
go func() {
96+
defer func() {
97+
if rec := recover(); rec != nil && rec != http.ErrAbortHandler {
98+
s.logger.Error(fmt.Errorf("panic: %v", rec), "panic in prefill request")
99+
}
100+
}()
93101
pw := &bufferedResponseWriter{}
94102
prefillHandler.ServeHTTP(pw, prefillReq)
95103
s.logger.V(5).Info("prefill request completed", "status", pw.statusCode)
@@ -99,8 +107,8 @@ func (s *Server) sendSGLangConcurrentRequests(w http.ResponseWriter, r *http.Req
99107
s.decoderProxy.ServeHTTP(w, decodeReq)
100108
}
101109

102-
func cloneWithJSONBody(r *http.Request, body []byte) *http.Request {
103-
req := r.Clone(r.Context())
110+
func cloneWithJSONBody(ctx context.Context, r *http.Request, body []byte) *http.Request {
111+
req := r.Clone(ctx)
104112
req.Body = io.NopCloser(bytes.NewReader(body))
105113
req.ContentLength = int64(len(body))
106114
return req
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/*
2+
Copyright 2025 The llm-d Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package proxy
18+
19+
import (
20+
"io"
21+
"net/http"
22+
"net/http/httptest"
23+
"net/url"
24+
"strings"
25+
"time"
26+
27+
"github.com/llm-d/llm-d-inference-scheduler/pkg/common"
28+
. "github.com/onsi/ginkgo/v2" // nolint:revive
29+
. "github.com/onsi/gomega" // nolint:revive
30+
)
31+
32+
var _ = Describe("SGLang Connector", func() {
33+
34+
var testInfo *sidecarTestInfo
35+
36+
BeforeEach(func() {
37+
// Mock testing setup using the SGLang connector mode
38+
testInfo = sidecarConnectionTestSetup(ConnectorSGLang)
39+
})
40+
41+
It("should successfully send concurrent requests to prefill and decode with bootstrap info", func() {
42+
By("starting the proxy")
43+
go func() {
44+
defer GinkgoRecover()
45+
46+
validator := &AllowlistValidator{enabled: false}
47+
err := testInfo.proxy.Start(testInfo.ctx, nil, validator)
48+
Expect(err).ToNot(HaveOccurred())
49+
50+
testInfo.stoppedCh <- struct{}{}
51+
}()
52+
53+
// Wait for proxy to start
54+
time.Sleep(1 * time.Second)
55+
Expect(testInfo.proxy.addr).ToNot(BeNil())
56+
proxyBaseAddr := "http://" + testInfo.proxy.addr.String()
57+
58+
By("sending a /v1/chat/completions request with prefill header")
59+
body := `{
60+
"model": "Qwen/Qwen2-0.5B",
61+
"messages": [
62+
{"role": "user", "content": "Hello"}
63+
],
64+
"max_tokens": 50
65+
}`
66+
67+
req, err := http.NewRequest(http.MethodPost, proxyBaseAddr+ChatCompletionsPath, strings.NewReader(body))
68+
Expect(err).ToNot(HaveOccurred())
69+
70+
prefillHostPort := testInfo.prefillBackend.URL[len("http://"):]
71+
req.Header.Add(common.PrefillPodHeader, prefillHostPort)
72+
73+
rp, err := http.DefaultClient.Do(req)
74+
Expect(err).ToNot(HaveOccurred())
75+
76+
if rp.StatusCode != 200 {
77+
bp, _ := io.ReadAll(rp.Body) //nolint:all
78+
Fail(string(bp))
79+
}
80+
81+
// Because SGLang connector sends requests concurrently (prefill in goroutine),
82+
// we sleep a tiny bit to ensure the prefill handler has time to finish processing.
83+
time.Sleep(100 * time.Millisecond)
84+
85+
// Validate prefill request
86+
Expect(testInfo.prefillHandler.RequestCount.Load()).To(BeNumerically("==", 1))
87+
Expect(testInfo.prefillHandler.CompletionRequests).To(HaveLen(1))
88+
prq1 := testInfo.prefillHandler.CompletionRequests[0]
89+
90+
// Validate decode request
91+
Expect(testInfo.decodeHandler.RequestCount.Load()).To(BeNumerically("==", 1))
92+
Expect(testInfo.decodeHandler.CompletionRequests).To(HaveLen(1))
93+
drq1 := testInfo.decodeHandler.CompletionRequests[0]
94+
95+
// Bootstrap validations for prefill
96+
Expect(prq1).To(HaveKey(requestFieldBootstrapHost))
97+
Expect(prq1).To(HaveKey(requestFieldBootstrapPort))
98+
Expect(prq1).To(HaveKey(requestFieldBootstrapRoom))
99+
100+
expectedHost := strings.Split(prefillHostPort, ":")[0]
101+
Expect(prq1[requestFieldBootstrapHost]).To(Equal(expectedHost))
102+
Expect(prq1[requestFieldBootstrapPort]).To(Equal(float64(sglangBootstrapPort)))
103+
Expect(prq1[requestFieldBootstrapRoom]).ToNot(BeNil())
104+
105+
// Bootstrap validations for decode
106+
Expect(drq1).To(HaveKey(requestFieldBootstrapHost))
107+
Expect(drq1).To(HaveKey(requestFieldBootstrapPort))
108+
Expect(drq1).To(HaveKey(requestFieldBootstrapRoom))
109+
110+
Expect(drq1[requestFieldBootstrapHost]).To(Equal(expectedHost))
111+
Expect(drq1[requestFieldBootstrapPort]).To(Equal(float64(sglangBootstrapPort)))
112+
Expect(drq1[requestFieldBootstrapRoom]).To(Equal(prq1[requestFieldBootstrapRoom])) // Room ID must match
113+
114+
testInfo.cancelFn()
115+
<-testInfo.stoppedCh
116+
})
117+
118+
It("should not panic when prefill response is slower than decode response", func() {
119+
// Stop previously injected servers
120+
testInfo.decodeBackend.Close()
121+
testInfo.prefillBackend.Close()
122+
123+
var prefillFinished bool
124+
125+
slowPrefill := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
126+
testInfo.prefillHandler.ServeHTTP(w, r)
127+
time.Sleep(300 * time.Millisecond) // Simulated load delay on KV Cache
128+
prefillFinished = true
129+
})
130+
testInfo.prefillBackend = httptest.NewServer(slowPrefill)
131+
132+
fastDecode := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
133+
testInfo.decodeHandler.ServeHTTP(w, r)
134+
})
135+
testInfo.decodeBackend = httptest.NewServer(fastDecode)
136+
testInfo.decodeURL, _ = url.Parse(testInfo.decodeBackend.URL)
137+
138+
// Re-initialize proxy to fetch the new mock addresses
139+
cfg := Config{
140+
Connector: ConnectorSGLang,
141+
}
142+
testInfo.proxy = NewProxy("0", testInfo.decodeURL, cfg)
143+
144+
go func() {
145+
defer GinkgoRecover()
146+
validator := &AllowlistValidator{enabled: false}
147+
err := testInfo.proxy.Start(testInfo.ctx, nil, validator)
148+
Expect(err).ToNot(HaveOccurred())
149+
testInfo.stoppedCh <- struct{}{}
150+
}()
151+
152+
time.Sleep(1 * time.Second)
153+
proxyBaseAddr := "http://" + testInfo.proxy.addr.String()
154+
155+
body := `{"model": "Qwen", "messages": [{"role": "user", "content": "Hello"}], "max_tokens": 50}`
156+
req, err := http.NewRequest(http.MethodPost, proxyBaseAddr+ChatCompletionsPath, strings.NewReader(body))
157+
Expect(err).ToNot(HaveOccurred())
158+
159+
prefillHostPort := testInfo.prefillBackend.URL[len("http://"):]
160+
req.Header.Add(common.PrefillPodHeader, prefillHostPort)
161+
162+
// Submit request. This will complete as soon as fastDecode completes.
163+
rp, err := http.DefaultClient.Do(req)
164+
Expect(err).ToNot(HaveOccurred())
165+
Expect(rp.StatusCode).To(Equal(200))
166+
167+
// The original panicking goroutine takes 300ms total. Give it time to attempt finishing up!
168+
time.Sleep(500 * time.Millisecond)
169+
170+
Expect(prefillFinished).To(BeTrue())
171+
Expect(testInfo.prefillHandler.RequestCount.Load()).To(BeNumerically("==", 1))
172+
Expect(testInfo.decodeHandler.RequestCount.Load()).To(BeNumerically("==", 1))
173+
174+
testInfo.cancelFn()
175+
<-testInfo.stoppedCh
176+
})
177+
})

0 commit comments

Comments
 (0)