Skip to content

Commit 9e038bf

Browse files
authored
server/debug: add detached context for debug server (#751)
1 parent d51615f commit 9e038bf

File tree

2 files changed

+125
-3
lines changed

2 files changed

+125
-3
lines changed

server/debug/server.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -569,8 +569,8 @@ func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) {
569569
http.Error(w, err.Error(), http.StatusBadRequest)
570570
return
571571
}
572-
573-
out, err := rn.Run(r.Context(), req.UserID, req.SessionID,
572+
ctx := newDetachedContext(r.Context())
573+
out, err := rn.Run(ctx, req.UserID, req.SessionID,
574574
convertContentToMessage(req.NewMessage))
575575
if err != nil {
576576
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -616,7 +616,8 @@ func (s *Server) handleRunSSE(w http.ResponseWriter, r *http.Request) {
616616
http.Error(w, err.Error(), http.StatusBadRequest)
617617
return
618618
}
619-
out, err := rn.Run(context.Background(), req.UserID, req.SessionID,
619+
ctx := newDetachedContext(r.Context())
620+
out, err := rn.Run(ctx, req.UserID, req.SessionID,
620621
convertContentToMessage(req.NewMessage))
621622
if err != nil {
622623
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -1619,3 +1620,27 @@ func isGraphToolEvent(e *event.Event) bool {
16191620
_, exists := e.StateDelta[graph.MetadataKeyTool]
16201621
return exists
16211622
}
1623+
1624+
// detachedContext wraps a parent context but disables cancellation and
1625+
// deadlines while preserving all values. This allows us to keep trace and
1626+
// logging metadata from the incoming request context without being affected
1627+
// by HTTP‑level timeouts or client disconnects.
1628+
type detachedContext struct {
1629+
context.Context
1630+
}
1631+
1632+
func (detachedContext) Deadline() (time.Time, bool) {
1633+
return time.Time{}, false
1634+
}
1635+
1636+
func (detachedContext) Done() <-chan struct{} {
1637+
return nil
1638+
}
1639+
1640+
func (detachedContext) Err() error {
1641+
return nil
1642+
}
1643+
1644+
func newDetachedContext(ctx context.Context) context.Context {
1645+
return detachedContext{Context: ctx}
1646+
}

server/debug/server_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ type fakeRunner struct {
8989
err error
9090
}
9191

92+
type ctxCapturingRunner struct {
93+
ctx context.Context
94+
}
95+
9296
func (f *fakeRunner) Run(ctx context.Context, userID string, sessionID string, message model.Message, runOpts ...agent.RunOption) (<-chan *event.Event, error) {
9397
if f.err != nil {
9498
return nil, f.err
@@ -105,6 +109,23 @@ func (f *fakeRunner) Run(ctx context.Context, userID string, sessionID string, m
105109

106110
func (f *fakeRunner) Close() error { return nil }
107111

112+
func (f *ctxCapturingRunner) Run(
113+
ctx context.Context,
114+
userID string,
115+
sessionID string,
116+
message model.Message,
117+
runOpts ...agent.RunOption,
118+
) (<-chan *event.Event, error) {
119+
f.ctx = ctx
120+
ch := make(chan *event.Event)
121+
close(ch)
122+
return ch, nil
123+
}
124+
125+
func (f *ctxCapturingRunner) Close() error {
126+
return nil
127+
}
128+
108129
type flushRecorder struct {
109130
*httptest.ResponseRecorder
110131
}
@@ -1435,6 +1456,82 @@ func TestHandleRunSSE_NonStreaming(t *testing.T) {
14351456
assert.Contains(t, w.Body.String(), "data: ")
14361457
}
14371458

1459+
func TestNewDetachedContextPreservesValues(t *testing.T) {
1460+
type ctxKey struct{}
1461+
key := ctxKey{}
1462+
parent, cancel := context.WithCancel(
1463+
context.WithValue(context.Background(), key, "trace-id"),
1464+
)
1465+
cancel()
1466+
1467+
ctx := newDetachedContext(parent)
1468+
1469+
assert.Equal(t, "trace-id", ctx.Value(key))
1470+
_, ok := ctx.Deadline()
1471+
assert.False(t, ok)
1472+
assert.Nil(t, ctx.Done())
1473+
assert.Nil(t, ctx.Err())
1474+
1475+
err := agent.CheckContextCancelled(ctx)
1476+
assert.NoError(t, err)
1477+
}
1478+
1479+
func TestHandleRunSSE_UsesDetachedContext(t *testing.T) {
1480+
type ctxKey struct{}
1481+
key := ctxKey{}
1482+
1483+
ctxRunner := &ctxCapturingRunner{}
1484+
server := &Server{
1485+
agents: map[string]agent.Agent{},
1486+
router: mux.NewRouter(),
1487+
runners: map[string]runner.Runner{"app": ctxRunner},
1488+
sessionSvc: sessioninmemory.NewSessionService(),
1489+
traces: map[string]attribute.Set{},
1490+
1491+
memoryExporter: newInMemoryExporter(),
1492+
}
1493+
1494+
reqBody := schema.AgentRunRequest{
1495+
AppName: "app",
1496+
UserID: "user",
1497+
SessionID: "sess",
1498+
NewMessage: schema.Content{
1499+
Role: "user",
1500+
Parts: []schema.Part{{Text: "hi"}},
1501+
},
1502+
Streaming: true,
1503+
}
1504+
body, err := json.Marshal(reqBody)
1505+
require.NoError(t, err)
1506+
1507+
baseCtx, cancel := context.WithCancel(
1508+
context.WithValue(context.Background(), key, "trace-id"),
1509+
)
1510+
cancel()
1511+
1512+
req := httptest.NewRequest(
1513+
http.MethodPost,
1514+
"/run_sse",
1515+
bytes.NewReader(body),
1516+
).WithContext(baseCtx)
1517+
req.Header.Set("Content-Type", "application/json")
1518+
w := &flushRecorder{ResponseRecorder: httptest.NewRecorder()}
1519+
1520+
server.handleRunSSE(w, req)
1521+
1522+
assert.Equal(t, http.StatusOK, w.Code)
1523+
if assert.NotNil(t, ctxRunner.ctx) {
1524+
assert.Equal(t, "trace-id", ctxRunner.ctx.Value(key))
1525+
_, ok := ctxRunner.ctx.Deadline()
1526+
assert.False(t, ok)
1527+
assert.Nil(t, ctxRunner.ctx.Done())
1528+
assert.Nil(t, ctxRunner.ctx.Err())
1529+
1530+
err = agent.CheckContextCancelled(ctxRunner.ctx)
1531+
assert.NoError(t, err)
1532+
}
1533+
}
1534+
14381535
func TestServerGetRunnerCache(t *testing.T) {
14391536
server := New(map[string]agent.Agent{
14401537
"app": &mockAgent{name: "app"},

0 commit comments

Comments
 (0)