fix(phone): sync confirmed call timeout handling

This commit is contained in:
iamxvbaba 2026-07-26 15:59:20 +08:00
parent 7ce15f68ab
commit c3c079edf3
9 changed files with 152 additions and 27 deletions

View file

@ -46,28 +46,27 @@ func newRegistry() *registry {
}
}
// sweepLocked 是 P1 的纯年龄 GC(调用方持有 r.mu):
// - 终态 tombstone 超过 tombstoneTTL → 回收(密钥材料随之销毁);
// - 非终态超过 2×ringTimeout → 直接回收(双端同时崩溃的兜底,防僵尸通话
// 吃满并发上限;不推送、不落历史,正常超时由客户端定时器与 P2 dispatcher 处理)。
func (r *registry) sweepLocked(nowUnix int64, ringTimeoutSec, tombstoneTTLSec int64) {
// sweepTombstonesLocked 只回收超过保留期的终态 tombstone(调用方持有 r.mu)。
//
// 非终态绝不能在 GC 中按 Date 直接删除:Requested/Ringing/Accepted 的超时必须由
// Service.ExpireDue 完成状态迁移、双端推送和历史落库;Confirmed 没有服务端时长
// 上限,必须一直可供 signaling/discard 寻址,直到显式挂断或进程重启。
func (r *registry) sweepTombstonesLocked(nowUnix, tombstoneTTLSec int64) {
for id, e := range r.byID {
switch {
case e.call.Terminal():
if nowUnix-int64(e.call.DiscardedAt) > tombstoneTTLSec {
r.removeLocked(id, e, false)
}
default:
if nowUnix-int64(e.call.Date) > 2*ringTimeoutSec {
r.removeLocked(id, e, true)
}
if e.call.Terminal() && nowUnix-int64(e.call.DiscardedAt) > tombstoneTTLSec {
r.removeLocked(id, e, false)
}
}
}
func (r *registry) removeLocked(id int64, e *entry, wasActive bool) {
delete(r.byID, id)
delete(r.byRandom, randomKey{callerID: e.call.AdminID, randomID: e.call.RandomID})
key := randomKey{callerID: e.call.AdminID, randomID: e.call.RandomID}
// 终态后允许客户端复用 random_id 创建新通话;旧 tombstone 到期时不能
// 把已指向新 call 的幂等索引一并删掉。
if indexedID, ok := r.byRandom[key]; ok && indexedID == id {
delete(r.byRandom, key)
}
if wasActive {
r.decActiveLocked(e.call.AdminID)
}

View file

@ -45,6 +45,9 @@ type Config struct {
TombstoneTTL time.Duration
// MaxActivePerUser 是单用户并发非终态通话上限(防呼叫轰炸自锁)。
MaxActivePerUser int
// MaxRegistryEntries 是进程内 registry 的硬上限。达到上限时拒绝新通话,
// 不驱逐可能仍在进行的 Confirmed 通话。
MaxRegistryEntries int
// SignalingRatePerSecond 是单通话每秒信令转发上限;超限静默丢弃(不破坏客户端状态机)。
SignalingRatePerSecond int
}
@ -59,6 +62,9 @@ func (c Config) withDefaults() Config {
if c.MaxActivePerUser <= 0 {
c.MaxActivePerUser = 4
}
if c.MaxRegistryEntries <= 0 {
c.MaxRegistryEntries = 10_000
}
if c.SignalingRatePerSecond <= 0 {
c.SignalingRatePerSecond = 50
}
@ -103,7 +109,7 @@ func (s *Service) RequestCall(ctx context.Context, callerID int64, in domain.Pho
s.reg.mu.Lock()
defer s.reg.mu.Unlock()
s.reg.sweepLocked(nowUnix, int64(s.cfg.RingTimeout/time.Second), int64(s.cfg.TombstoneTTL/time.Second))
s.reg.sweepTombstonesLocked(nowUnix, int64(s.cfg.TombstoneTTL/time.Second))
// 幂等:同一 (callerID, randomID) 的未终结通话直接返回快照,吸收客户端重试。
key := randomKey{callerID: callerID, randomID: in.RandomID}
@ -112,6 +118,9 @@ func (s *Service) RequestCall(ctx context.Context, callerID int64, in domain.Pho
return e.call, nil
}
}
if len(s.reg.byID) >= s.cfg.MaxRegistryEntries {
return domain.PhoneCall{}, ErrOccupyFailed
}
if s.reg.active[callerID] >= s.cfg.MaxActivePerUser {
return domain.PhoneCall{}, ErrOccupyFailed
}
@ -306,7 +315,7 @@ func (s *Service) ExpireDue(ctx context.Context, now time.Time) []domain.PhoneCa
s.reg.markDiscardedLocked(e, reason, 0, int(nowUnix))
expired = append(expired, e.call)
}
s.reg.sweepLocked(nowUnix, ringSec, int64(s.cfg.TombstoneTTL/time.Second))
s.reg.sweepTombstonesLocked(nowUnix, int64(s.cfg.TombstoneTTL/time.Second))
return expired
}

View file

@ -72,6 +72,7 @@ func newTestService(clk clock.Clock, mutate ...func(*Config)) *Service {
RingTimeout: 90 * time.Second,
TombstoneTTL: 60 * time.Second,
MaxActivePerUser: 4,
MaxRegistryEntries: 10_000,
SignalingRatePerSecond: 50,
}
for _, fn := range mutate {
@ -265,9 +266,15 @@ func TestPhoneCallRandomIDIdempotent(t *testing.T) {
if err != nil || third.ID == first.ID {
t.Fatalf("post-discard request id = %d err=%v, want fresh call", third.ID, err)
}
// 旧 tombstone 到期回收时,不得误删已改指向新 call 的 random_id 索引。
clk.Advance(61 * time.Second)
retry, err := s.RequestCall(ctx, 1, req)
if err != nil || retry.ID != third.ID {
t.Fatalf("retry after old tombstone GC id = %d err=%v, want %d", retry.ID, err, third.ID)
}
}
func TestPhoneCallQuotaAndSweep(t *testing.T) {
func TestPhoneCallQuotaAndExpiry(t *testing.T) {
clk := newTestClock()
s := newTestService(clk, func(c *Config) { c.MaxActivePerUser = 2 })
ctx := context.Background()
@ -281,10 +288,53 @@ func TestPhoneCallQuotaAndSweep(t *testing.T) {
if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 99, RandomID: 99, GAHash: gaHash, Protocol: testProtocol()}); !errors.Is(err, ErrOccupyFailed) {
t.Fatalf("over quota err = %v, want ErrOccupyFailed", err)
}
// 双端崩溃兜底:超过 2×RingTimeout 的僵尸通话被纯年龄 GC 回收,配额释放。
clk.Advance(181 * time.Second)
// 未建立通话只能由 ExpireDue 迁入终态,确保 dispatcher 能推送并落历史;
// registry GC 不得静默删除 active call。
clk.Advance(91 * time.Second)
expired := s.ExpireDue(ctx, clk.Now())
if len(expired) != 2 {
t.Fatalf("expired = %d, want 2", len(expired))
}
if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 99, RandomID: 99, GAHash: gaHash, Protocol: testProtocol()}); err != nil {
t.Fatalf("request after sweep: %v", err)
t.Fatalf("request after expiry: %v", err)
}
}
func TestPhoneCallRegistryCapacityDoesNotEvictConfirmedCall(t *testing.T) {
clk := newTestClock()
s := newTestService(clk, func(c *Config) { c.MaxRegistryEntries = 1 })
ctx := context.Background()
ga, gaHash := testGA()
confirmed := mustRequest(t, s, 1, 2, gaHash)
if _, err := s.AcceptCall(ctx, 2, confirmed.ID, confirmed.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil {
t.Fatalf("accept: %v", err)
}
if _, _, err := s.ConfirmCall(ctx, 1, confirmed.ID, confirmed.AccessHash, ga, 1, testProtocol()); err != nil {
t.Fatalf("confirm: %v", err)
}
clk.Advance(365 * 24 * time.Hour)
if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 {
t.Fatalf("confirmed call expired after one year: %+v", got)
}
if _, err := s.RequestCall(ctx, 3, domain.PhoneCallRequest{
CalleeID: 4, RandomID: 2, GAHash: gaHash, Protocol: testProtocol(),
}); !errors.Is(err, ErrOccupyFailed) {
t.Fatalf("request at registry capacity err = %v, want ErrOccupyFailed", err)
}
if snap, ok := s.Lookup(ctx, confirmed.ID, confirmed.AccessHash); !ok || snap.State != domain.PhoneCallStateConfirmed {
t.Fatalf("confirmed call = %+v ok=%v, want preserved", snap, ok)
}
if _, _, err := s.DiscardCall(ctx, 1, confirmed.ID, confirmed.AccessHash, domain.PhoneCallDiscardReasonHangup, 1); err != nil {
t.Fatalf("discard: %v", err)
}
clk.Advance(61 * time.Second)
if _, err := s.RequestCall(ctx, 3, domain.PhoneCallRequest{
CalleeID: 4, RandomID: 2, GAHash: gaHash, Protocol: testProtocol(),
}); err != nil {
t.Fatalf("request after tombstone GC: %v", err)
}
}
@ -476,4 +526,13 @@ func TestPhoneCallExpireDue(t *testing.T) {
if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 {
t.Fatalf("second ExpireDue = %d, want 0", len(got))
}
// 回归:旧 registry GC 会在 2×RingTimeout 后静默删除 Confirmed,导致后续
// sendSignalingData/discardCall 返回 CALL_PEER_INVALID。
clk.Advance(91 * time.Second)
if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 {
t.Fatalf("confirmed call expired after 2×RingTimeout: %+v", got)
}
if _, ok := s.Lookup(ctx, confirmedCall.ID, confirmedCall.AccessHash); !ok {
t.Fatal("confirmed call must remain addressable after 2×RingTimeout")
}
}