Skip to content

Commit 6b68f48

Browse files
xgopilotpionxe
andcommitted
fix(tui): harden gateway runtime adapter handshake and event delivery
Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com>
1 parent 8de41b4 commit 6b68f48

8 files changed

Lines changed: 189 additions & 45 deletions

internal/cli/gateway_runtime_bridge.go

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -414,15 +414,7 @@ func isRuntimeNotFoundError(err error) bool {
414414
if err == nil {
415415
return false
416416
}
417-
if errors.Is(err, os.ErrNotExist) {
418-
return true
419-
}
420-
normalized := strings.ToLower(strings.TrimSpace(err.Error()))
421-
return strings.Contains(normalized, "not found") ||
422-
strings.Contains(normalized, "file does not exist") ||
423-
strings.Contains(normalized, "does not exist") ||
424-
strings.Contains(normalized, "no such file") ||
425-
strings.Contains(normalized, "no such file or directory")
417+
return errors.Is(err, agentsession.ErrSessionNotFound) || errors.Is(err, os.ErrNotExist)
426418
}
427419

428420
var _ gateway.RuntimePort = (*gatewayRuntimePortBridge)(nil)

internal/cli/gateway_runtime_bridge_test.go

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ func TestGatewayRuntimePortBridgeLoadSessionNotFoundBranches(t *testing.T) {
351351
t.Parallel()
352352

353353
base := &runtimeStub{
354-
loadErr: errors.New("session not found"),
354+
loadErr: agentsession.ErrSessionNotFound,
355355
}
356356
bridgeWithoutCreator, err := newGatewayRuntimePortBridge(context.Background(), &runtimeWithoutCreator{base: base})
357357
if err != nil {
@@ -367,7 +367,7 @@ func TestGatewayRuntimePortBridgeLoadSessionNotFoundBranches(t *testing.T) {
367367
}
368368

369369
stub := &runtimeStub{
370-
loadErr: errors.New("file does not exist"),
370+
loadErr: os.ErrNotExist,
371371
createErr: errors.New("create failed"),
372372
}
373373
bridgeWithCreator, err := newGatewayRuntimePortBridge(context.Background(), stub)
@@ -390,6 +390,12 @@ func TestIsRuntimeNotFoundErrorIncludesOSErrNotExist(t *testing.T) {
390390
if !isRuntimeNotFoundError(os.ErrNotExist) {
391391
t.Fatalf("os.ErrNotExist should be treated as runtime not found")
392392
}
393+
if !isRuntimeNotFoundError(agentsession.ErrSessionNotFound) {
394+
t.Fatalf("ErrSessionNotFound should be treated as runtime not found")
395+
}
396+
if isRuntimeNotFoundError(errors.New("session not found")) {
397+
t.Fatalf("plain string not-found error should not be treated as runtime not found")
398+
}
393399
}
394400

395401
func TestGatewayRuntimePortBridgeRuntimeMethodErrors(t *testing.T) {
@@ -431,7 +437,7 @@ func TestGatewayRuntimePortBridgeRuntimeMethodErrors(t *testing.T) {
431437
func TestGatewayRuntimePortBridgeLoadSessionUpsertWhenMissing(t *testing.T) {
432438
now := time.Now()
433439
stub := &runtimeStub{
434-
loadErr: errors.New("file does not exist"),
440+
loadErr: agentsession.ErrSessionNotFound,
435441
createSession: agentsession.Session{
436442
ID: "session-new",
437443
Title: "New Session",
@@ -464,7 +470,7 @@ func TestGatewayRuntimePortBridgeLoadSessionUpsertWhenMissing(t *testing.T) {
464470
}
465471
}
466472

467-
func TestGatewayRuntimePortBridgeLoadSessionUpsertWhenMissingNoSuchFile(t *testing.T) {
473+
func TestGatewayRuntimePortBridgeLoadSessionNoUpsertOnPlainStringNotFoundError(t *testing.T) {
468474
now := time.Now()
469475
stub := &runtimeStub{
470476
loadErr: errors.New("open sessions/session-new.json: no such file"),
@@ -482,18 +488,15 @@ func TestGatewayRuntimePortBridgeLoadSessionUpsertWhenMissingNoSuchFile(t *testi
482488
}
483489
defer bridge.Close()
484490

485-
session, err := bridge.LoadSession(context.Background(), gateway.LoadSessionInput{
491+
_, err = bridge.LoadSession(context.Background(), gateway.LoadSessionInput{
486492
SubjectID: testBridgeSubjectID,
487493
SessionID: " session-new ",
488494
})
489-
if err != nil {
490-
t.Fatalf("load_session upsert no such file: %v", err)
491-
}
492-
if stub.createID != "session-new" {
493-
t.Fatalf("create id = %q, want %q", stub.createID, "session-new")
495+
if err == nil || err.Error() != "open sessions/session-new.json: no such file" {
496+
t.Fatalf("expected original string error passthrough, got %v", err)
494497
}
495-
if session.ID != "session-new" {
496-
t.Fatalf("upsert session id = %q, want %q", session.ID, "session-new")
498+
if stub.createID != "" {
499+
t.Fatalf("create should not be called for plain string error, got createID=%q", stub.createID)
497500
}
498501
}
499502

internal/tui/services/gateway_rpc_client.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"encoding/json"
66
"errors"
77
"fmt"
8-
"log"
98
"net"
109
"strings"
1110
"sync"
@@ -413,14 +412,12 @@ func (c *GatewayRPCClient) startNotificationDispatcher() {
413412
})
414413
}
415414

416-
// enqueueNotification 以无阻塞方式投递通知;队列满时丢弃并记录告警,避免反压读循环
415+
// enqueueNotification 以阻塞方式投递通知,确保 gateway.event 不会因队列满被静默丢弃
417416
func (c *GatewayRPCClient) enqueueNotification(notification gatewayRPCNotification) {
418417
select {
419418
case <-c.closed:
420419
return
421420
case c.notificationQueue <- notification:
422-
default:
423-
log.Printf("gateway rpc client: drop notification method=%q reason=queue_full", notification.Method)
424421
}
425422
}
426423

internal/tui/services/gateway_rpc_client_additional_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net"
1010
"os"
1111
"path/filepath"
12+
"strconv"
1213
"strings"
1314
"sync"
1415
"testing"
@@ -456,6 +457,59 @@ func TestGatewayRPCClientNotificationDispatcherStopsOnQueueClose(t *testing.T) {
456457
client.notificationWG.Wait()
457458
}
458459

460+
func TestGatewayRPCClientEnqueueNotificationDoesNotDropUnderQueuePressure(t *testing.T) {
461+
t.Parallel()
462+
463+
const total = 256
464+
client := &GatewayRPCClient{
465+
closed: make(chan struct{}),
466+
pending: make(map[string]chan gatewayRPCResponse),
467+
notifications: make(chan gatewayRPCNotification, 1),
468+
notificationQueue: make(chan gatewayRPCNotification, 1),
469+
}
470+
client.startNotificationDispatcher()
471+
t.Cleanup(func() { _ = client.Close() })
472+
473+
receivedCh := make(chan struct{}, total)
474+
go func() {
475+
for range client.Notifications() {
476+
receivedCh <- struct{}{}
477+
}
478+
}()
479+
480+
var enqueueWG sync.WaitGroup
481+
for i := 0; i < total; i++ {
482+
enqueueWG.Add(1)
483+
go func(index int) {
484+
defer enqueueWG.Done()
485+
client.enqueueNotification(gatewayRPCNotification{
486+
Method: protocol.MethodGatewayEvent,
487+
Params: json.RawMessage(`{"index":` + strconv.Itoa(index) + `}`),
488+
})
489+
}(i)
490+
}
491+
492+
waitDone := make(chan struct{})
493+
go func() {
494+
enqueueWG.Wait()
495+
close(waitDone)
496+
}()
497+
498+
select {
499+
case <-waitDone:
500+
case <-time.After(5 * time.Second):
501+
t.Fatalf("enqueue notifications timed out under queue pressure")
502+
}
503+
504+
for i := 0; i < total; i++ {
505+
select {
506+
case <-receivedCh:
507+
case <-time.After(5 * time.Second):
508+
t.Fatalf("expected %d notifications, got %d", total, i)
509+
}
510+
}
511+
}
512+
459513
func TestGatewayRPCClientWriteRequestFailure(t *testing.T) {
460514
t.Parallel()
461515

internal/tui/services/gateway_rpc_client_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ func TestGatewayRPCClientCallWithEmptyMethodReturnsError(t *testing.T) {
219219
}
220220
}
221221

222-
func TestGatewayRPCClientReadLoopDoesNotBlockOnNotifications(t *testing.T) {
222+
func TestGatewayRPCClientReadLoopSustainsBackpressureWhenNotificationsAreConsumed(t *testing.T) {
223223
tokenFile, _ := createTestAuthTokenFile(t)
224224

225225
client, err := NewGatewayRPCClient(GatewayRPCClientOptions{
@@ -257,6 +257,11 @@ func TestGatewayRPCClientReadLoopDoesNotBlockOnNotifications(t *testing.T) {
257257
}
258258
t.Cleanup(func() { _ = client.Close() })
259259

260+
go func() {
261+
for range client.Notifications() {
262+
}
263+
}()
264+
260265
callErr := client.CallWithOptions(
261266
context.Background(),
262267
protocol.MethodGatewayPing,

internal/tui/services/remote_runtime_adapter.go

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,19 +71,20 @@ func NewRemoteRuntimeAdapter(options RemoteRuntimeAdapterOptions) (*RemoteRuntim
7171
if timeout <= 0 {
7272
timeout = defaultRemoteRuntimeTimeout
7373
}
74+
retryCount := normalizeRemoteRuntimeRetryCount(options.RetryCount)
7475

7576
rpcClient, err := newGatewayRPCClientFactory(GatewayRPCClientOptions{
7677
ListenAddress: strings.TrimSpace(options.ListenAddress),
7778
TokenFile: strings.TrimSpace(options.TokenFile),
7879
RequestTimeout: timeout,
79-
RetryCount: options.RetryCount,
80+
RetryCount: retryCount,
8081
})
8182
if err != nil {
8283
return nil, err
8384
}
8485

8586
streamClient := newGatewayStreamClientFactory(rpcClient.Notifications())
86-
adapter := newRemoteRuntimeAdapterWithClients(rpcClient, streamClient, timeout, options.RetryCount)
87+
adapter := newRemoteRuntimeAdapterWithClients(rpcClient, streamClient, timeout, retryCount)
8788

8889
ctx, cancel := context.WithTimeout(context.Background(), timeout)
8990
defer cancel()
@@ -100,6 +101,7 @@ func newRemoteRuntimeAdapterWithClients(
100101
timeout time.Duration,
101102
retryCount int,
102103
) *RemoteRuntimeAdapter {
104+
retryCount = normalizeRemoteRuntimeRetryCount(retryCount)
103105
adapter := &RemoteRuntimeAdapter{
104106
rpcClient: rpcClient,
105107
streamClient: streamClient,
@@ -113,7 +115,7 @@ func newRemoteRuntimeAdapterWithClients(
113115
return adapter
114116
}
115117

116-
// Submit 将用户输入提交到网关:先 authenticate,再 loadSession 预热,随后 bindStream,最后 run。
118+
// Submit 将用户输入提交到网关:先 authenticate,再 bindStream,随后 loadSession,最后 run。
117119
func (r *RemoteRuntimeAdapter) Submit(ctx context.Context, input agentruntime.PrepareInput) error {
118120
sessionID := strings.TrimSpace(input.SessionID)
119121
if sessionID == "" {
@@ -127,10 +129,10 @@ func (r *RemoteRuntimeAdapter) Submit(ctx context.Context, input agentruntime.Pr
127129
if err := r.authenticate(ctx); err != nil {
128130
return err
129131
}
130-
if err := r.preloadSession(ctx, sessionID); err != nil {
132+
if err := r.bindStream(ctx, sessionID, runID); err != nil {
131133
return err
132134
}
133-
if err := r.bindStream(ctx, sessionID, runID); err != nil {
135+
if err := r.preloadSession(ctx, sessionID); err != nil {
134136
return err
135137
}
136138

@@ -495,11 +497,23 @@ func (r *RemoteRuntimeAdapter) setActiveRun(runID string, sessionID string) {
495497
func (r *RemoteRuntimeAdapter) clearActiveRun(runID string) {
496498
r.activeMu.Lock()
497499
defer r.activeMu.Unlock()
498-
if strings.TrimSpace(runID) == "" || strings.EqualFold(strings.TrimSpace(runID), strings.TrimSpace(r.activeRunID)) {
500+
normalizedRunID := strings.TrimSpace(runID)
501+
if normalizedRunID == "" {
502+
return
503+
}
504+
if strings.EqualFold(normalizedRunID, strings.TrimSpace(r.activeRunID)) {
499505
r.activeRunID = ""
500506
}
501507
}
502508

509+
// normalizeRemoteRuntimeRetryCount 统一归一化重试次数,避免零值关闭默认重试兜底。
510+
func normalizeRemoteRuntimeRetryCount(retryCount int) int {
511+
if retryCount <= 0 {
512+
return defaultGatewayRPCRetryCount
513+
}
514+
return retryCount
515+
}
516+
503517
func (r *RemoteRuntimeAdapter) activeRun() (string, string) {
504518
r.activeMu.Lock()
505519
defer r.activeMu.Unlock()

internal/tui/services/remote_runtime_adapter_additional_test.go

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,17 @@ func TestNewRemoteRuntimeAdapterBranches(t *testing.T) {
7272
adapter, err := NewRemoteRuntimeAdapter(RemoteRuntimeAdapterOptions{
7373
ListenAddress: "ok",
7474
RequestTimeout: -1,
75+
RetryCount: 0,
7576
})
7677
if err != nil {
7778
t.Fatalf("NewRemoteRuntimeAdapter() error = %v", err)
7879
}
7980
if adapter.timeout != defaultRemoteRuntimeTimeout {
8081
t.Fatalf("timeout = %v, want %v", adapter.timeout, defaultRemoteRuntimeTimeout)
8182
}
83+
if adapter.retryCount != defaultGatewayRPCRetryCount {
84+
t.Fatalf("retryCount = %d, want %d", adapter.retryCount, defaultGatewayRPCRetryCount)
85+
}
8286
_ = adapter.Close()
8387
}
8488

@@ -304,6 +308,62 @@ func TestRemoteRuntimeAdapterEventObservationAndActiveRunState(t *testing.T) {
304308
if runID != "" {
305309
t.Fatalf("expected cleared run id, got %q", runID)
306310
}
311+
312+
adapter.setActiveRun("run-c", "session-c")
313+
adapter.observeEvent(agentruntime.RuntimeEvent{Type: agentruntime.EventError})
314+
runID, sessionID = adapter.activeRun()
315+
if runID != "run-c" || sessionID != "session-c" {
316+
t.Fatalf("event error without run id should not clear active run, got run=%q session=%q", runID, sessionID)
317+
}
318+
}
319+
320+
func TestNewRemoteRuntimeAdapterWithClientsNormalizesRetryCount(t *testing.T) {
321+
t.Parallel()
322+
323+
adapter := newRemoteRuntimeAdapterWithClients(
324+
&stubRemoteRPCClient{notifications: make(chan gatewayRPCNotification)},
325+
&stubRemoteStreamClient{events: make(chan agentruntime.RuntimeEvent)},
326+
time.Second,
327+
0,
328+
)
329+
t.Cleanup(func() { _ = adapter.Close() })
330+
331+
if adapter.retryCount != defaultGatewayRPCRetryCount {
332+
t.Fatalf("retryCount = %d, want %d", adapter.retryCount, defaultGatewayRPCRetryCount)
333+
}
334+
}
335+
336+
func TestRemoteRuntimeAdapterUsesDefaultRetryWhenOptionsZero(t *testing.T) {
337+
t.Parallel()
338+
339+
rpcClient := &stubRemoteRPCClient{
340+
frames: map[string]gateway.MessageFrame{
341+
protocol.MethodGatewayListSessions: {
342+
Type: gateway.FrameTypeAck,
343+
Action: gateway.FrameActionListSessions,
344+
Payload: map[string]any{"sessions": []gateway.SessionSummary{}},
345+
},
346+
},
347+
notifications: make(chan gatewayRPCNotification),
348+
}
349+
adapter := newRemoteRuntimeAdapterWithClients(
350+
rpcClient,
351+
&stubRemoteStreamClient{events: make(chan agentruntime.RuntimeEvent)},
352+
time.Second,
353+
0,
354+
)
355+
t.Cleanup(func() { _ = adapter.Close() })
356+
357+
if _, err := adapter.ListSessions(context.Background()); err != nil {
358+
t.Fatalf("ListSessions() error = %v", err)
359+
}
360+
options, ok := rpcClient.snapshotOptions()[protocol.MethodGatewayListSessions]
361+
if !ok {
362+
t.Fatalf("expected listSessions call options to be captured")
363+
}
364+
if options.Retries != defaultGatewayRPCRetryCount {
365+
t.Fatalf("listSessions retries = %d, want %d", options.Retries, defaultGatewayRPCRetryCount)
366+
}
307367
}
308368

309369
func TestRemoteRuntimeAdapterLoadSessionAndCancelErrorPaths(t *testing.T) {
@@ -347,9 +407,13 @@ func TestRemoteRuntimeAdapterSubmitAndCompactErrorPaths(t *testing.T) {
347407
if err := adapter.Submit(context.Background(), agentruntime.PrepareInput{}); err == nil || !strings.Contains(err.Error(), "bind failed") {
348408
t.Fatalf("expected bind failed submit error, got %v", err)
349409
}
350-
params := rpcClient.snapshotParams()[protocol.MethodGatewayLoadSession].(protocol.LoadSessionParams)
351-
if strings.TrimSpace(params.SessionID) == "" {
352-
t.Fatalf("Submit() should generate default session id")
410+
methods := rpcClient.snapshotMethods()
411+
if len(methods) != 1 || methods[0] != protocol.MethodGatewayBindStream {
412+
t.Fatalf("Submit() should fail after bindStream and before loadSession, methods=%#v", methods)
413+
}
414+
bindParams, ok := rpcClient.snapshotParams()[protocol.MethodGatewayBindStream].(protocol.BindStreamParams)
415+
if !ok || strings.TrimSpace(bindParams.SessionID) == "" {
416+
t.Fatalf("Submit() should generate default session id for bindStream, params=%#v", rpcClient.snapshotParams()[protocol.MethodGatewayBindStream])
353417
}
354418

355419
rpcClient.authErr = errors.New("auth failed")

0 commit comments

Comments
 (0)