diff --git a/backend/internal/service/openai_live.go b/backend/internal/service/openai_live.go index 3687c0c44..413d288aa 100644 --- a/backend/internal/service/openai_live.go +++ b/backend/internal/service/openai_live.go @@ -554,18 +554,26 @@ func (s *OpenAIGatewayService) ProxyLiveSideband( runErr := s.runLiveController(proxyCtx, record, upstream, errCh) cancel() _, _ = store.ReleaseLiveController(context.Background(), record.CallHash, owner) - if errors.Is(runErr, ErrLiveCallNotFound) { + if liveSessionEnded(runErr) || !time.Now().Before(record.ExpiresAt) { s.finalizeLiveCall(record) return runErr } - if !errors.Is(runErr, context.DeadlineExceeded) && time.Now().Before(record.ExpiresAt) { - go s.observeLiveCall(record.CallHash) - return runErr - } - s.finalizeLiveCall(record) + go s.observeLiveCall(record.CallHash) return runErr } +// liveSessionEnded 判断控制连接的退出原因是否意味着会话已终结(应 finalize:写 +// usage log 并释放租约),而不是可以交给 observer 重连的临时错误。 +// +// ErrLiveUnavailable 在控制循环里只会来自租约续租失败。RefreshLiveLease 的 Lua 在 +// leaseID 被 GC 后不会重新写入,重连也拿不回并发槽 —— 若按临时错误重试,会话会以 +// 约 1 秒一轮的节奏空转到 ExpiresAt,期间持着上游连接却不计入任何并发限制。 +func liveSessionEnded(err error) bool { + return errors.Is(err, ErrLiveCallNotFound) || + errors.Is(err, ErrLiveUnavailable) || + errors.Is(err, context.DeadlineExceeded) +} + func (s *OpenAIGatewayService) runLiveController( ctx context.Context, record *LiveCallRecord, @@ -626,7 +634,7 @@ func (s *OpenAIGatewayService) observeLiveCall(callHash string) { if errors.Is(runErr, ErrLiveControllerChanged) { return } - if errors.Is(runErr, context.DeadlineExceeded) || errors.Is(runErr, ErrLiveCallNotFound) { + if liveSessionEnded(runErr) { s.finalizeLiveCall(record) return } @@ -706,7 +714,9 @@ func (s *OpenAIGatewayService) waitForLiveObserverRetry(record *LiveCallRecord) return false } controller, err := store.GetLiveController(context.Background(), record.CallHash) - return err == nil && controller == LiveControllerObserver && time.Now().Before(record.ExpiresAt) + // 过期不在此处判定:返回 true 让调用方回到循环顶部的过期分支,由它 finalize + // (写 usage log + 释放租约)。在这里直接返回 false 会让会话静默结束、不留记录。 + return err == nil && controller == LiveControllerObserver } func (s *OpenAIGatewayService) refreshLiveLease(record *LiveCallRecord) bool { diff --git a/backend/internal/service/openai_live_lifecycle_test.go b/backend/internal/service/openai_live_lifecycle_test.go index 59571cf3c..7ad2c1b0f 100644 --- a/backend/internal/service/openai_live_lifecycle_test.go +++ b/backend/internal/service/openai_live_lifecycle_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "strings" @@ -415,3 +416,54 @@ func TestProxyLiveSidebandForwardsTextAndBinary(t *testing.T) { upstream.reads <- liveTestFrame{err: coderws.CloseError{Code: coderws.StatusNormalClosure}} require.ErrorIs(t, <-proxyResult, ErrLiveCallNotFound) } + +// TestLiveSessionEndedTreatsLeaseLossAsTerminal 锁定:租约续租失败(ErrLiveUnavailable) +// 必须判为会话终结。RefreshLiveLease 的 Lua 在 leaseID 被 GC 后不会重新写入,若把它 +// 当临时错误交给 observer 重连,会话会空转到 ExpiresAt 且不计入任何并发限制。 +func TestLiveSessionEndedTreatsLeaseLossAsTerminal(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"租约丢失", ErrLiveUnavailable, true}, + {"租约丢失(被包装)", fmt.Errorf("refresh live lease: %w", ErrLiveUnavailable), true}, + {"上游报告会话已关闭", ErrLiveCallNotFound, true}, + {"到达会话时长上限", context.DeadlineExceeded, true}, + {"控制权被他人接管", ErrLiveControllerChanged, false}, + {"临时读错误", errors.New("unexpected EOF"), false}, + {"无错误", nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, liveSessionEnded(tc.err)) + }) + } +} + +// TestWaitForLiveObserverRetryLeavesExpiryToLoopFinalize 锁定:已过期但控制权仍在 +// observer 手上时返回 true,让调用方回到 observeLiveCall 循环顶部的过期分支去 +// finalize(写 usage log + 释放租约)。在此处直接返回 false 会让会话静默结束、不留记录。 +func TestWaitForLiveObserverRetryLeavesExpiryToLoopFinalize(t *testing.T) { + record := &LiveCallRecord{ + CallID: "call_expired", + CallHash: hashLiveCallID("call_expired"), + Controller: LiveControllerObserver, + ExpiresAt: time.Now().Add(-time.Minute), + } + store := &liveTestStore{} + require.NoError(t, store.SaveLiveCall(context.Background(), record, time.Hour)) + svc := &OpenAIGatewayService{cache: store} + + require.True(t, svc.waitForLiveObserverRetry(record), + "过期判定必须留给循环顶部,否则不会写 usage log") + + // 控制权已被他人接管时仍必须停止重试,避免与新控制者抢同一个 call。 + require.NoError(t, store.SaveLiveCall(context.Background(), &LiveCallRecord{ + CallID: record.CallID, + CallHash: record.CallHash, + Controller: LiveControllerProxy, + ExpiresAt: time.Now().Add(time.Hour), + }, time.Hour)) + require.False(t, svc.waitForLiveObserverRetry(record)) +} diff --git a/backend/migrations/186_allow_live_usage_request_type.sql b/backend/migrations/188_allow_live_usage_request_type.sql similarity index 100% rename from backend/migrations/186_allow_live_usage_request_type.sql rename to backend/migrations/188_allow_live_usage_request_type.sql diff --git a/backend/migrations/187_add_group_allow_live.sql b/backend/migrations/189_add_group_allow_live.sql similarity index 100% rename from backend/migrations/187_add_group_allow_live.sql rename to backend/migrations/189_add_group_allow_live.sql