fix: sync protocol and discussion stability fixes

This commit is contained in:
A 2026-07-12 07:05:02 +08:00
parent 9f73dc20da
commit aa21bd04e1
43 changed files with 7258 additions and 503 deletions

View file

@ -336,7 +336,12 @@ func (s *Service) appendMissingChannelPeerPreviews(ctx context.Context, userID i
if !ok || view.Forbidden { if !ok || view.Forbidden {
continue continue
} }
if view.Self.Status != domain.ChannelMemberActive { // Linked discussion guests need a transient peer-dialog snapshot so
// TDesktop can finish materializing the comments History after
// requestSelf. ChannelLeft keeps the snapshot out of the main chat list,
// and Guest guarantees this path never turns an ordinary public preview
// into a dialog.
if view.Self.Status != domain.ChannelMemberActive && !view.Self.Guest {
continue continue
} }
history, err := s.channels.ListChannelHistory(ctx, userID, domain.ChannelHistoryFilter{ history, err := s.channels.ListChannelHistory(ctx, userID, domain.ChannelHistoryFilter{

View file

@ -25,13 +25,31 @@ type outboundWriter interface {
Send(context.Context, *bin.Buffer) error Send(context.Context, *bin.Buffer) error
} }
type connLifecycle uint32
const (
// The zero value is deliberately provisional so test/embedded Conn values start
// outside every SessionManager index until they complete activation.
connLifecycleProvisional connLifecycle = iota
connLifecycleClaiming
connLifecycleActive
// retired is terminal and irreversible. A physical connection that lost an
// activation claim must never become visible again, even if its read goroutine
// was already between preflight and publish when a replacement arrived.
connLifecycleRetired
)
type Conn struct { type Conn struct {
transport transport.Conn transport transport.Conn
writer outboundWriter // transportLease owns exactly one generation of the physical transport.
cipher crypto.Cipher // It is nil only for directly constructed test/embedded Conns that retain
msgID *proto.MessageIDGen // the legacy raw-transport close fallback.
writeTimeout time.Duration transportLease *physicalTransportLease
metrics Metrics writer outboundWriter
cipher crypto.Cipher
msgID *proto.MessageIDGen
writeTimeout time.Duration
metrics Metrics
authKeyID [8]byte authKeyID [8]byte
// authKeyHex 是 authKeyID 的 hex 缓存:每条 RPC 的结构化日志都会带它, // authKeyHex 是 authKeyID 的 hex 缓存:每条 RPC 的结构化日志都会带它,
@ -66,7 +84,11 @@ type Conn struct {
outboundScratchOnce sync.Once outboundScratchOnce sync.Once
// terminal 表示该 logical Conn 已停止接受新的出站操作。写失败时由 // terminal 表示该 logical Conn 已停止接受新的出站操作。写失败时由
// outbound actor 置位并只发停止信号,不能在 actor 内等待自身退出。 // outbound actor 置位并只发停止信号,不能在 actor 内等待自身退出。
terminal atomic.Bool terminal atomic.Bool
// lifecycle is a monotonic activation state machine. In particular, retired
// never transitions back to claiming/active; this closes the stale-read-loop
// ABA where an evicted Conn observed "not active" and registered itself again.
lifecycle atomic.Uint32
transportClose sync.Once transportClose sync.Once
rpcScheduler *inboundRPCScheduler rpcScheduler *inboundRPCScheduler
@ -130,6 +152,63 @@ type Conn struct {
clientLayer atomic.Int32 clientLayer atomic.Int32
} }
func (c *Conn) lifecycleState() connLifecycle {
if c == nil {
return connLifecycleRetired
}
return connLifecycle(c.lifecycle.Load())
}
func (c *Conn) beginActivationClaim() bool {
if c == nil || c.terminal.Load() || !c.isPhysicalTransportCurrentOpen() {
return false
}
if !c.lifecycle.CompareAndSwap(uint32(connLifecycleProvisional), uint32(connLifecycleClaiming)) {
return false
}
// Physical close can win after the pre-check but before the lifecycle CAS.
// Do not let a doomed claimant enter SessionManager and retire a healthy old
// owner for the same logical session.
if c.terminal.Load() || !c.isPhysicalTransportCurrentOpen() {
c.lifecycle.Store(uint32(connLifecycleRetired))
return false
}
return true
}
func (c *Conn) publishActivation() bool {
if c == nil || c.terminal.Load() || !c.isPhysicalTransportCurrentOpen() {
return false
}
if !c.lifecycle.CompareAndSwap(uint32(connLifecycleClaiming), uint32(connLifecycleActive)) {
return false
}
// A concurrent transport failure can retire the Conn between the first
// terminal check and the CAS. Never let that intermediate active value escape.
if c.terminal.Load() || !c.isPhysicalTransportCurrentOpen() {
c.lifecycle.Store(uint32(connLifecycleRetired))
return false
}
return true
}
func (c *Conn) isActive() bool {
return c != nil && !c.terminal.Load() && c.lifecycleState() == connLifecycleActive
}
// transferTransportOwnership hands this Conn's physical socket to the next
// logical generation. The caller must have fenced and drained the old writer.
func (c *Conn) transferTransportOwnership() (*physicalTransportLease, bool) {
if c == nil || c.transportLease == nil {
return nil, false
}
return c.transportLease.Transfer()
}
func (c *Conn) isPhysicalTransportCurrentOpen() bool {
return c != nil && (c.transportLease == nil || c.transportLease.IsCurrentOpen())
}
// ClientLayer 返回连接协商的 TL layer未协商时返回 canonical layer227不降级 // ClientLayer 返回连接协商的 TL layer未协商时返回 canonical layer227不降级
func (c *Conn) ClientLayer() int { func (c *Conn) ClientLayer() int {
if l := c.clientLayer.Load(); l != 0 { if l := c.clientLayer.Load(); l != 0 {

View file

@ -0,0 +1,116 @@
package mtprotoedge
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gotd/td/bin"
"github.com/gotd/td/proto"
"github.com/gotd/td/tg"
)
type blockingDuplicateRPC struct {
started chan struct{}
release chan struct{}
calls atomic.Int32
once sync.Once
}
func newBlockingDuplicateRPC() *blockingDuplicateRPC {
return &blockingDuplicateRPC{
started: make(chan struct{}, 4),
release: make(chan struct{}),
}
}
func (h *blockingDuplicateRPC) Dispatch(ctx context.Context, _ [8]byte, _ int64, _ *bin.Buffer) (bin.Encoder, error) {
h.calls.Add(1)
h.started <- struct{}{}
select {
case <-h.release:
return &tg.Config{ThisDC: 2}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (*blockingDuplicateRPC) NegotiatedLayer([8]byte, int64) (int, bool) { return 227, true }
func (h *blockingDuplicateRPC) unblock() { h.once.Do(func() { close(h.release) }) }
// TestPendingSameConnectionDuplicateDoesNotBlockFreshRequest models the core
// Android startup failure: an RPC is executing, salt correction makes the client
// resend the same msg_id, then initConnection assigns a fresh msg_id. A local
// duplicate must not synchronously join the old owner on the socket read loop,
// otherwise the fresh request remains unread until the old result/replay storm
// has drained.
func TestPendingSameConnectionDuplicateDoesNotBlockFreshRequest(t *testing.T) {
const dc = 2
handler := newBlockingDuplicateRPC()
defer handler.unblock()
addr, pub, server := startTestServer(t, Options{
DC: dc,
RPC: handler,
RPCMaxInflight: 2,
RPCGlobalWorkers: 2,
RPCQueueSize: 8,
})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
ids := proto.NewMessageIDGen(time.Now)
oldID := ids.New(proto.MessageFromClient)
newID := ids.New(proto.MessageFromClient)
sendEncryptedWithSeq(t, conn, cipher, auth, oldID, 1, &tg.HelpGetConfigRequest{})
waitDuplicateHandlerStarts(t, handler.started, "original request")
// Same ID/seq is a retransmission, not a second business operation.
sendEncryptedWithSeq(t, conn, cipher, auth, oldID, 1, &tg.HelpGetConfigRequest{})
// A client-side init/layer rewrap legitimately assigns a new ID and next seq.
sendEncryptedWithSeq(t, conn, cipher, auth, newID, 3, &tg.HelpGetConfigRequest{})
waitDuplicateHandlerStarts(t, handler.started, "fresh request behind duplicate")
if got := handler.calls.Load(); got != 2 {
t.Fatalf("handler calls before release = %d, want original + fresh only", got)
}
handler.unblock()
frames := collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{
proto.ResultTypeID: 2,
})
results := make(map[int64]int)
for _, frame := range frames {
if frame.TypeID != proto.ResultTypeID {
continue
}
var result proto.Result
if err := result.Decode(frame.Plain); err != nil {
t.Fatalf("decode rpc_result: %v", err)
}
results[result.RequestMessageID]++
}
if results[oldID] != 1 || results[newID] != 1 || len(results) != 2 {
t.Fatalf("rpc_result counts = %+v, want one for old and one for fresh id", results)
}
if got := handler.calls.Load(); got != 2 {
t.Fatalf("final handler calls = %d, want 2", got)
}
deadline := time.Now().Add(2 * time.Second)
for server.rpcResults.flightLimit.snapshot() != 0 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if got := server.rpcResults.flightLimit.snapshot(); got != 0 {
t.Fatalf("duplicate admission leaked flight slots: %d", got)
}
}
func waitDuplicateHandlerStarts(t *testing.T, started <-chan struct{}, what string) {
t.Helper()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatalf("%s did not reach handler; socket read loop is likely blocked on a duplicate", what)
}
}

View file

@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"io" "io"
"math" "math"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
@ -19,6 +20,7 @@ import (
"github.com/gotd/td/crypto" "github.com/gotd/td/crypto"
"github.com/gotd/td/mt" "github.com/gotd/td/mt"
"github.com/gotd/td/proto" "github.com/gotd/td/proto"
"github.com/gotd/td/proto/codec"
"github.com/gotd/td/tgerr" "github.com/gotd/td/tgerr"
"github.com/gotd/td/transport" "github.com/gotd/td/transport"
@ -30,11 +32,16 @@ import (
// connState 是单连接的 MTProto 运行态。 // connState 是单连接的 MTProto 运行态。
type connState struct { type connState struct {
sentCreated bool // createdFloor is the smallest client msg_id covered by the latest
seen map[int64]clientMsgRecord // 已处理的 client msg_id用于幂等和 msgs_state_req // new_session_created notification for this server-side session generation.
order []int64 // It only moves down: official clients resend every request below first_msg_id,
minSeen int64 // so advertising an outer container id while accepting smaller inner ids would
maxSeen int64 // orphan the original rpc_result messages.
createdFloor int64
seen map[int64]clientMsgRecord // 已处理的 client msg_id用于幂等和 msgs_state_req
order []int64
minSeen int64
maxSeen int64
// maxContentMsgID/maxContentSeqNo 是已接受 content 消息的 msg_id / seq_no 高水位, // maxContentMsgID/maxContentSeqNo 是已接受 content 消息的 msg_id / seq_no 高水位,
// 供 validateSeq 的 O(1) 快路径使用(客户端正常发送严格递增)。二者只增不减、 // 供 validateSeq 的 O(1) 快路径使用(客户端正常发送严格递增)。二者只增不减、
// 不随 seen 淘汰回退——快路径只接受「全扫描也必然接受」的子集,其余回落全扫描。 // 不随 seen 淘汰回退——快路径只接受「全扫描也必然接受」的子集,其余回落全扫描。
@ -46,6 +53,9 @@ type clientMsgRecord struct {
state byte state byte
seqNo int32 seqNo int32
content bool content bool
// service is the constructor class admitted with this msg_id. A duplicate
// uses this committed class and never decodes/executes its replacement body.
service bool
} }
func newConnState() *connState { func newConnState() *connState {
@ -76,11 +86,11 @@ const (
// MTProto service vectors operate on bounded connection tracking tables. Accepting more IDs // MTProto service vectors operate on bounded connection tracking tables. Accepting more IDs
// only burns decode/CPU and cannot improve the result. // only burns decode/CPU and cannot improve the result.
maxServiceMessageIDs = 4096 maxServiceMessageIDs = 4096
// A decoded container descriptor is 48 bytes on 64-bit Go today. Charge 64 bytes per entry // Charge each container entry for the decoded proto.Message view plus the staged connState,
// before allocating the exact-size slice so allocator rounding and future field growth remain // action and ACK descriptors retained by the single-pass inbound plan. Bodies remain zero-copy
// inside the process-wide inbound budget. Message bodies stay as zero-copy views of the already // views of the already charged plaintext frame/gzip expansion; RPC copies have a separate batch
// charged plaintext frame/gzip expansion. // admission budget.
containerDescriptorBudgetBytes = 64 containerDescriptorBudgetBytes = 192
msgStateUnknown byte = 1 msgStateUnknown byte = 1
msgStateNotReceived byte = 2 msgStateNotReceived byte = 2
@ -97,11 +107,14 @@ const (
badMsgContainer = 64 badMsgContainer = 64
) )
var errActivationAuthKeyRejected = errors.New("activation auth key no longer exists")
// handleEncrypted 解密加密消息,按需注册连接,处理服务消息并分发明文 payload。 // handleEncrypted 解密加密消息,按需注册连接,处理服务消息并分发明文 payload。
// 返回(可能新建/更新的)当前连接对象,供 serveConn 维护生命周期。 // 返回(可能新建/更新的)当前连接对象,供 serveConn 维护生命周期。
// fetchedKey 非 nil 表示本帧的 auth key 是刚从 AuthKeyStore 查出的(首帧/换 auth key/被销毁 // fetchedKey 非 nil 表示本帧的 auth key 是刚从 AuthKeyStore 查出的(首帧/换 auth key/被销毁
// 后回落);为 nil 表示走快路径——serveConn 判定 current 仍持同一未销毁的 auth key直接复用 // 后回落);为 nil 表示走连接缓存快路径——serveConn 判定 current 仍持同一未销毁的 auth key
// current.key/current.salt 解密,既不回查 AuthKeyStore 也不重建 store.AuthKeyData。 // 直接复用 current.key/current.salt 解密。任何 provisional 在 claim 建立后、发 required
// control 前都会最终回查 AuthKeyStore使外部撤销与 activation 线性化。
// plain 是 serveConn 持有的复用明文缓冲frame 的 slice 仅在下一帧解密前有效。 // plain 是 serveConn 持有的复用明文缓冲frame 的 slice 仅在下一帧解密前有效。
func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *connState, current *Conn, fetchedKey *store.AuthKeyData, b, plain *bin.Buffer) (*Conn, error) { func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *connState, current *Conn, fetchedKey *store.AuthKeyData, b, plain *bin.Buffer) (*Conn, error) {
var key crypto.AuthKey var key crypto.AuthKey
@ -120,30 +133,27 @@ func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *con
return current, fmt.Errorf("decrypt: %w", err) return current, fmt.Errorf("decrypt: %w", err)
} }
if frame.salt != serverSalt { // 首个加密消息(即使 salt 尚未修正)或 session 变化时创建并保留唯一的
c := current // provisional Conn。同一物理 transport 换 session 必须先不可逆地 fence/drain
temp := false // 旧 writer再把物理 lease 原子转交给新 generation。为每个 bad_server_salt
if c == nil || c.sessionID != frame.sessionID || c.authKeyID != key.ID { // 临时创建 Conn 会在同一 socket 上启动多个 outbound actorAndroid 的启动重试
c = s.newConn(tc, key, frame.sessionID, serverSalt) // 风暴随即变成并发写和重复结果放大。
temp = true
}
err := s.sendBadServerSalt(ctx, c, frame.messageID, frame.seqNo, serverSalt)
if temp {
c.Close()
}
return current, err
}
// 首个加密消息或 session 变化时(重新)注册连接到 SessionManager。
if current == nil || current.sessionID != frame.sessionID || current.authKeyID != key.ID { if current == nil || current.sessionID != frame.sessionID || current.authKeyID != key.ID {
if current != nil { if current != nil {
cs.reset() cs.reset()
} current.beginTerminalShutdown()
if current != nil {
s.conns.Unregister(current) s.conns.Unregister(current)
current.Close() if !current.waitOutboundShutdownUntil(forceCloseBatchTimeout) {
return current, errors.New("previous session outbound writer did not stop")
}
nextLease, ok := current.transferTransportOwnership()
if !ok {
return current, ErrConnClosed
}
current = s.newConnWithLease(nextLease, key, frame.sessionID, serverSalt)
} else {
current = s.newConn(tc, key, frame.sessionID, serverSalt)
} }
current = s.newConn(tc, key, frame.sessionID, serverSalt)
// 注册即播种协商 layer新 Conn 的 clientLayer 为 0=canonical 227若等到 // 注册即播种协商 layer新 Conn 的 clientLayer 为 0=canonical 227若等到
// 首条 RPC 的 Dispatch 返回后才刷新,重连老客户端在首条 RPC handler 执行期间 // 首条 RPC 的 Dispatch 返回后才刷新,重连老客户端在首条 RPC handler 执行期间
// 收到的 pending flush / 并发 push 会漏降级。进程内重连时 rpc 层留有 // 收到的 pending flush / 并发 push 会漏降级。进程内重连时 rpc 层留有
@ -153,68 +163,120 @@ func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *con
current.SetClientLayer(layer) current.SetClientLayer(layer)
} }
} }
s.conns.Register(current)
} }
s.maybePersistSession(ctx, current, frame.sessionID, key.ID, serverSalt) if frame.salt != serverSalt {
// bad_server_salt 是修正后重试的物理屏障payload 与加密 envelope 都必须携带
// 同一个权威 salt写失败则该 provisional/active Conn 不得继续接收状态。
return current, s.sendBadServerSalt(ctx, current, frame.messageID, frame.seqNo, serverSalt)
}
body := frame.data body := frame.data
typeID, err := (&bin.Buffer{Buf: body}).PeekID() typeID, err := (&bin.Buffer{Buf: body}).PeekID()
if err != nil { if err != nil {
return current, fmt.Errorf("peek encrypted payload type id: %w", err) return current, fmt.Errorf("peek encrypted payload type id: %w", err)
} }
if code := validateClientEnvelope(s.clock.Now(), frame.messageID, frame.seqNo, typeID); code != 0 { plan, err := s.preflightInbound(cs, frame.messageID, frame.seqNo, body)
s.log.Debug("Sending bad_msg_notification", if err != nil {
zap.Int64("msg_id", frame.messageID), var bad *dispatchBadMsgError
zap.Int32("seq_no", frame.seqNo), if errors.As(err, &bad) {
zap.Uint32("type_id", typeID), s.log.Debug("Sending bad_msg_notification",
zap.Int("code", code), zap.Int64("msg_id", bad.msgID),
) zap.Int32("seq_no", bad.seqNo),
return current, s.sendBadMsg(ctx, current, frame.messageID, frame.seqNo, code) zap.Uint32("type_id", typeID),
zap.Int("code", bad.code),
)
return current, s.sendBadMsg(ctx, current, bad.msgID, bad.seqNo, bad.code)
}
return current, err
} }
if err := sendQuickAckIfRequested(ctx, tc, key, frame.plaintext, s.writeTimeout); err != nil { defer plan.close()
if err := s.prepareInboundRPCBatch(ctx, current, plan); err != nil {
return current, err
}
if err := sendQuickAckIfRequested(ctx, current.transport, key, frame.plaintext, s.writeTimeout); err != nil {
return current, err return current, err
} }
content := clientMessageNeedsAck(typeID) moveCreatedFloor := cs.createdFloor == 0 || plan.logicalMin < cs.createdFloor
if record, ok := cs.seenRecord(frame.messageID); ok { claimPending := false
s.log.Debug("Duplicate msg_id; replay cached result if available", zap.Int64("msg_id", frame.messageID)) if current.lifecycleState() == connLifecycleProvisional {
if err := s.replayRPCResultByRequest(ctx, current, frame.messageID); err != nil { if !moveCreatedFloor {
return current, errors.New("provisional session has no new_session_created boundary")
}
if err := s.conns.BeginActivation(current); err != nil {
return current, err return current, err
} }
if !record.content { claimPending = true
return current, nil defer func() {
if claimPending {
s.conns.AbortActivation(current)
}
}()
// BeginActivation has installed current in claimsByAuth, which is the shared
// linearization domain with auth-key revocation. A delete that completed before
// the claim is visible here as !found; a delete after this read must observe and
// fence the claim. This final check intentionally covers every activation path:
// first correct-salt frame, retained bad-salt provisional and session transfer.
fresh, found, getErr := s.authKeys.Get(ctx, current.authKeyID)
if getErr != nil {
return current, fmt.Errorf("revalidate activation auth key: %w", getErr)
}
if !found || fresh.ID != current.authKeyID || fresh.Value != [256]byte(current.key.Value) {
// Send the terminal protocol error while the claim still owns a live writer;
// the deferred abort then fences and removes it before serveConn returns.
if sendErr := s.sendProtoError(ctx, current.transport, codec.CodeAuthKeyNotFound); sendErr != nil {
return current, sendErr
}
return current, errActivationAuthKeyRejected
}
if current.terminal.Load() || !current.isPhysicalTransportCurrentOpen() {
return current, ErrConnClosed
} }
return current, s.sendAck(ctx, current, frame.messageID)
} }
if code := cs.validateSeq(frame.messageID, frame.seqNo, content); code != 0 { if moveCreatedFloor {
s.log.Debug("Sending bad_msg_notification", s.log.Debug("Sending new_session_created",
zap.Int64("msg_id", frame.messageID), zap.Int64("first_msg_id", plan.logicalMin),
zap.Int64("outer_msg_id", frame.messageID),
zap.Int32("seq_no", frame.seqNo), zap.Int32("seq_no", frame.seqNo),
zap.Uint32("type_id", typeID),
zap.Int("code", code),
) )
return current, s.sendBadMsg(ctx, current, frame.messageID, frame.seqNo, code) if err := s.sendNewSessionCreated(ctx, current, plan.logicalMin); err != nil {
}
cs.track(frame.messageID, frame.seqNo, content, msgStateReceived)
if !cs.sentCreated {
cs.sentCreated = true
s.log.Debug("Sending new_session_created", zap.Int64("msg_id", frame.messageID), zap.Int32("seq_no", frame.seqNo))
if err := s.sendNewSessionCreated(ctx, current, frame.messageID); err != nil {
return current, err return current, err
} }
} }
if !current.isPhysicalTransportCurrentOpen() {
return current, ErrConnClosed
}
if claimPending {
if err := s.conns.PublishActivation(current); err != nil {
return current, err
}
claimPending = false
}
if moveCreatedFloor {
cs.createdFloor = plan.logicalMin
}
plan.commitState(cs)
s.maybePersistSession(ctx, current, frame.sessionID, key.ID, serverSalt)
var acks []int64 if err := s.executeInboundPlan(ctx, cs, current, plan); err != nil {
if err := s.dispatch(ctx, cs, current, frame.messageID, frame.seqNo, &bin.Buffer{Buf: body}, &acks); err != nil {
return current, err return current, err
} }
if len(acks) > 0 { if err := plan.commitRPCBatch(); err != nil {
if err := s.sendAck(ctx, current, acks...); err != nil { return current, err
}
if len(plan.ackIDs) > 0 {
if err := s.sendAck(ctx, current, plan.ackIDs...); err != nil {
return current, err return current, err
} }
} }
// An overlapping cross-connection owner may run until RPCTimeout. ACK the
// accepted duplicate before joining it so the new client does not build a
// retransmit storm while its read loop waits for the shared result.
if err := s.executePendingRPCReplays(ctx, current, plan); err != nil {
return current, err
}
return current, nil return current, nil
} }
@ -284,236 +346,37 @@ func clientQuickAckToken(key crypto.AuthKey, plaintext []byte) uint32 {
// dispatch 处理一条明文消息:解包 container/gzip处理服务消息其余转 RPC 路由。 // dispatch 处理一条明文消息:解包 container/gzip处理服务消息其余转 RPC 路由。
// content-related 消息ping、RPC的 msg_id 会收集到 acks 以便统一确认。 // content-related 消息ping、RPC的 msg_id 会收集到 acks 以便统一确认。
func (s *Server) dispatch(ctx context.Context, cs *connState, c *Conn, msgID int64, seqNo int32, b *bin.Buffer, acks *[]int64) error { func (s *Server) dispatch(ctx context.Context, cs *connState, c *Conn, msgID int64, seqNo int32, b *bin.Buffer, acks *[]int64) error {
expanded := 0 plan, err := s.preflightInbound(cs, msgID, seqNo, b.Buf)
return s.dispatchWithBudget(ctx, cs, c, msgID, seqNo, b, acks, dispatchBudget{expanded: &expanded})
}
type dispatchBudget struct {
depth int
containerDepth int
expanded *int
}
func (s *Server) dispatchWithBudget(ctx context.Context, cs *connState, c *Conn, msgID int64, seqNo int32, b *bin.Buffer, acks *[]int64, budget dispatchBudget) error {
if budget.depth > maxDispatchDepth {
return fmt.Errorf("mtproto wrapper depth %d exceeds %d", budget.depth, maxDispatchDepth)
}
id, err := b.PeekID()
if err != nil { if err != nil {
return fmt.Errorf("peek type id: %w", err) var bad *dispatchBadMsgError
} if errors.As(err, &bad) && c != nil {
ackContent := func() { return s.sendBadMsg(ctx, c, bad.msgID, bad.seqNo, bad.code)
if clientMessageNeedsAck(id) { }
*acks = append(*acks, msgID) return err
}
} }
defer plan.close()
plan.commitState(cs)
*acks = append(*acks, plan.ackIDs...)
return s.executeInboundPlan(ctx, cs, c, plan)
}
switch id { // dispatchBadMsgError carries a protocol-level rejection discovered during the
case proto.GZIPTypeID: // side-effect-free wrapper/container preflight. The caller emits the single
data, releaseExpansion, err := s.decodeGZIPWithGlobalBudget(b) // bad_msg_notification only after the whole container has been inspected.
if err != nil { type dispatchBadMsgError struct {
return fmt.Errorf("decode gzip: %w", err) msgID int64
} seqNo int32
defer releaseExpansion() code int
*budget.expanded += len(data) }
if *budget.expanded > maxDispatchExpandedBytes {
return fmt.Errorf("cumulative gzip expansion %d exceeds %d", *budget.expanded, maxDispatchExpandedBytes)
}
budget.depth++
return s.dispatchWithBudget(ctx, cs, c, msgID, seqNo, &bin.Buffer{Buf: data}, acks, budget)
case proto.MessageContainerTypeID: func (e *dispatchBadMsgError) Error() string {
if budget.containerDepth != 0 { return fmt.Sprintf("bad client message %d/%d: code %d", e.msgID, e.seqNo, e.code)
return s.sendBadMsg(ctx, c, msgID, seqNo, badMsgContainer)
}
count, err := containerMessageCount(b)
if err != nil {
return fmt.Errorf("decode container count: %w", err)
}
if count > maxContainerMessages {
return s.sendBadMsg(ctx, c, msgID, seqNo, badMsgContainer)
}
container, releaseContainer, err := s.decodeMessageContainerViews(b, count)
if err != nil {
return fmt.Errorf("decode container: %w", err)
}
defer releaseContainer()
if code := validateClientContainer(msgID, seqNo, container); code != 0 {
return s.sendBadMsg(ctx, c, msgID, seqNo, code)
}
budget.depth++
budget.containerDepth++
for i := range container.Messages {
m := container.Messages[i]
typeID, err := (&bin.Buffer{Buf: m.Body}).PeekID()
if err != nil {
return fmt.Errorf("peek container message type id: %w", err)
}
content := clientMessageNeedsAck(typeID)
if record, ok := cs.seenRecord(m.ID); ok {
if err := s.replayRPCResultByRequest(ctx, c, m.ID); err != nil {
return err
}
if record.content {
*acks = append(*acks, m.ID)
}
continue
}
if code := cs.validateSeq(m.ID, int32(m.SeqNo), content); code != 0 {
return s.sendBadMsg(ctx, c, m.ID, int32(m.SeqNo), code)
}
cs.track(m.ID, int32(m.SeqNo), content, msgStateReceived)
if err := s.dispatchWithBudget(ctx, cs, c, m.ID, int32(m.SeqNo), &bin.Buffer{Buf: m.Body}, acks, budget); err != nil {
return err
}
}
return nil
case mt.PingRequestTypeID:
var ping mt.PingRequest
if err := ping.Decode(b); err != nil {
return fmt.Errorf("decode ping: %w", err)
}
ackContent()
return s.sendPong(ctx, c, msgID, ping.PingID)
case mt.PingDelayDisconnectRequestTypeID:
var ping mt.PingDelayDisconnectRequest
if err := ping.Decode(b); err != nil {
return fmt.Errorf("decode ping_delay_disconnect: %w", err)
}
ackContent()
return s.sendPong(ctx, c, msgID, ping.PingID)
case mt.GetFutureSaltsRequestTypeID:
var req mt.GetFutureSaltsRequest
if err := req.Decode(b); err != nil {
return fmt.Errorf("decode get_future_salts: %w", err)
}
ackContent()
return s.sendFutureSalts(ctx, c, msgID, req.Num)
case mt.MsgsAckTypeID:
if err := validateFirstVectorCount(b, maxServiceMessageIDs); err != nil {
return fmt.Errorf("msgs_ack vector: %w", err)
}
var ack mt.MsgsAck
if err := ack.Decode(b); err != nil {
return fmt.Errorf("decode msgs_ack: %w", err)
}
c.AckServerMessages(ack.MsgIDs)
s.log.Debug("Received msgs_ack", zap.Int64s("msg_ids", ack.MsgIDs))
return nil
case mt.MsgsStateReqTypeID:
if err := validateFirstVectorCount(b, maxServiceMessageIDs); err != nil {
return fmt.Errorf("msgs_state_req vector: %w", err)
}
var req mt.MsgsStateReq
if err := req.Decode(b); err != nil {
return fmt.Errorf("decode msgs_state_req: %w", err)
}
ackContent()
outgoing, err := c.OutgoingStateInfo(ctx, req.MsgIDs)
if err != nil {
return err
}
return s.sendMsgsStateInfo(ctx, c, msgID, mergeStateInfo(outgoing, cs.stateInfo(req.MsgIDs)))
case mt.MsgResendReqTypeID:
if err := validateFirstVectorCount(b, maxServiceMessageIDs); err != nil {
return fmt.Errorf("msg_resend_req vector: %w", err)
}
var req mt.MsgResendReq
if err := req.Decode(b); err != nil {
return fmt.Errorf("decode msg_resend_req: %w", err)
}
ackContent()
outgoing, err := c.ResendMessages(ctx, req.MsgIDs)
if err != nil {
return err
}
return s.sendMsgsStateInfo(ctx, c, msgID, mergeStateInfo(outgoing, cs.stateInfo(req.MsgIDs)))
case mt.MsgsStateInfoTypeID:
reqMsgID, info, err := msgsStateInfoView(b)
if err != nil {
return fmt.Errorf("decode msgs_state_info: %w", err)
}
s.log.Debug("Received msgs_state_info", zap.Int64("req_msg_id", reqMsgID), zap.Int("len", len(info)))
return nil
case mt.MsgsAllInfoTypeID:
count, info, err := msgsAllInfoView(b)
if err != nil {
return fmt.Errorf("decode msgs_all_info: %w", err)
}
if len(info) != count {
return fmt.Errorf("decode msgs_all_info: info length %d does not match msg_ids %d", len(info), count)
}
s.log.Debug("Received msgs_all_info", zap.Int("msg_ids", count), zap.Int("len", len(info)))
return nil
case mt.DestroySessionRequestTypeID:
var req mt.DestroySessionRequest
if err := req.Decode(b); err != nil {
return fmt.Errorf("decode destroy_session: %w", err)
}
ackContent()
return s.sendDestroySession(ctx, c, req.SessionID)
case mt.HTTPWaitRequestTypeID:
var req mt.HTTPWaitRequest
if err := req.Decode(b); err != nil {
return fmt.Errorf("decode http_wait: %w", err)
}
s.log.Debug("Received http_wait",
zap.Int("max_delay", req.MaxDelay),
zap.Int("wait_after", req.WaitAfter),
zap.Int("max_wait", req.MaxWait),
)
return nil
case mt.RPCDropAnswerRequestTypeID:
var req mt.RPCDropAnswerRequest
if err := req.Decode(b); err != nil {
return fmt.Errorf("decode rpc_drop_answer: %w", err)
}
ackContent()
s.log.Debug("Received rpc_drop_answer", zap.Int64("req_msg_id", req.ReqMsgID))
return s.sendResult(ctx, c, msgID, &mt.RPCAnswerUnknown{})
case destroyAuthKeyRequestTypeID:
var req destroyAuthKeyRequest
if err := req.Decode(b); err != nil {
return err
}
ackContent()
s.log.Debug("Received destroy_auth_key", zap.String("auth_key_id", c.authKeyHex))
// 真正销毁:删密钥库记录(每帧回查,删除后该 key 的入站帧立即失效)并主动
// 断开同 key 的其他连接——出站推送用连接持有的密钥副本加密、不回查密钥库,
// 不断开的话被销毁 key 的空闲连接仍能持续收到推送。发起连接除外:响应要
// 先送达它的下一帧会因密钥缺失自然断开。授权authorizations不在此清理
// destroy_auth_key 是 PFS 密钥轮换的清理动作,不等于登出。
if err := s.authKeys.Delete(ctx, c.authKeyID); err != nil {
s.log.Warn("Delete auth key failed", zap.String("auth_key_id", c.authKeyHex), zap.Error(err))
return c.SendAsync(ctx, proto.MessageServerResponse, &destroyAuthKeyFail{})
}
// 标记密钥已销毁:发起连接被 CloseSessionsForRawAuthKeyExcept 排除(响应需先送达),
// 它下一帧不能再走 serveConn 的密钥复用快路径,须回落到 Get→AuthKeyNotFound 自然失效。
c.keyDestroyed.Store(true)
s.conns.CloseSessionsForRawAuthKeyExcept(c.authKeyID, c.sessionID)
return c.SendAsync(ctx, proto.MessageServerResponse, &destroyAuthKeyOk{})
default:
ackContent()
return s.enqueueRPC(ctx, c, msgID, id, b)
}
} }
// decodeGZIPWithGlobalBudget reserves the maximum single-wrapper output before // decodeGZIPWithGlobalBudget reserves the maximum single-wrapper output before
// decompression starts. Once the actual size is known the excess reservation is // decompression starts. Once the actual size is known the excess reservation is
// returned, while the actual output remains charged through recursive dispatch. // returned, while the actual output remains charged until the inbound plan is
// executed or aborted.
// This closes the gap where every connection read goroutine could otherwise hold // This closes the gap where every connection read goroutine could otherwise hold
// an unaccounted 10 MiB expansion before the shared RPC scheduler saw the body. // an unaccounted 10 MiB expansion before the shared RPC scheduler saw the body.
func (s *Server) decodeGZIPWithGlobalBudget(b *bin.Buffer) ([]byte, func(), error) { func (s *Server) decodeGZIPWithGlobalBudget(b *bin.Buffer) ([]byte, func(), error) {
@ -571,8 +434,14 @@ func gzipPackedBytesView(b *bin.Buffer) ([]byte, error) {
if binary.LittleEndian.Uint32(b.Buf[:4]) != proto.GZIPTypeID { if binary.LittleEndian.Uint32(b.Buf[:4]) != proto.GZIPTypeID {
return nil, fmt.Errorf("unexpected gzip constructor %#x", binary.LittleEndian.Uint32(b.Buf[:4])) return nil, fmt.Errorf("unexpected gzip constructor %#x", binary.LittleEndian.Uint32(b.Buf[:4]))
} }
payload, _, err := tlBytesView(b.Buf[4:], -1) payload, consumed, err := tlBytesView(b.Buf[4:], -1)
return payload, err if err != nil {
return nil, err
}
if 4+consumed != len(b.Buf) {
return nil, fmt.Errorf("gzip_packed has %d trailing bytes", len(b.Buf)-(4+consumed))
}
return payload, nil
} }
// tlBytesView validates one TL bytes envelope and returns a view into the caller-owned buffer. // tlBytesView validates one TL bytes envelope and returns a view into the caller-owned buffer.
@ -604,9 +473,9 @@ func tlBytesView(raw []byte, maxPayload int) ([]byte, int, error) {
} }
// decodeMessageContainerViews parses the container without proto.Message.Decode's per-body // decodeMessageContainerViews parses the container without proto.Message.Decode's per-body
// copies. Bodies are immutable views of b and stay alive only for this synchronous dispatch; // copies. Bodies stay as immutable views through the single-pass inbound plan; batch admission
// enqueueRPC takes its own budgeted copy before returning. Only the exact-size descriptor slice // takes independent RPC copies before the backing frame/expansion is released. The descriptor
// is new memory, and that allocation is reserved globally first. // reservation also covers staged state, actions and ACK metadata retained by that plan.
func (s *Server) decodeMessageContainerViews(b *bin.Buffer, count int) (proto.MessageContainer, func(), error) { func (s *Server) decodeMessageContainerViews(b *bin.Buffer, count int) (proto.MessageContainer, func(), error) {
release := func() {} release := func() {}
if b == nil || len(b.Buf) < 8 { if b == nil || len(b.Buf) < 8 {
@ -663,6 +532,10 @@ func (s *Server) decodeMessageContainerViews(b *bin.Buffer, count int) (proto.Me
} }
offset = bodyEnd offset = bodyEnd
} }
if offset != len(b.Buf) {
release()
return proto.MessageContainer{}, func() {}, fmt.Errorf("message container has %d trailing bytes", len(b.Buf)-offset)
}
return proto.MessageContainer{Messages: messages}, release, nil return proto.MessageContainer{Messages: messages}, release, nil
} }
@ -673,10 +546,13 @@ func msgsStateInfoView(b *bin.Buffer) (int64, []byte, error) {
if got := binary.LittleEndian.Uint32(b.Buf[:4]); got != mt.MsgsStateInfoTypeID { if got := binary.LittleEndian.Uint32(b.Buf[:4]); got != mt.MsgsStateInfoTypeID {
return 0, nil, fmt.Errorf("unexpected constructor %#x", got) return 0, nil, fmt.Errorf("unexpected constructor %#x", got)
} }
info, _, err := tlBytesView(b.Buf[12:], maxServiceMessageIDs) info, consumed, err := tlBytesView(b.Buf[12:], maxServiceMessageIDs)
if err != nil { if err != nil {
return 0, nil, err return 0, nil, err
} }
if 12+consumed != len(b.Buf) {
return 0, nil, fmt.Errorf("msgs_state_info has %d trailing bytes", len(b.Buf)-(12+consumed))
}
return int64(binary.LittleEndian.Uint64(b.Buf[4:12])), info, nil return int64(binary.LittleEndian.Uint64(b.Buf[4:12])), info, nil
} }
@ -691,10 +567,13 @@ func msgsAllInfoView(b *bin.Buffer) (int, []byte, error) {
return 0, nil, io.ErrUnexpectedEOF return 0, nil, io.ErrUnexpectedEOF
} }
offset := 12 + count*8 offset := 12 + count*8
info, _, err := tlBytesView(b.Buf[offset:], maxServiceMessageIDs) info, consumed, err := tlBytesView(b.Buf[offset:], maxServiceMessageIDs)
if err != nil { if err != nil {
return 0, nil, err return 0, nil, err
} }
if offset+consumed != len(b.Buf) {
return 0, nil, fmt.Errorf("msgs_all_info has %d trailing bytes", len(b.Buf)-(offset+consumed))
}
return count, info, nil return count, info, nil
} }
@ -750,15 +629,41 @@ func mergeStateInfo(primary, fallback []byte) []byte {
// (已 PeekID 过一次method 只解析一次并随任务透传,避免同一请求三处重复 PeekID/typeName。 // (已 PeekID 过一次method 只解析一次并随任务透传,避免同一请求三处重复 PeekID/typeName。
func (s *Server) enqueueRPC(ctx context.Context, c *Conn, msgID int64, typeID uint32, request *bin.Buffer) error { func (s *Server) enqueueRPC(ctx context.Context, c *Conn, msgID int64, typeID uint32, request *bin.Buffer) error {
method := s.typeName(typeID) method := s.typeName(typeID)
if cached, ok := s.cachedRPCResult(c, msgID); ok { claim, err := s.rpcResults.Acquire(c.authKeyID, c.sessionID, msgID)
if err != nil {
if errors.Is(err, ErrRPCResultFlightCapacity) {
c.metrics.InboundRPCDropped(method, "flight_capacity")
return s.handleInboundRPCAdmissionError(ctx, c, msgID, method, ErrInboundRPCQueueFull)
}
return err
}
switch claim.state {
case rpcResultAcquireCompleted:
s.log.Info("RPC duplicate replay from session cache", s.log.Info("RPC duplicate replay from session cache",
zap.String("method", method), zap.String("method", method),
zap.Int64("msg_id", msgID), zap.Int64("msg_id", msgID),
zap.String("auth_key_id", c.authKeyHex), zap.String("auth_key_id", c.authKeyHex),
zap.Int64("session_id", c.sessionID), zap.Int64("session_id", c.sessionID),
) )
return c.SendEncoded(ctx, proto.MessageServerResponse, cached) return s.sendCachedRPCResult(ctx, c, claim.encoded)
case rpcResultAcquirePending:
encoded, ok, waitErr := claim.waiter.Wait(ctx)
if waitErr != nil || !ok || encoded == nil {
return waitErr
}
return s.sendCachedRPCResult(ctx, c, encoded)
case rpcResultAcquireOwner:
// Ownership transfers to the queued task only after commit succeeds.
default:
return ErrRPCResultFlightInvalid
} }
owner := claim.owner
transferred := false
defer func() {
if !transferred {
owner.Abort()
}
}()
// 两级条数/字节预算必须先于 Copy对抗客户端不能用大量满尺寸请求在“判断队列满” // 两级条数/字节预算必须先于 Copy对抗客户端不能用大量满尺寸请求在“判断队列满”
// 之前制造一轮无上限的临时 body 分配。reservation 在 commit/abort 间唯一持有预算。 // 之前制造一轮无上限的临时 body 分配。reservation 在 commit/abort 间唯一持有预算。
reservation, err := c.reserveInboundRPC(ctx, method, request.Len()) reservation, err := c.reserveInboundRPC(ctx, method, request.Len())
@ -767,11 +672,21 @@ func (s *Server) enqueueRPC(ctx context.Context, c *Conn, msgID int64, typeID ui
} }
defer reservation.abort() defer reservation.abort()
body := request.Copy() body := request.Copy()
responseGate := &rpcResponseGate{} err = reservation.commit(s.newInboundRPCTask(c, msgID, method, body, owner))
transferred = err == nil
return s.handleInboundRPCAdmissionError(ctx, c, msgID, method, err)
}
// newInboundRPCTask builds the exactly-once timeout/result gate shared by the
// single-message and atomic container-batch admission paths. body must already
// be an independently owned, budgeted copy.
func (s *Server) newInboundRPCTask(c *Conn, msgID int64, method string, body []byte, owner *rpcResultOwnerLease) inboundRPC {
responseGate := newRPCResponseGate()
timeoutResponse := func() { timeoutResponse := func() {
if !responseGate.tryTimeout() { if !responseGate.tryTimeout() {
return return
} }
defer responseGate.finish()
// 原 task context 已到期,使用有界的新 context 回显明确的可重试超时; // 原 task context 已到期,使用有界的新 context 回显明确的可重试超时;
// 500 保持 TDesktop 默认重试语义,错误名区分于容量型 FLOOD_WAIT。 // 500 保持 TDesktop 默认重试语义,错误名区分于容量型 FLOOD_WAIT。
writeTimeout := c.writeTimeout writeTimeout := c.writeTimeout
@ -793,10 +708,29 @@ func (s *Server) enqueueRPC(ctx context.Context, c *Conn, msgID int64, typeID ui
) )
} }
} }
err = reservation.commit(inboundRPC{ return inboundRPC{
method: method, method: method,
size: len(body), size: len(body),
onTimeout: timeoutResponse, onTimeout: timeoutResponse,
release: func() {
if owner == nil {
return
}
// A running deadline callback owns the same terminal flight but executes
// on context.AfterFunc's goroutine. Let its bounded delivery complete before
// deciding that the task produced no result; otherwise release could Abort
// the owner while RPC_TIMEOUT is already inside the physical writer.
if !responseGate.wait(requiredControlMaxWait + time.Second) {
c.fenceUndeliveredRPCResult()
}
if owner.Abort() {
// connState already remembers this request. If a committed task exits
// without publishing any terminal rpc_result, a same-Conn retransmit
// would otherwise be ACKed forever. Force a fresh physical generation
// where the request can be admitted again.
c.fenceUndeliveredRPCResult()
}
},
run: func(taskCtx context.Context) error { run: func(taskCtx context.Context) error {
// body 是预算成功后生成的独立副本,且每个任务只 run 一次, // body 是预算成功后生成的独立副本,且每个任务只 run 一次,
// 无需再 append 拷贝;直接复用,省掉一份 inbound 在途内存。 // 无需再 append 拷贝;直接复用,省掉一份 inbound 在途内存。
@ -816,8 +750,7 @@ func (s *Server) enqueueRPC(ctx context.Context, c *Conn, msgID int64, typeID ui
} }
return nil return nil
}, },
}) }
return s.handleInboundRPCAdmissionError(ctx, c, msgID, method, err)
} }
func (s *Server) handleInboundRPCAdmissionError(ctx context.Context, c *Conn, msgID int64, method string, err error) error { func (s *Server) handleInboundRPCAdmissionError(ctx context.Context, c *Conn, msgID int64, method string, err error) error {
@ -839,8 +772,18 @@ func (s *Server) handleInboundRPCAdmissionError(ctx context.Context, c *Conn, ms
// handleRPC 把明文 RPC 请求交给 RPC 路由,并将结果或错误包成 rpc_result 回发。 // handleRPC 把明文 RPC 请求交给 RPC 路由,并将结果或错误包成 rpc_result 回发。
func (s *Server) handleRPC(ctx context.Context, c *Conn, msgID int64, method string, b *bin.Buffer, responseGate *rpcResponseGate) error { func (s *Server) handleRPC(ctx context.Context, c *Conn, msgID int64, method string, b *bin.Buffer, responseGate *rpcResponseGate) error {
if s.rpc == nil { if s.rpc == nil {
s.log.Warn("No RPC handler configured; dropping request", zap.String("method", method)) if err := ctx.Err(); err != nil {
return nil return err
}
if responseGate != nil && !responseGate.tryNormal() {
return context.DeadlineExceeded
}
defer responseGate.finish()
s.log.Warn("No RPC handler configured", zap.String("method", method))
return s.sendResult(ctx, c, msgID, &mt.RPCError{
ErrorCode: 500,
ErrorMessage: "NOT_IMPLEMENTED",
})
} }
ctx = postresponse.WithCallbacks(ctx) ctx = postresponse.WithCallbacks(ctx)
@ -874,10 +817,48 @@ func (s *Server) handleRPC(ctx context.Context, c *Conn, msgID int64, method str
fields = dbtrace.AppendZapFields(fields, "", dbStats.Snapshot()) fields = dbtrace.AppendZapFields(fields, "", dbStats.Snapshot())
if ctxErr := ctx.Err(); ctxErr != nil { if ctxErr := ctx.Err(); ctxErr != nil {
// A canceled request context means neither a success nor an error can be delivered // The old physical Conn may have been replaced after the business transaction
// with this expired context. In particular, do not cache a late successful result and // committed. Never write with the expired context itself: a fenced generation
// hand it to outbound: a past write deadline would correctly poison that transport and // publishes cache-only for its replacement, while a still-live generation uses
// could prevent the scheduler's fresh-context RPC_TIMEOUT response from being sent. // a fresh bounded delivery context. A deadline callback that already won
// responseGate has published RPC_TIMEOUT and prevents this late result from
// overwriting it.
// Only a successful business result proves useful work completed. Errors
// observed after cancellation may themselves be cancellation-derived or
// transient and must remain retryable rather than poisoning the replay cache.
var terminal bin.Encoder
if err == nil && result != nil {
terminal = result
}
if terminal != nil && (responseGate == nil || responseGate.tryNormal()) {
defer responseGate.finish()
if c.terminal.Load() || !c.isPhysicalTransportCurrentOpen() {
// Replacement/shutdown already fenced this logical generation. Cache-only
// publication is safe and lets the replacement join the completed flight.
if encoded, encodeErr := s.encodeRPCResult(c, msgID, terminal); encodeErr != nil {
s.log.Warn("Encode canceled RPC result for replay failed", append(fields, zap.Error(encodeErr))...)
} else {
s.storeRPCResult(c, msgID, encoded)
postresponse.Run(context.WithoutCancel(ctx))
}
} else {
// An individual RPC deadline can expire while the physical connection is
// still healthy. Use a fresh bounded delivery context; publishing cache-only
// here would strand same-Conn duplicates behind an ACK with no result.
writeTimeout := c.writeTimeout
if writeTimeout <= 0 || writeTimeout > 5*time.Second {
writeTimeout = 5 * time.Second
}
responseCtx, cancel := context.WithTimeout(context.Background(), writeTimeout)
sendErr := s.sendResult(responseCtx, c, msgID, terminal)
cancel()
if sendErr != nil {
s.log.Debug("Send canceled RPC result failed", append(fields, zap.Error(sendErr))...)
} else {
postresponse.Run(context.WithoutCancel(ctx))
}
}
}
cancelFields := append(fields, zap.NamedError("context_error", ctxErr)) cancelFields := append(fields, zap.NamedError("context_error", ctxErr))
if err != nil { if err != nil {
cancelFields = append(cancelFields, zap.NamedError("dispatch_error", err)) cancelFields = append(cancelFields, zap.NamedError("dispatch_error", err))
@ -891,6 +872,7 @@ func (s *Server) handleRPC(ctx context.Context, c *Conn, msgID int64, method str
s.log.Info("RPC result suppressed after timeout", fields...) s.log.Info("RPC result suppressed after timeout", fields...)
return context.DeadlineExceeded return context.DeadlineExceeded
} }
defer responseGate.finish()
if err != nil { if err != nil {
var rpcErr *tgerr.Error var rpcErr *tgerr.Error
@ -921,6 +903,12 @@ func (s *Server) handleRPC(ctx context.Context, c *Conn, msgID int64, method str
// first owns the response, and the other path becomes a no-op. // first owns the response, and the other path becomes a no-op.
type rpcResponseGate struct { type rpcResponseGate struct {
state atomic.Uint32 state atomic.Uint32
done chan struct{}
once sync.Once
}
func newRPCResponseGate() *rpcResponseGate {
return &rpcResponseGate{done: make(chan struct{})}
} }
func (g *rpcResponseGate) tryNormal() bool { func (g *rpcResponseGate) tryNormal() bool {
@ -931,14 +919,84 @@ func (g *rpcResponseGate) tryTimeout() bool {
return g != nil && g.state.CompareAndSwap(0, 2) return g != nil && g.state.CompareAndSwap(0, 2)
} }
func (g *rpcResponseGate) finish() {
if g == nil || g.done == nil {
return
}
g.once.Do(func() { close(g.done) })
}
// wait returns once there is no response owner, the winning response has
// completed deliver-or-fence publication, or the bounded safety deadline wins.
func (g *rpcResponseGate) wait(timeout time.Duration) bool {
if g == nil || g.state.Load() == 0 {
return true
}
if g.done == nil {
return false
}
if timeout <= 0 {
<-g.done
return true
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-g.done:
return true
case <-timer.C:
return false
}
}
// sendResult 把 RPC 结果包成 rpc_result 并加密回发。 // sendResult 把 RPC 结果包成 rpc_result 并加密回发。
func (s *Server) sendResult(ctx context.Context, c *Conn, reqMsgID int64, result bin.Encoder) error { func (s *Server) sendResult(ctx context.Context, c *Conn, reqMsgID int64, result bin.Encoder) error {
if result == nil {
result = &mt.RPCError{ErrorCode: 500, ErrorMessage: "INTERNAL"}
}
encoded, err := s.encodeRPCResult(c, reqMsgID, result) encoded, err := s.encodeRPCResult(c, reqMsgID, result)
if err != nil { if err != nil {
// The business operation has already crossed atomic admission. Convert an
// invalid result encoder into one deterministic terminal RPC error instead of
// aborting the flight and allowing a reconnect to execute the operation again.
s.log.Warn("Encode RPC result failed; sending INTERNAL", zap.Int64("req_msg_id", reqMsgID), zap.Error(err))
encoded, err = s.encodeRPCResult(c, reqMsgID, &mt.RPCError{
ErrorCode: 500,
ErrorMessage: "INTERNAL",
})
if err != nil {
c.fenceUndeliveredRPCResult()
return err
}
}
if err := c.SendEncoded(ctx, proto.MessageServerResponse, encoded); err != nil {
// A completed result may be published before delivery only after this logical
// Conn is irreversibly fenced. SendEncoded has non-writing failure paths
// (queue/context/scratch deadline); without this terminal barrier a later
// same-Conn duplicate would be ACKed while no result can ever arrive.
c.fenceUndeliveredRPCResult()
s.storeRPCResult(c, reqMsgID, encoded)
return err return err
} }
// On a live Conn, completed means the rpc_result has reached the reliable byte
// stream. Same-physical duplicates can therefore be ACK-only without data loss.
s.storeRPCResult(c, reqMsgID, encoded) s.storeRPCResult(c, reqMsgID, encoded)
return c.SendEncoded(ctx, proto.MessageServerResponse, encoded) return nil
}
// sendCachedRPCResult preserves the delivery half of the rpc_result invariant
// for completed-flight replays: either the cached result reaches this physical
// byte stream, or this logical Conn is fenced so a replacement may retry it.
func (s *Server) sendCachedRPCResult(ctx context.Context, c *Conn, encoded *encodedOutboundMessage) error {
if encoded == nil {
c.fenceUndeliveredRPCResult()
return errors.New("nil cached rpc_result")
}
if err := c.SendEncoded(ctx, proto.MessageServerResponse, encoded); err != nil {
c.fenceUndeliveredRPCResult()
return err
}
return nil
} }
// encodeRPCResult 编码 rpc_result。内层对象与 rpc_result 头type_id + req_msg_id // encodeRPCResult 编码 rpc_result。内层对象与 rpc_result 头type_id + req_msg_id
@ -986,13 +1044,14 @@ func (s *Server) replayRPCResultByRequest(ctx context.Context, c *Conn, reqMsgID
return nil return nil
} }
if resent, err := c.ResendByRequest(ctx, reqMsgID); err != nil { if resent, err := c.ResendByRequest(ctx, reqMsgID); err != nil {
c.fenceUndeliveredRPCResult()
return err return err
} else if resent { } else if resent {
s.log.Debug("Resent connection cached rpc_result for duplicate msg_id", zap.Int64("msg_id", reqMsgID)) s.log.Debug("Resent connection cached rpc_result for duplicate msg_id", zap.Int64("msg_id", reqMsgID))
return nil return nil
} }
if cached, ok := s.cachedRPCResult(c, reqMsgID); ok { if cached, ok := s.cachedRPCResult(c, reqMsgID); ok {
if err := c.SendEncoded(ctx, proto.MessageServerResponse, cached); err != nil { if err := s.sendCachedRPCResult(ctx, c, cached); err != nil {
return err return err
} }
s.log.Debug("Resent session cached rpc_result for duplicate msg_id", zap.Int64("msg_id", reqMsgID)) s.log.Debug("Resent session cached rpc_result for duplicate msg_id", zap.Int64("msg_id", reqMsgID))
@ -1044,7 +1103,10 @@ func (s *Server) sendFutureSalts(ctx context.Context, c *Conn, reqMsgID int64, n
// 复用同一值会让断线重连后的 new_session_created 被吞掉,错过的差分补拉 // 复用同一值会让断线重连后的 new_session_created 被吞掉,错过的差分补拉
// Android 收到后才调 getDifference随之丢失。 // Android 收到后才调 getDifference随之丢失。
func (s *Server) sendNewSessionCreated(ctx context.Context, c *Conn, firstMsgID int64) error { func (s *Server) sendNewSessionCreated(ctx context.Context, c *Conn, firstMsgID int64) error {
return c.SendAsync(ctx, proto.MessageFromServer, &mt.NewSessionCreated{ // This notification changes the client's request map and update recovery
// state. Unlike best-effort ack/pong traffic, it must be written successfully
// before the corresponding RPC batch starts executing.
return c.SendRequiredControl(ctx, proto.MessageFromServer, &mt.NewSessionCreated{
FirstMsgID: firstMsgID, FirstMsgID: firstMsgID,
UniqueID: s.newServerSessionUID(), UniqueID: s.newServerSessionUID(),
ServerSalt: c.salt, ServerSalt: c.salt,
@ -1098,7 +1160,7 @@ func (s *Server) sendBadMsg(ctx context.Context, c *Conn, badMsgID int64, badSeq
// sendBadServerSalt 通知客户端修正 server_salterror_code 48 // sendBadServerSalt 通知客户端修正 server_salterror_code 48
func (s *Server) sendBadServerSalt(ctx context.Context, c *Conn, badMsgID int64, badSeqno int32, newSalt int64) error { func (s *Server) sendBadServerSalt(ctx context.Context, c *Conn, badMsgID int64, badSeqno int32, newSalt int64) error {
return c.SendPriority(ctx, proto.MessageFromServer, &mt.BadServerSalt{ return c.SendRequiredControl(ctx, proto.MessageFromServer, &mt.BadServerSalt{
BadMsgID: badMsgID, BadMsgID: badMsgID,
BadMsgSeqno: int(badSeqno), BadMsgSeqno: int(badSeqno),
ErrorCode: 48, ErrorCode: 48,
@ -1138,25 +1200,6 @@ func validateClientEnvelope(now time.Time, msgID int64, seqNo int32, typeID uint
return 0 return 0
} }
func validateClientContainer(containerMsgID int64, containerSeqNo int32, container proto.MessageContainer) int {
for _, m := range container.Messages {
if m.ID >= containerMsgID || int32(m.SeqNo) > containerSeqNo {
return badMsgContainer
}
typeID, err := (&bin.Buffer{Buf: m.Body}).PeekID()
if err != nil {
return badMsgContainer
}
if typeID == proto.MessageContainerTypeID {
return badMsgContainer
}
if code := validateClientContainerEnvelope(m.ID, int32(m.SeqNo), typeID); code != 0 {
return badMsgContainer
}
}
return 0
}
func validateClientContainerEnvelope(msgID int64, seqNo int32, typeID uint32) int { func validateClientContainerEnvelope(msgID int64, seqNo int32, typeID uint32) int {
if msgID == 0 || proto.MessageID(msgID).Type() != proto.MessageFromClient { if msgID == 0 || proto.MessageID(msgID).Type() != proto.MessageFromClient {
return badMsgIDInvalidBits return badMsgIDInvalidBits
@ -1235,10 +1278,15 @@ func (cs *connState) validateSeq(msgID int64, seqNo int32, content bool) int {
} }
func (cs *connState) track(msgID int64, seqNo int32, content bool, state byte) { func (cs *connState) track(msgID int64, seqNo int32, content bool, state byte) {
cs.trackInbound(msgID, seqNo, content, false, state)
}
func (cs *connState) trackInbound(msgID int64, seqNo int32, content, service bool, state byte) {
cs.seen[msgID] = clientMsgRecord{ cs.seen[msgID] = clientMsgRecord{
state: state, state: state,
seqNo: seqNo, seqNo: seqNo,
content: content, content: content,
service: service,
} }
if content { if content {
if msgID > cs.maxContentMsgID { if msgID > cs.maxContentMsgID {

View file

@ -40,25 +40,72 @@ func TestEncryptedPingPong(t *testing.T) {
} }
} }
// TestDuplicateMsgIDIdempotent 验证 M4相同 msg_id 的重复 content 请求被幂等处理, // TestDuplicateMsgIDIdempotent 验证相同物理连接上的重复 content 请求只重新 ACK
// server 重发已缓存的 rpc_result并重新 ack不重复执行业务。 // 原 owner 仍是唯一 rpc_result 发送者。若每次重复都重放完整结果Android 的
// bad_server_salt 全量重试会把一个启动批次放大成 N 轮孤儿结果并饿死新 request id。
func TestDuplicateMsgIDIdempotent(t *testing.T) { func TestDuplicateMsgIDIdempotent(t *testing.T) {
const dc = 2 const dc = 2
addr, pub, _ := startTestServer(t, Options{DC: dc}) handler := &admissionCountingRPC{}
addr, pub, _ := startTestServer(t, Options{DC: dc, RPC: handler})
conn, auth, cipher := dialHandshake(t, addr, dc, pub) conn, auth, cipher := dialHandshake(t, addr, dc, pub)
clientMsgID := proto.NewMessageIDGen(time.Now) clientMsgID := proto.NewMessageIDGen(time.Now)
msgID := clientMsgID.New(proto.MessageFromClient) msgID := clientMsgID.New(proto.MessageFromClient)
sendEncrypted(t, conn, cipher, auth, msgID, &mt.RPCDropAnswerRequest{ReqMsgID: msgID - 4}) sendEncrypted(t, conn, cipher, auth, msgID, &tg.HelpGetConfigRequest{})
first := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{
mustHave(t, first, proto.ResultTypeID, "first rpc_result") proto.ResultTypeID: 1,
mt.MsgsAckTypeID: 1,
})
waitForAtomicCalls(t, &handler.calls, 1)
// 相同 msg_id —— 幂等:重发已有 rpc_result并重新 ack。 // 用一个小型重试风暴覆盖完成后的 duplicate 路径。TCP 仍存活时原结果已在同一
sendEncrypted(t, conn, cipher, auth, msgID, &mt.RPCDropAnswerRequest{ReqMsgID: msgID - 4}) // 可靠字节流上;每个 duplicate 只需 ACK不应产生第二个 rpc_result。
second := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) const duplicateCount = 16
mustHave(t, second, proto.ResultTypeID, "resent rpc_result") for i := 0; i < duplicateCount; i++ {
mustHave(t, second, mt.MsgsAckTypeID, "second ack") sendEncrypted(t, conn, cipher, auth, msgID, &tg.HelpGetConfigRequest{})
}
frames := collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{
mt.MsgsAckTypeID: duplicateCount,
})
for _, frame := range frames {
if frame.TypeID == proto.ResultTypeID {
t.Fatalf("same-connection duplicate emitted an extra rpc_result")
}
}
if got := handler.calls.Load(); got != 1 {
t.Fatalf("same-connection duplicate business calls = %d, want 1", got)
}
}
func TestServiceDuplicateCannotReplaceOriginallyAdmittedPayload(t *testing.T) {
const dc = 2
addr, pub, _ := startTestServer(t, Options{DC: dc})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
ids := proto.NewMessageIDGen(time.Now)
msgID := ids.New(proto.MessageFromClient)
sendEncryptedWithSeq(t, conn, cipher, auth, msgID, 1, &mt.PingRequest{PingID: 11})
collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{
mt.PongTypeID: 1,
mt.MsgsAckTypeID: 1,
})
// Same id/seq/content parity but a destructive replacement body. Duplicate
// handling must use the original committed request class and never execute it.
sendEncryptedWithSeq(t, conn, cipher, auth, msgID, 1, &destroyAuthKeyRequest{})
collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{mt.MsgsAckTypeID: 1})
freshID := ids.New(proto.MessageFromClient)
sendEncryptedWithSeq(t, conn, cipher, auth, freshID, 3, &mt.PingRequest{PingID: 22})
replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.PongTypeID)
var pong mt.Pong
if err := pong.Decode(mustHave(t, replies, mt.PongTypeID, "pong after replacement attempt")); err != nil {
t.Fatalf("decode pong: %v", err)
}
if pong.MsgID != freshID || pong.PingID != 22 {
t.Fatalf("pong after replacement = %+v, want msg=%d ping=22", pong, freshID)
}
} }
// TestGetFutureSalts 验证 MTProto service message get_future_salts 由连接层直接响应, // TestGetFutureSalts 验证 MTProto service message get_future_salts 由连接层直接响应,
@ -268,6 +315,14 @@ func TestOldMessageInFreshContainerAccepted(t *testing.T) {
}) })
replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.PongTypeID) replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.PongTypeID)
createdBuf := mustHave(t, replies, mt.NewSessionCreatedTypeID, "new_session_created")
var created mt.NewSessionCreated
if err := created.Decode(createdBuf); err != nil {
t.Fatalf("decode new_session_created: %v", err)
}
if created.FirstMsgID != oldPingMsgID {
t.Fatalf("new_session_created.first_msg_id = %d, want accepted inner msg_id %d", created.FirstMsgID, oldPingMsgID)
}
buf := mustHave(t, replies, mt.PongTypeID, "pong") buf := mustHave(t, replies, mt.PongTypeID, "pong")
var pong mt.Pong var pong mt.Pong
if err := pong.Decode(buf); err != nil { if err := pong.Decode(buf); err != nil {
@ -382,8 +437,10 @@ func TestBadServerSalt(t *testing.T) {
wrongSalt := auth.ServerSalt + 1 wrongSalt := auth.ServerSalt + 1
sendEncryptedWithSalt(t, conn, cipher, auth, wrongSalt, reqMsgID, &mt.PingRequest{PingID: 1}) sendEncryptedWithSalt(t, conn, cipher, auth, wrongSalt, reqMsgID, &mt.PingRequest{PingID: 1})
replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.BadServerSaltTypeID) envelope, typeID, buf := readServerMessage(t, conn, cipher, auth.AuthKey)
buf := mustHave(t, replies, mt.BadServerSaltTypeID, "bad_server_salt") if typeID != mt.BadServerSaltTypeID {
t.Fatalf("bad salt reply type = %#x, want %#x", typeID, mt.BadServerSaltTypeID)
}
var bad mt.BadServerSalt var bad mt.BadServerSalt
if err := bad.Decode(buf); err != nil { if err := bad.Decode(buf); err != nil {
@ -398,6 +455,11 @@ func TestBadServerSalt(t *testing.T) {
if bad.NewServerSalt != auth.ServerSalt { if bad.NewServerSalt != auth.ServerSalt {
t.Fatalf("bad_server_salt.new_server_salt = %#x, want %#x", bad.NewServerSalt, auth.ServerSalt) t.Fatalf("bad_server_salt.new_server_salt = %#x, want %#x", bad.NewServerSalt, auth.ServerSalt)
} }
// DrKLO stores the salt from the encrypted envelope, not only the TL payload.
// A mismatch makes every correction ineffective and re-enters the resend storm.
if envelope.Salt != bad.NewServerSalt {
t.Fatalf("bad_server_salt envelope salt = %#x, payload = %#x", envelope.Salt, bad.NewServerSalt)
}
} }
func TestBadMsgSeqOddExpected(t *testing.T) { func TestBadMsgSeqOddExpected(t *testing.T) {
@ -470,7 +532,7 @@ func TestBadMsgSeqTooHigh(t *testing.T) {
func TestSessionChangeResetsClientSeqState(t *testing.T) { func TestSessionChangeResetsClientSeqState(t *testing.T) {
const dc = 2 const dc = 2
addr, pub, _ := startTestServer(t, Options{DC: dc}) addr, pub, srv := startTestServer(t, Options{DC: dc})
conn, auth, cipher := dialHandshake(t, addr, dc, pub) conn, auth, cipher := dialHandshake(t, addr, dc, pub)
clientMsgID := proto.NewMessageIDGen(time.Now) clientMsgID := proto.NewMessageIDGen(time.Now)
@ -492,6 +554,19 @@ func TestSessionChangeResetsClientSeqState(t *testing.T) {
} }
mustHave(t, replies, mt.NewSessionCreatedTypeID, "new_session_created after session change") mustHave(t, replies, mt.NewSessionCreatedTypeID, "new_session_created after session change")
mustHave(t, replies, mt.MsgsAckTypeID, "msgs_ack after session change") mustHave(t, replies, mt.MsgsAckTypeID, "msgs_ack after session change")
oldKey := sessionKey{authKeyID: auth.AuthKey.ID, sessionID: auth.SessionID}
newKey := sessionKey{authKeyID: auth.AuthKey.ID, sessionID: nextSessionID}
srv.conns.mu.RLock()
_, oldVisible := srv.conns.bySession[oldKey]
newConn := srv.conns.bySession[newKey]
claims := len(srv.conns.claims)
online := len(srv.conns.bySession)
srv.conns.mu.RUnlock()
if oldVisible || newConn == nil || !newConn.isActive() || claims != 0 || online != 1 {
t.Fatalf("same-transport switch state: old=%v new=%p active=%v claims=%d online=%d",
oldVisible, newConn, newConn != nil && newConn.isActive(), claims, online)
}
} }
func readBadMsgNotification(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key crypto.AuthKey) mt.BadMsgNotification { func readBadMsgNotification(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key crypto.AuthKey) mt.BadMsgNotification {

View file

@ -190,12 +190,82 @@ func collectReplies(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key
return got return got
} }
// serverReplyFrame preserves the wire order and encrypted envelope of a server
// reply. Tests which exercise session boundaries must not collapse replies into
// a TypeID-keyed map: both duplicate response types and their order are part of
// the observable protocol behavior.
type serverReplyFrame struct {
Message *crypto.EncryptedMessageData
TypeID uint32
Plain *bin.Buffer
}
// collectReplyFrames reads ordered server replies until every requested TypeID
// has been observed the requested number of times. Unrequested frames are kept
// in the returned slice so callers can assert ordering around control messages.
func collectReplyFrames(
t *testing.T,
conn transport.Conn,
cipher crypto.Cipher,
key crypto.AuthKey,
wantCounts map[uint32]int,
) []serverReplyFrame {
t.Helper()
remaining := make(map[uint32]int, len(wantCounts))
required := 0
for typeID, count := range wantCounts {
if count <= 0 {
continue
}
remaining[typeID] = count
required += count
}
if required == 0 {
return nil
}
// Keep the helper bounded while allowing unrelated control replies (notably
// msgs_ack) to be interleaved with the frames under test.
// One shared deadline bounds the whole collection. A per-frame deadline would
// multiply a missing-result failure by the maximum number of unrelated frames.
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
frames := make([]serverReplyFrame, 0, required)
for i := 0; i < required+16; i++ {
message, typeID, plain := readServerMessageContext(t, ctx, conn, cipher, key)
frames = append(frames, serverReplyFrame{
Message: message,
TypeID: typeID,
Plain: plain,
})
if count, ok := remaining[typeID]; ok {
if count == 1 {
delete(remaining, typeID)
} else {
remaining[typeID] = count - 1
}
}
if len(remaining) == 0 {
return frames
}
}
t.Fatalf("missing reply counts after %d frames: %+v", len(frames), remaining)
return nil
}
func readServerMessage(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key crypto.AuthKey) (*crypto.EncryptedMessageData, uint32, *bin.Buffer) { func readServerMessage(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key crypto.AuthKey) (*crypto.EncryptedMessageData, uint32, *bin.Buffer) {
t.Helper() t.Helper()
var buf bin.Buffer
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return readServerMessageContext(t, ctx, conn, cipher, key)
}
func readServerMessageContext(t *testing.T, ctx context.Context, conn transport.Conn, cipher crypto.Cipher, key crypto.AuthKey) (*crypto.EncryptedMessageData, uint32, *bin.Buffer) {
t.Helper()
var buf bin.Buffer
err := conn.Recv(ctx, &buf) err := conn.Recv(ctx, &buf)
cancel()
if err != nil { if err != nil {
t.Fatalf("recv server message: %v", err) t.Fatalf("recv server message: %v", err)
} }

View file

@ -0,0 +1,880 @@
package mtprotoedge
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"time"
"go.uber.org/zap"
"github.com/gotd/td/bin"
"github.com/gotd/td/mt"
"github.com/gotd/td/proto"
)
type inboundItemKind uint8
const (
inboundItemDuplicate inboundItemKind = iota + 1
inboundItemServiceDuplicate
inboundItemPing
inboundItemPingDelay
inboundItemFutureSalts
inboundItemMsgsAck
inboundItemStateReq
inboundItemResendReq
inboundItemStateInfo
inboundItemAllInfo
inboundItemDestroySession
inboundItemHTTPWait
inboundItemDropAnswer
inboundItemDestroyAuthKey
inboundItemRPC
inboundItemCapacityError
inboundItemPendingRPC
// inboundItemReplayRPC is a request first observed by this physical Conn whose
// terminal result already exists in the cross-connection cache. It is distinct
// from inboundItemDuplicate: a duplicate already present in this Conn's seen
// table must only be ACKed. The original owner/result is already using the same
// reliable TCP stream, so replaying it once per retransmit wave amplifies a
// client retry burst and can starve newer request IDs behind orphan results.
inboundItemReplayRPC
)
type inboundItem struct {
kind inboundItemKind
msgID int64
seqNo int32
typeID uint32
content bool
body []byte
payload any
}
type stagedClientMessage struct {
msgID int64
seqNo int32
content bool
service bool
}
type inboundPlan struct {
items []inboundItem
staged []stagedClientMessage
ackIDs []int64
logicalMin int64
releases []func()
rpcPrepared bool
rpcReservation *inboundRPCBatchReservation
rpcTasks []inboundRPC
rpcOwners []*rpcResultOwnerLease
}
func (p *inboundPlan) close() {
if p == nil {
return
}
if p.rpcReservation != nil {
p.rpcReservation.abort()
p.rpcReservation = nil
}
for _, owner := range p.rpcOwners {
owner.Abort()
}
p.rpcOwners = nil
for i := len(p.releases) - 1; i >= 0; i-- {
p.releases[i]()
}
p.releases = nil
}
func (p *inboundPlan) commitRPCBatch() error {
if p == nil || p.rpcReservation == nil {
return nil
}
// handleEncrypted calls this only after ownership/session-control barriers,
// connState commit and every synchronous service action have completed. The
// batch is runnable immediately; using the old deferred scheduler token here
// was not a real barrier on a busy Conn because an existing ready token could
// dequeue newly appended tasks before activateRPCBatch ran.
_, err := p.rpcReservation.commit(p.rpcTasks, false)
if err != nil {
return err
}
p.rpcReservation = nil
p.rpcTasks = nil
// Every owner is now attached to exactly one queued task. Its task.release
// path aborts if no terminal rpc_result was published.
p.rpcOwners = nil
return nil
}
func (p *inboundPlan) includeLogicalID(msgID int64) {
if p.logicalMin == 0 || msgID < p.logicalMin {
p.logicalMin = msgID
}
}
func (p *inboundPlan) commitState(cs *connState) {
for _, m := range p.staged {
cs.trackInbound(m.msgID, m.seqNo, m.content, m.service, msgStateReceived)
}
}
type connStateOverlay struct {
base *connState
staged []stagedClientMessage
maxContentMsgID int64
maxContentSeqNo int32
}
func newConnStateOverlay(base *connState) connStateOverlay {
return connStateOverlay{
base: base,
maxContentMsgID: base.maxContentMsgID,
maxContentSeqNo: base.maxContentSeqNo,
}
}
func (o *connStateOverlay) seenRecord(msgID int64) (clientMsgRecord, bool) {
for i := len(o.staged) - 1; i >= 0; i-- {
m := o.staged[i]
if m.msgID == msgID {
return clientMsgRecord{state: msgStateReceived, seqNo: m.seqNo, content: m.content, service: m.service}, true
}
}
return o.base.seenRecord(msgID)
}
func (o *connStateOverlay) validateSeq(msgID int64, seqNo int32, content bool) int {
if !content {
return 0
}
if msgID > o.maxContentMsgID && seqNo > o.maxContentSeqNo {
return 0
}
for seenMsgID, record := range o.base.seen {
if !record.content {
continue
}
if seenMsgID < msgID && record.seqNo >= seqNo {
return badMsgSeqTooLow
}
if seenMsgID > msgID && record.seqNo <= seqNo {
return badMsgSeqTooHigh
}
}
for _, record := range o.staged {
if !record.content {
continue
}
if record.msgID < msgID && record.seqNo >= seqNo {
return badMsgSeqTooLow
}
if record.msgID > msgID && record.seqNo <= seqNo {
return badMsgSeqTooHigh
}
}
return 0
}
func (o *connStateOverlay) stage(msgID int64, seqNo int32, content, service bool) {
o.staged = append(o.staged, stagedClientMessage{msgID: msgID, seqNo: seqNo, content: content, service: service})
if content {
if msgID > o.maxContentMsgID {
o.maxContentMsgID = msgID
}
if seqNo > o.maxContentSeqNo {
o.maxContentSeqNo = seqNo
}
}
}
type inboundScope struct {
insideContainer bool
mustBeDuplicate bool
}
type inboundPreflightBudget struct {
depth int
containerDepth int
expanded int
now time.Time
}
func (s *Server) preflightInbound(cs *connState, msgID int64, seqNo int32, body []byte) (*inboundPlan, error) {
plan := &inboundPlan{logicalMin: 0}
overlay := newConnStateOverlay(cs)
// All envelope checks in one transport frame use the same clock sample. Apart
// from making boundary behavior deterministic, this lets walkInbound reject
// an invalid outer msg_id before spending CPU or memory on gzip expansion.
budget := &inboundPreflightBudget{now: s.clock.Now()}
if err := s.walkInbound(plan, &overlay, msgID, seqNo, body, inboundScope{}, budget); err != nil {
plan.close()
return nil, err
}
plan.staged = overlay.staged
if plan.logicalMin == 0 {
plan.close()
return nil, fmt.Errorf("inbound plan accepted no logical message")
}
for _, item := range plan.items {
if item.kind == inboundItemDestroyAuthKey && len(plan.items) != 1 {
plan.close()
return nil, &dispatchBadMsgError{msgID: msgID, seqNo: seqNo, code: badMsgContainer}
}
}
return plan, nil
}
func (s *Server) walkInbound(
plan *inboundPlan,
overlay *connStateOverlay,
msgID int64,
seqNo int32,
body []byte,
scope inboundScope,
budget *inboundPreflightBudget,
) error {
if budget.depth > maxDispatchDepth {
return fmt.Errorf("mtproto wrapper depth %d exceeds %d", budget.depth, maxDispatchDepth)
}
b := &bin.Buffer{Buf: body}
typeID, err := b.PeekID()
if err != nil {
return fmt.Errorf("peek type id: %w", err)
}
if code := validateInboundMessageID(budget.now, msgID, scope.insideContainer); code != 0 {
if scope.insideContainer {
code = badMsgContainer
}
return &dispatchBadMsgError{msgID: msgID, seqNo: seqNo, code: code}
}
// A repeated outer container still has to be decoded far enough to enumerate
// its inner request ids, but none of those already-accepted inner bodies needs
// decoding again. In particular, never reinflate a gzip body merely to discover
// the content bit we already retained with its msg_id. If an outer duplicate
// introduces an unseen inner id, reject the container before touching its body.
// A seen top-level content gzip is also safe to short-circuit: a container is
// non-content, so it cannot be hidden behind that retained record. Ambiguous
// non-content top-level gzip still expands to distinguish a container wrapper.
if record, seen := overlay.seenRecord(msgID); scope.mustBeDuplicate ||
(typeID == proto.GZIPTypeID && seen && (scope.insideContainer || record.content)) {
if !seen || record.seqNo != seqNo {
return &dispatchBadMsgError{msgID: msgID, seqNo: seqNo, code: badMsgContainer}
}
return appendInboundDuplicate(plan, msgID, seqNo, typeID, record)
}
if typeID == proto.GZIPTypeID {
data, release, err := s.decodeGZIPWithGlobalBudget(b)
if err != nil {
return fmt.Errorf("decode gzip: %w", err)
}
plan.releases = append(plan.releases, release)
budget.expanded += len(data)
if budget.expanded > maxDispatchExpandedBytes {
return fmt.Errorf("cumulative gzip expansion %d exceeds %d", budget.expanded, maxDispatchExpandedBytes)
}
budget.depth++
err = s.walkInbound(plan, overlay, msgID, seqNo, data, scope, budget)
budget.depth--
return err
}
if typeID == proto.MessageContainerTypeID {
if scope.insideContainer || budget.containerDepth != 0 {
return &dispatchBadMsgError{msgID: msgID, seqNo: seqNo, code: badMsgContainer}
}
if code := validateClientEnvelope(budget.now, msgID, seqNo, typeID); code != 0 {
return &dispatchBadMsgError{msgID: msgID, seqNo: seqNo, code: code}
}
outerRecord, outerSeen := overlay.seenRecord(msgID)
if outerSeen {
if outerRecord.content || outerRecord.seqNo != seqNo {
return &dispatchBadMsgError{msgID: msgID, seqNo: seqNo, code: badMsgContainer}
}
} else {
if code := overlay.validateSeq(msgID, seqNo, false); code != 0 {
return &dispatchBadMsgError{msgID: msgID, seqNo: seqNo, code: code}
}
overlay.stage(msgID, seqNo, false, false)
}
count, err := containerMessageCount(b)
if err != nil {
return fmt.Errorf("decode container count: %w", err)
}
if count > maxContainerMessages {
return &dispatchBadMsgError{msgID: msgID, seqNo: seqNo, code: badMsgContainer}
}
container, release, err := s.decodeMessageContainerViews(b, count)
if err != nil {
return fmt.Errorf("decode container: %w", err)
}
plan.releases = append(plan.releases, release)
plan.items = growInboundItems(plan.items, count)
plan.ackIDs = growInt64s(plan.ackIDs, count)
overlay.staged = growStagedMessages(overlay.staged, count+1)
if len(container.Messages) == 0 {
plan.includeLogicalID(msgID)
return nil
}
budget.depth++
budget.containerDepth++
for _, m := range container.Messages {
if m.ID >= msgID || int32(m.SeqNo) > seqNo {
return &dispatchBadMsgError{msgID: msgID, seqNo: seqNo, code: badMsgContainer}
}
if err := s.walkInbound(plan, overlay, m.ID, int32(m.SeqNo), m.Body, inboundScope{
insideContainer: true,
mustBeDuplicate: outerSeen,
}, budget); err != nil {
return err
}
}
budget.containerDepth--
budget.depth--
return nil
}
if scope.insideContainer {
if code := validateClientContainerEnvelope(msgID, seqNo, typeID); code != 0 {
return &dispatchBadMsgError{msgID: msgID, seqNo: seqNo, code: badMsgContainer}
}
} else if code := validateClientEnvelope(budget.now, msgID, seqNo, typeID); code != 0 {
return &dispatchBadMsgError{msgID: msgID, seqNo: seqNo, code: code}
}
content := clientMessageNeedsAck(typeID)
if record, seen := overlay.seenRecord(msgID); seen {
if record.seqNo != seqNo || record.content != content {
return &dispatchBadMsgError{msgID: msgID, seqNo: seqNo, code: badMsgContainer}
}
return appendInboundDuplicate(plan, msgID, seqNo, typeID, record)
}
if scope.mustBeDuplicate {
return &dispatchBadMsgError{msgID: msgID, seqNo: seqNo, code: badMsgContainer}
}
if code := overlay.validateSeq(msgID, seqNo, content); code != 0 {
return &dispatchBadMsgError{msgID: msgID, seqNo: seqNo, code: code}
}
overlay.stage(msgID, seqNo, content, inboundTypeIsService(typeID))
plan.includeLogicalID(msgID)
item, err := preflightInboundItem(msgID, seqNo, typeID, content, body)
if err != nil {
return err
}
plan.items = append(plan.items, item)
if content {
plan.ackIDs = append(plan.ackIDs, msgID)
}
return nil
}
func validateInboundMessageID(now time.Time, msgID int64, insideContainer bool) int {
if msgID == 0 || proto.MessageID(msgID).Type() != proto.MessageFromClient {
return badMsgIDInvalidBits
}
// A container's outer envelope supplies the wall-clock admission boundary for
// its inner messages. Inner ids still need the client low bits checked before
// wrapper expansion, but intentionally keep the established no-time-check rule.
if insideContainer {
return 0
}
msgTime := proto.MessageID(msgID).Time()
if msgTime.Before(now.Add(-300 * time.Second)) {
return badMsgIDTooLow
}
if msgTime.After(now.Add(30 * time.Second)) {
return badMsgIDTooHigh
}
return 0
}
func appendInboundDuplicate(plan *inboundPlan, msgID int64, seqNo int32, typeID uint32, record clientMsgRecord) error {
plan.includeLogicalID(msgID)
kind := inboundItemDuplicate
if record.service {
kind = inboundItemServiceDuplicate
}
plan.items = append(plan.items, inboundItem{
kind: kind, msgID: msgID, seqNo: seqNo, typeID: typeID, content: record.content,
})
if record.content {
plan.ackIDs = append(plan.ackIDs, msgID)
}
return nil
}
func inboundTypeIsService(typeID uint32) bool {
switch typeID {
case mt.PingRequestTypeID,
mt.PingDelayDisconnectRequestTypeID,
mt.GetFutureSaltsRequestTypeID,
mt.MsgsAckTypeID,
mt.MsgsStateReqTypeID,
mt.MsgResendReqTypeID,
mt.MsgsStateInfoTypeID,
mt.MsgsAllInfoTypeID,
mt.DestroySessionRequestTypeID,
mt.HTTPWaitRequestTypeID,
mt.RPCDropAnswerRequestTypeID,
destroyAuthKeyRequestTypeID:
return true
default:
return false
}
}
func growInboundItems(items []inboundItem, extra int) []inboundItem {
if extra <= cap(items)-len(items) {
return items
}
grown := make([]inboundItem, len(items), len(items)+extra)
copy(grown, items)
return grown
}
func growStagedMessages(items []stagedClientMessage, extra int) []stagedClientMessage {
if extra <= cap(items)-len(items) {
return items
}
grown := make([]stagedClientMessage, len(items), len(items)+extra)
copy(grown, items)
return grown
}
func growInt64s(items []int64, extra int) []int64 {
if extra <= cap(items)-len(items) {
return items
}
grown := make([]int64, len(items), len(items)+extra)
copy(grown, items)
return grown
}
type stateInfoPayload struct {
reqMsgID int64
info []byte
}
type allInfoPayload struct {
count int
info []byte
}
// int64VectorView is a bounded, immutable view of a TL Vector<long>. Keeping the
// encoded bytes avoids one retained []int64 allocation per service message while
// a whole container is in preflight. Execution materializes the small bounded
// slice only for the existing Conn APIs that require []int64, after all wrappers
// and every sibling message have already passed structural validation.
type int64VectorView struct {
raw []byte
count int
}
func decodeInt64VectorView(b *bin.Buffer, expectedTypeID uint32, max int) (int64VectorView, error) {
if b == nil || len(b.Buf) < 12 {
return int64VectorView{}, io.ErrUnexpectedEOF
}
if got := binary.LittleEndian.Uint32(b.Buf[:4]); got != expectedTypeID {
return int64VectorView{}, fmt.Errorf("unexpected constructor %#x", got)
}
if got := binary.LittleEndian.Uint32(b.Buf[4:8]); got != bin.TypeVector {
return int64VectorView{}, fmt.Errorf("unexpected vector constructor %#x", got)
}
count := int(int32(binary.LittleEndian.Uint32(b.Buf[8:12])))
if count < 0 {
return int64VectorView{}, fmt.Errorf("negative vector count %d", count)
}
if count > max {
return int64VectorView{}, fmt.Errorf("vector count %d exceeds %d", count, max)
}
if count > (len(b.Buf)-12)/8 {
return int64VectorView{}, io.ErrUnexpectedEOF
}
end := 12 + count*8
if end != len(b.Buf) {
return int64VectorView{}, fmt.Errorf("long vector has %d trailing bytes", len(b.Buf)-end)
}
return int64VectorView{raw: b.Buf[12:end:end], count: count}, nil
}
func (v int64VectorView) materialize() []int64 {
if v.count == 0 {
return nil
}
ids := make([]int64, v.count)
for i := range ids {
offset := i * 8
ids[i] = int64(binary.LittleEndian.Uint64(v.raw[offset : offset+8]))
}
return ids
}
func decodeInboundServiceExact(b *bin.Buffer, decoder bin.Decoder) error {
if err := decoder.Decode(b); err != nil {
return err
}
if b.Len() != 0 {
return fmt.Errorf("service message has %d trailing bytes", b.Len())
}
return nil
}
func preflightInboundItem(msgID int64, seqNo int32, typeID uint32, content bool, body []byte) (inboundItem, error) {
item := inboundItem{msgID: msgID, seqNo: seqNo, typeID: typeID, content: content, body: body}
b := &bin.Buffer{Buf: body}
switch typeID {
case mt.PingRequestTypeID:
var value mt.PingRequest
if err := decodeInboundServiceExact(b, &value); err != nil {
return item, fmt.Errorf("decode ping: %w", err)
}
item.kind, item.payload = inboundItemPing, value
case mt.PingDelayDisconnectRequestTypeID:
var value mt.PingDelayDisconnectRequest
if err := decodeInboundServiceExact(b, &value); err != nil {
return item, fmt.Errorf("decode ping_delay_disconnect: %w", err)
}
item.kind, item.payload = inboundItemPingDelay, value
case mt.GetFutureSaltsRequestTypeID:
var value mt.GetFutureSaltsRequest
if err := decodeInboundServiceExact(b, &value); err != nil {
return item, fmt.Errorf("decode get_future_salts: %w", err)
}
item.kind, item.payload = inboundItemFutureSalts, value
case mt.MsgsAckTypeID:
value, err := decodeInt64VectorView(b, mt.MsgsAckTypeID, maxServiceMessageIDs)
if err != nil {
return item, fmt.Errorf("decode msgs_ack: %w", err)
}
item.kind, item.payload = inboundItemMsgsAck, value
case mt.MsgsStateReqTypeID:
value, err := decodeInt64VectorView(b, mt.MsgsStateReqTypeID, maxServiceMessageIDs)
if err != nil {
return item, fmt.Errorf("decode msgs_state_req: %w", err)
}
item.kind, item.payload = inboundItemStateReq, value
case mt.MsgResendReqTypeID:
value, err := decodeInt64VectorView(b, mt.MsgResendReqTypeID, maxServiceMessageIDs)
if err != nil {
return item, fmt.Errorf("decode msg_resend_req: %w", err)
}
item.kind, item.payload = inboundItemResendReq, value
case mt.MsgsStateInfoTypeID:
reqMsgID, info, err := msgsStateInfoView(b)
if err != nil {
return item, fmt.Errorf("decode msgs_state_info: %w", err)
}
item.kind, item.payload = inboundItemStateInfo, stateInfoPayload{reqMsgID: reqMsgID, info: info}
case mt.MsgsAllInfoTypeID:
count, info, err := msgsAllInfoView(b)
if err != nil {
return item, fmt.Errorf("decode msgs_all_info: %w", err)
}
if len(info) != count {
return item, fmt.Errorf("decode msgs_all_info: info length %d does not match msg_ids %d", len(info), count)
}
item.kind, item.payload = inboundItemAllInfo, allInfoPayload{count: count, info: info}
case mt.DestroySessionRequestTypeID:
var value mt.DestroySessionRequest
if err := decodeInboundServiceExact(b, &value); err != nil {
return item, fmt.Errorf("decode destroy_session: %w", err)
}
item.kind, item.payload = inboundItemDestroySession, value
case mt.HTTPWaitRequestTypeID:
var value mt.HTTPWaitRequest
if err := decodeInboundServiceExact(b, &value); err != nil {
return item, fmt.Errorf("decode http_wait: %w", err)
}
item.kind, item.payload = inboundItemHTTPWait, value
case mt.RPCDropAnswerRequestTypeID:
var value mt.RPCDropAnswerRequest
if err := decodeInboundServiceExact(b, &value); err != nil {
return item, fmt.Errorf("decode rpc_drop_answer: %w", err)
}
item.kind, item.payload = inboundItemDropAnswer, value
case destroyAuthKeyRequestTypeID:
var value destroyAuthKeyRequest
if err := decodeInboundServiceExact(b, &value); err != nil {
return item, err
}
item.kind, item.payload = inboundItemDestroyAuthKey, value
default:
item.kind = inboundItemRPC
}
return item, nil
}
// prepareInboundRPCBatch performs the whole container's count/byte admission
// before copying or scheduling any API RPC. Capacity exhaustion is converted
// into one consistent terminal FLOOD_WAIT result per uncached RPC; no business
// handler from the batch is allowed to start in that case.
func (s *Server) prepareInboundRPCBatch(ctx context.Context, c *Conn, plan *inboundPlan) error {
plan.rpcPrepared = true
// Keep service-only frames (ping/ack/http_wait) allocation-free here. These
// collections are needed only after the first real API RPC acquires ownership.
var indices []int
var specs []inboundRPCSpec
var ownersInPlan map[int64]*rpcResultOwnerLease
flightCapacity := false
for i := range plan.items {
item := &plan.items[i]
if item.kind != inboundItemRPC && item.kind != inboundItemDuplicate {
continue
}
localDuplicate := item.kind == inboundItemDuplicate
if localDuplicate {
// connState already proves this msg_id was admitted on this physical
// generation. Its original owner either still holds the flight or has
// published a result after physical write; do not consume a global flight
// slot merely to ACK the duplicate.
continue
}
method := s.typeName(item.typeID)
claim, err := s.rpcResults.Acquire(c.authKeyID, c.sessionID, item.msgID)
if errors.Is(err, ErrRPCResultFlightCapacity) {
if item.kind == inboundItemRPC {
c.metrics.InboundRPCDropped(method, "flight_capacity")
flightCapacity = true
item.kind = inboundItemCapacityError
}
continue
}
if err != nil {
return err
}
switch claim.state {
case rpcResultAcquireCompleted:
s.log.Info("RPC duplicate replay from session cache",
zap.String("method", method),
zap.Int64("msg_id", item.msgID),
zap.String("auth_key_id", c.authKeyHex),
zap.Int64("session_id", c.sessionID),
)
item.kind = inboundItemReplayRPC
item.payload = claim.encoded
case rpcResultAcquirePending:
// A malformed/replayed container may repeat the same msg_id after this
// very plan installed its owner. More generally, any request already in
// this Conn's seen table shares the owner's reliable response path. Only
// a fresh physical replacement may wait for and replay the old result.
if ownersInPlan[item.msgID] != nil {
item.kind = inboundItemDuplicate
item.payload = nil
} else {
item.kind = inboundItemPendingRPC
item.payload = claim.waiter
}
case rpcResultAcquireOwner:
if item.kind == inboundItemDuplicate {
// connState says this request was already accepted, so absence from
// both completed and in-flight tables is not authority to execute it.
claim.owner.Abort()
continue
}
if ownersInPlan == nil {
ownersInPlan = make(map[int64]*rpcResultOwnerLease)
indices = make([]int, 0, len(plan.items))
specs = make([]inboundRPCSpec, 0, len(plan.items))
}
ownersInPlan[item.msgID] = claim.owner
plan.rpcOwners = append(plan.rpcOwners, claim.owner)
item.payload = claim.owner
indices = append(indices, i)
specs = append(specs, inboundRPCSpec{method: method, size: len(item.body)})
default:
return ErrRPCResultFlightInvalid
}
}
if flightCapacity {
// One container is one API admission unit. If the cross-connection
// exactly-once table cannot claim every fresh request, none of this
// batch may reach a business handler.
for _, index := range indices {
plan.items[index].kind = inboundItemCapacityError
}
return nil
}
if len(specs) == 0 {
return nil
}
reservation, err := c.reserveInboundRPCBatch(ctx, specs)
if err != nil {
if errors.Is(err, ErrInboundRPCQueueFull) {
for _, index := range indices {
plan.items[index].kind = inboundItemCapacityError
}
return nil
}
return err
}
plan.rpcReservation = reservation
plan.rpcTasks = make([]inboundRPC, len(indices))
for i, index := range indices {
item := &plan.items[index]
body := append([]byte(nil), item.body...)
owner, _ := item.payload.(*rpcResultOwnerLease)
plan.rpcTasks[i] = s.newInboundRPCTask(c, item.msgID, specs[i].method, body, owner)
}
return nil
}
func (s *Server) executeInboundPlan(ctx context.Context, cs *connState, c *Conn, plan *inboundPlan) error {
for _, item := range plan.items {
switch item.kind {
case inboundItemDuplicate:
// The preflight plan already stages a content ACK for a locally seen
// duplicate. Do not wait for or replay its result on the same reliable
// stream; the original owner is solely responsible for that response.
continue
case inboundItemServiceDuplicate:
// Classify from the originally committed connState record, never from the
// retransmitted body. This prevents same-id payload replacement. Only an
// already cached answer is eligible for resend (rpc_drop_answer today);
// other best-effort service traffic uses a later fresh request.
if err := s.replayRPCResultByRequest(ctx, c, item.msgID); err != nil {
return err
}
case inboundItemReplayRPC:
if encoded, _ := item.payload.(*encodedOutboundMessage); encoded != nil {
if err := s.sendCachedRPCResult(ctx, c, encoded); err != nil {
return err
}
} else if err := s.replayRPCResultByRequest(ctx, c, item.msgID); err != nil {
return err
}
case inboundItemPendingRPC:
// Wait only after this plan's fresh RPC batch has become runnable; see
// executePendingRPCReplays. Blocking here could otherwise deadlock on
// an owner appended by the same container.
continue
case inboundItemPing:
if err := s.sendPong(ctx, c, item.msgID, item.payload.(mt.PingRequest).PingID); err != nil {
return err
}
case inboundItemPingDelay:
if err := s.sendPong(ctx, c, item.msgID, item.payload.(mt.PingDelayDisconnectRequest).PingID); err != nil {
return err
}
case inboundItemFutureSalts:
if err := s.sendFutureSalts(ctx, c, item.msgID, item.payload.(mt.GetFutureSaltsRequest).Num); err != nil {
return err
}
case inboundItemMsgsAck:
ids := item.payload.(int64VectorView).materialize()
c.AckServerMessages(ids)
s.log.Debug("Received msgs_ack", zap.Int64s("msg_ids", ids))
case inboundItemStateReq:
ids := item.payload.(int64VectorView).materialize()
outgoing, err := c.OutgoingStateInfo(ctx, ids)
if err != nil {
return err
}
if err := s.sendMsgsStateInfo(ctx, c, item.msgID, mergeStateInfo(outgoing, cs.stateInfo(ids))); err != nil {
return err
}
case inboundItemResendReq:
ids := item.payload.(int64VectorView).materialize()
outgoing, err := c.ResendMessages(ctx, ids)
if err != nil {
return err
}
if err := s.sendMsgsStateInfo(ctx, c, item.msgID, mergeStateInfo(outgoing, cs.stateInfo(ids))); err != nil {
return err
}
case inboundItemStateInfo:
value := item.payload.(stateInfoPayload)
s.log.Debug("Received msgs_state_info", zap.Int64("req_msg_id", value.reqMsgID), zap.Int("len", len(value.info)))
case inboundItemAllInfo:
value := item.payload.(allInfoPayload)
s.log.Debug("Received msgs_all_info", zap.Int("msg_ids", value.count), zap.Int("len", len(value.info)))
case inboundItemDestroySession:
if err := s.sendDestroySession(ctx, c, item.payload.(mt.DestroySessionRequest).SessionID); err != nil {
return err
}
case inboundItemHTTPWait:
value := item.payload.(mt.HTTPWaitRequest)
s.log.Debug("Received http_wait", zap.Int("max_delay", value.MaxDelay), zap.Int("wait_after", value.WaitAfter), zap.Int("max_wait", value.MaxWait))
case inboundItemDropAnswer:
value := item.payload.(mt.RPCDropAnswerRequest)
s.log.Debug("Received rpc_drop_answer", zap.Int64("req_msg_id", value.ReqMsgID))
if err := s.sendResult(ctx, c, item.msgID, &mt.RPCAnswerUnknown{}); err != nil {
return err
}
case inboundItemDestroyAuthKey:
s.log.Debug("Received destroy_auth_key", zap.String("auth_key_id", c.authKeyHex))
if err := s.authKeys.Delete(ctx, c.authKeyID); err != nil {
s.log.Warn("Delete auth key failed", zap.String("auth_key_id", c.authKeyHex), zap.Error(err))
return c.SendAsync(ctx, proto.MessageServerResponse, &destroyAuthKeyFail{})
}
c.keyDestroyed.Store(true)
s.conns.CloseSessionsForRawAuthKeyExceptConn(c.authKeyID, c)
return c.SendAsync(ctx, proto.MessageServerResponse, &destroyAuthKeyOk{})
case inboundItemRPC:
if plan.rpcPrepared {
continue
}
if err := s.enqueueRPC(ctx, c, item.msgID, item.typeID, &bin.Buffer{Buf: item.body}); err != nil {
return err
}
case inboundItemCapacityError:
if err := s.sendResult(ctx, c, item.msgID, &mt.RPCError{
ErrorCode: 420,
ErrorMessage: "FLOOD_WAIT_1",
}); err != nil {
return err
}
default:
return fmt.Errorf("unknown inbound item kind %d", item.kind)
}
}
return nil
}
// executePendingRPCReplays joins owners already running on another physical
// connection for the same MTProto session. It never dispatches business code:
// owner Put publishes the immutable result to every waiter; owner Abort leaves
// the client free to retry after the old execution has definitely stopped.
func (s *Server) executePendingRPCReplays(ctx context.Context, c *Conn, plan *inboundPlan) error {
for _, item := range plan.items {
if item.kind != inboundItemPendingRPC {
continue
}
waiter, _ := item.payload.(*rpcResultWaiter)
if waiter == nil {
continue
}
encoded, ok, err := waiter.Wait(ctx)
if err != nil {
return err
}
if !ok || encoded == nil {
// The old owner stopped without publishing a result (normally because
// replacement cancellation reached the handler before it committed).
// A fresh-connection item still owns the decoded request body, so reacquire
// only after the prior flight is definitively gone. This is sequential
// retry, never concurrent business execution. Same-Conn seen duplicates
// have no body here and rely on the client's ordinary resend path.
if len(item.body) > 0 {
if err := s.enqueueRPC(ctx, c, item.msgID, item.typeID, &bin.Buffer{Buf: item.body}); err != nil {
return err
}
}
continue
}
if err := s.sendCachedRPCResult(ctx, c, encoded); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,44 @@
package mtprotoedge
import (
"testing"
"time"
"github.com/gotd/td/bin"
"github.com/gotd/td/proto"
"github.com/gotd/td/tg"
)
func BenchmarkInboundPlan32RPCContainer(b *testing.B) {
ids := proto.NewMessageIDGen(time.Now)
messages := make([]proto.Message, 32)
var request bin.Buffer
if err := (&tg.HelpGetConfigRequest{}).Encode(&request); err != nil {
b.Fatal(err)
}
requestBody := request.Copy()
for i := range messages {
messages[i] = proto.Message{
ID: ids.New(proto.MessageFromClient), SeqNo: 1 + i*2, Bytes: len(requestBody), Body: requestBody,
}
}
outerMsgID := ids.New(proto.MessageFromClient)
var container bin.Buffer
if err := (&proto.MessageContainer{Messages: messages}).Encode(&container); err != nil {
b.Fatal(err)
}
body := container.Copy()
s := New(Options{})
cs := newConnState()
b.ReportAllocs()
b.SetBytes(int64(len(body)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
plan, err := s.preflightInbound(cs, outerMsgID, 64, body)
if err != nil {
b.Fatal(err)
}
plan.close()
}
}

View file

@ -30,8 +30,13 @@ type inboundRPC struct {
size int size int
run func(context.Context) error run func(context.Context) error
onTimeout func() onTimeout func()
budget *inboundRPCGlobalReservation // release drops request-scoped ownership which is independent from the body
ticket *inboundRPCTicket // budget (for example a cross-connection in-flight RPC claim). It runs once
// after the request has either published a terminal result or become
// impossible to run; callers should still make the callback idempotent.
release func()
budget *inboundRPCGlobalReservation
ticket *inboundRPCTicket
} }
const ( const (
@ -76,7 +81,7 @@ type inboundRPCScheduler struct {
type inboundRPCGlobalReservation struct { type inboundRPCGlobalReservation struct {
scheduler *inboundRPCScheduler scheduler *inboundRPCScheduler
size int64 size int64
once sync.Once released atomic.Bool
} }
// inboundRPCReservation 同时持有全局和单连接的“Copy 前”预算。commit/abort 只能成功一次; // inboundRPCReservation 同时持有全局和单连接的“Copy 前”预算。commit/abort 只能成功一次;
@ -92,6 +97,32 @@ type inboundRPCReservation struct {
once sync.Once once sync.Once
} }
// inboundRPCSpec 是 container preflight 与 RPC scheduler 之间的有界 admission 描述。
// method 仅用于 metricssize 是在 Copy 之前必须预留的 request body 字节数。
type inboundRPCSpec struct {
method string
size int
}
type inboundRPCBatchEntry struct {
global *inboundRPCGlobalReservation
method string
size int
}
// inboundRPCBatchReservation 把一个 container 内的 RPC 视为一个 admission 单元。
// reserve 一次预留整批的全局/单连接条数和字节预算commit 一次 append
// 全部任务abort 一次归还全部预算。这防止 container 只执行前半批。
type inboundRPCBatchReservation struct {
conn *Conn
ctx context.Context
entries []inboundRPCBatchEntry
totalSize int64
once sync.Once
}
var errInboundRPCBatchTaskCount = errors.New("inbound rpc batch task count mismatch")
func newInboundRPCScheduler(workers, maxTasks int, maxBytes int64) *inboundRPCScheduler { func newInboundRPCScheduler(workers, maxTasks int, maxBytes int64) *inboundRPCScheduler {
if workers <= 0 { if workers <= 0 {
workers = 1 workers = 1
@ -196,17 +227,89 @@ func (s *inboundRPCScheduler) reserveGlobal(size int) (*inboundRPCGlobalReservat
return &inboundRPCGlobalReservation{scheduler: s, size: size64}, "", nil return &inboundRPCGlobalReservation{scheduler: s, size: size64}, "", nil
} }
// reserveGlobalBatch 在一次 budgetMu 临界区内检查并预留整批条数/字节。
// 返回的每个 reservation 仍由对应 task 单独归还,避免一个慢 RPC 持有
// 整个 container 已完成任务的预算。
func (s *inboundRPCScheduler) reserveGlobalBatch(sizes []int) ([]*inboundRPCGlobalReservation, string, error) {
reservations := make([]*inboundRPCGlobalReservation, len(sizes))
s.budgetMu.Lock()
defer s.budgetMu.Unlock()
select {
case <-s.stopCh:
return nil, "scheduler_closed", ErrConnClosed
default:
}
if len(sizes) > s.maxTasks-s.tasks {
return nil, "global_task_budget", ErrInboundRPCQueueFull
}
// 逐项从剩余预算中减,避免 total 和 s.bytes+total 溢出。
remaining := s.maxBytes - s.bytes
var total int64
for i, size := range sizes {
if size < 0 {
size = 0
}
size64 := int64(size)
if size64 > remaining-total {
return nil, "global_byte_budget", ErrInboundRPCQueueFull
}
total += size64
reservations[i] = &inboundRPCGlobalReservation{scheduler: s, size: size64}
}
s.tasks += len(sizes)
s.bytes += total
return reservations, "", nil
}
func (r *inboundRPCGlobalReservation) release() { func (r *inboundRPCGlobalReservation) release() {
if r == nil || r.scheduler == nil { if r == nil || r.scheduler == nil {
return return
} }
r.once.Do(func() { if !r.released.CompareAndSwap(false, true) {
s := r.scheduler return
s.budgetMu.Lock() }
s.tasks-- s := r.scheduler
s.bytes -= r.size s.budgetMu.Lock()
s.budgetMu.Unlock() s.tasks--
}) s.bytes -= r.size
s.budgetMu.Unlock()
}
// releaseInboundRPCGlobalBatch 使 batch abort/commit-failure 在一次全局锁内
// 归还它仍持有的全部预算。每张 ticket 的 CAS 保证与任何并发释放幂等。
func releaseInboundRPCGlobalBatch(reservations []*inboundRPCGlobalReservation) {
var (
scheduler *inboundRPCScheduler
tasks int
bytes int64
)
for _, reservation := range reservations {
if reservation == nil || reservation.scheduler == nil || !reservation.released.CompareAndSwap(false, true) {
continue
}
if scheduler == nil {
scheduler = reservation.scheduler
}
// 一个 batch 只能由同一 scheduler 创建。若未来出现混合调用,
// 仍通过单张 release 正确归还,而不会扣错 scheduler。
if reservation.scheduler != scheduler {
reservation.scheduler.budgetMu.Lock()
reservation.scheduler.tasks--
reservation.scheduler.bytes -= reservation.size
reservation.scheduler.budgetMu.Unlock()
continue
}
tasks++
bytes += reservation.size
}
if scheduler == nil || tasks == 0 {
return
}
scheduler.budgetMu.Lock()
scheduler.tasks -= tasks
scheduler.bytes -= bytes
scheduler.budgetMu.Unlock()
} }
func (s *inboundRPCScheduler) budgetSnapshot() (tasks int, bytes int64) { func (s *inboundRPCScheduler) budgetSnapshot() (tasks int, bytes int64) {
@ -363,6 +466,10 @@ func (c *Conn) reserveInboundRPC(ctx context.Context, method string, size int) (
return nil, ctx.Err() return nil, ctx.Err()
default: default:
} }
if c.terminal.Load() {
c.metrics.InboundRPCDropped(method, "scheduler_closed")
return nil, ErrConnClosed
}
if c.rpcScheduler == nil { if c.rpcScheduler == nil {
c.metrics.InboundRPCDropped(method, "scheduler_closed") c.metrics.InboundRPCDropped(method, "scheduler_closed")
return nil, ErrConnClosed return nil, ErrConnClosed
@ -392,7 +499,7 @@ func (c *Conn) reserveInboundRPC(ctx context.Context, method string, size int) (
c.metrics.InboundRPCDropped(method, "context_done") c.metrics.InboundRPCDropped(method, "context_done")
return nil, err return nil, err
} }
if c.rpcClosed { if c.rpcClosed || c.terminal.Load() {
c.rpcMu.Unlock() c.rpcMu.Unlock()
global.release() global.release()
c.metrics.InboundRPCDropped(method, "scheduler_closed") c.metrics.InboundRPCDropped(method, "scheduler_closed")
@ -427,6 +534,102 @@ func (c *Conn) reserveInboundRPC(ctx context.Context, method string, size int) (
}, nil }, nil
} }
// reserveInboundRPCBatch 必须在 container 内任何 request body Copy 前调用。
// 全局预算只锁一次,单连接预算也只锁一次;任一限制不满足时
// 整批失败,不会留下部分 task/字节 reservation。
func (c *Conn) reserveInboundRPCBatch(ctx context.Context, specs []inboundRPCSpec) (*inboundRPCBatchReservation, error) {
if ctx == nil {
ctx = context.Background()
}
select {
case <-ctx.Done():
c.dropInboundRPCSpecs(specs, "context_done")
return nil, ctx.Err()
default:
}
if c.terminal.Load() {
c.dropInboundRPCSpecs(specs, "scheduler_closed")
return nil, ErrConnClosed
}
if c.rpcScheduler == nil {
c.dropInboundRPCSpecs(specs, "scheduler_closed")
return nil, ErrConnClosed
}
normalized := make([]inboundRPCSpec, len(specs))
sizes := make([]int, len(specs))
for i, spec := range specs {
if spec.size < 0 {
spec.size = 0
}
normalized[i] = spec
sizes[i] = spec.size
}
globals, reason, err := c.rpcScheduler.reserveGlobalBatch(sizes)
if err != nil {
c.dropInboundRPCSpecs(normalized, reason)
return nil, err
}
entries := make([]inboundRPCBatchEntry, len(normalized))
var totalSize int64
for i, spec := range normalized {
entries[i] = inboundRPCBatchEntry{
global: globals[i],
method: spec.method,
size: spec.size,
}
totalSize += int64(spec.size)
}
c.rpcMu.Lock()
if err := ctx.Err(); err != nil {
c.rpcMu.Unlock()
releaseInboundRPCGlobalBatch(globals)
c.dropInboundRPCSpecs(normalized, "context_done")
return nil, err
}
if c.rpcClosed || c.terminal.Load() {
c.rpcMu.Unlock()
releaseInboundRPCGlobalBatch(globals)
c.dropInboundRPCSpecs(normalized, "scheduler_closed")
return nil, ErrConnClosed
}
// 用减法比较,避免对抗性 batch 的 len 加法溢出。
availableSlots := c.rpcQueueSize - c.rpcReserved - len(c.rpcQueue)
if len(entries) > availableSlots {
c.rpcMu.Unlock()
releaseInboundRPCGlobalBatch(globals)
c.dropInboundRPCSpecs(normalized, "queue_full")
return nil, ErrInboundRPCQueueFull
}
if totalSize > maxInflightRPCBytes-c.inflightRPCBytes.Load() {
c.rpcMu.Unlock()
releaseInboundRPCGlobalBatch(globals)
c.dropInboundRPCSpecs(normalized, "byte_budget")
return nil, ErrInboundRPCQueueFull
}
c.rpcReserved += len(entries)
c.inflightRPCBytes.Add(totalSize)
// Add 与 close 的 Wait 由 rpcMu 排序close 置 rpcClosed 后不会再发生 Add。
// 整批 reservation 只需一个 waitercommit/abort 也只会完成一次。
c.rpcReservationWG.Add(1)
c.rpcMu.Unlock()
return &inboundRPCBatchReservation{
conn: c,
ctx: ctx,
entries: entries,
totalSize: totalSize,
}, nil
}
func (c *Conn) dropInboundRPCSpecs(specs []inboundRPCSpec, reason string) {
for _, spec := range specs {
c.metrics.InboundRPCDropped(spec.method, reason)
}
}
// enqueueInboundRPC 是测试和已持有独立 body 的便捷入口。生产收包路径使用 // enqueueInboundRPC 是测试和已持有独立 body 的便捷入口。生产收包路径使用
// reserveInboundRPC -> Copy -> commit保证真正的 Copy 前预算。 // reserveInboundRPC -> Copy -> commit保证真正的 Copy 前预算。
func (c *Conn) enqueueInboundRPC(ctx context.Context, task inboundRPC) error { func (c *Conn) enqueueInboundRPC(ctx context.Context, task inboundRPC) error {
@ -450,7 +653,7 @@ func (r *inboundRPCReservation) commit(task inboundRPC) error {
c := r.conn c := r.conn
c.rpcMu.Lock() c.rpcMu.Lock()
c.rpcReserved-- c.rpcReserved--
if c.rpcClosed { if c.rpcClosed || c.terminal.Load() {
c.inflightRPCBytes.Add(-int64(r.size)) c.inflightRPCBytes.Add(-int64(r.size))
} else { } else {
// The request deadline starts when admission succeeds, not when a worker // The request deadline starts when admission succeeds, not when a worker
@ -525,6 +728,143 @@ func (r *inboundRPCReservation) abort() {
}) })
} }
// commit 在一次 rpcMu 临界区内把整批 task append 到队列。
// deferSchedule=false 保持旧的立即调度语义true 则返回一个幂等 activate
// 函数,让调用方先完成 new_session_created 等协议 barrier 再启动 worker。
//
// 延迟调度只能延迟本次 commit 新产生的 ready token调用方应在连接的
// 首个 admission batch 使用它,不得把它当作已有 worker 的全局暂停锁。
func (r *inboundRPCBatchReservation) commit(tasks []inboundRPC, deferSchedule bool) (activate func(), result error) {
if r == nil {
return nil, ErrConnClosed
}
result = ErrConnClosed
var (
committed bool
reschedule bool
firstQueueLen int
queueCap int
globals []*inboundRPCGlobalReservation
)
r.once.Do(func() {
c := r.conn
// A container reservation may deliberately span protocol-critical barriers
// (session ownership claim and new_session_created's physical write). Queue
// and execution latency starts only when the fully admitted batch becomes
// runnable, not while it is waiting behind those independent barriers.
enqueuedAt := time.Now()
deadline := time.Time{}
if c.rpcTimeout > 0 {
deadline = enqueuedAt.Add(c.rpcTimeout)
}
if ctxDeadline, ok := r.ctx.Deadline(); ok && (deadline.IsZero() || ctxDeadline.Before(deadline)) {
deadline = ctxDeadline
}
globals = make([]*inboundRPCGlobalReservation, len(r.entries))
for i := range r.entries {
globals[i] = r.entries[i].global
}
c.rpcMu.Lock()
c.rpcReserved -= len(r.entries)
if len(tasks) != len(r.entries) {
c.inflightRPCBytes.Add(-r.totalSize)
result = errInboundRPCBatchTaskCount
} else if c.rpcClosed || c.terminal.Load() {
c.inflightRPCBytes.Add(-r.totalSize)
} else {
prepared := make([]inboundRPC, len(tasks))
// The request deadline starts when admission succeeds, not when a worker
// eventually dequeues the request. This bounds total queue + execution
// latency and lets a queued request emit its explicit timeout on time.
for i := range tasks {
task := tasks[i]
entry := r.entries[i]
if deadline.IsZero() {
task.ctx, task.cancel = context.WithCancel(r.ctx)
} else {
task.ctx, task.cancel = context.WithDeadline(r.ctx, deadline)
}
task.stopRoot = context.AfterFunc(c.rpcRootCtx, task.cancel)
task.method = entry.method
task.enqueuedAt = enqueuedAt
task.deadline = deadline
task.size = entry.size
task.budget = entry.global
ticket := &inboundRPCTicket{}
if task.onTimeout != nil {
onTimeout := task.onTimeout
var timeoutOnce sync.Once
ticket.onTimeout = func() {
timeoutOnce.Do(onTimeout)
}
task.onTimeout = ticket.onTimeout
}
task.ticket = ticket
if task.onTimeout != nil && !task.deadline.IsZero() {
taskCtx := task.ctx
task.stopTimeout = context.AfterFunc(taskCtx, func() {
if errors.Is(taskCtx.Err(), context.DeadlineExceeded) {
c.expireInboundRPCTicket(ticket)
}
})
}
prepared[i] = task
}
firstQueueLen = len(c.rpcQueue) + 1
c.rpcQueue = append(c.rpcQueue, prepared...)
queueCap = c.rpcQueueSize
if len(prepared) > 0 && c.rpcRunning < c.rpcMaxInflight && !c.rpcReady {
c.rpcReady = true
reschedule = true
}
committed = true
result = nil
}
c.rpcMu.Unlock()
c.rpcReservationWG.Done()
if !committed {
releaseInboundRPCGlobalBatch(globals)
}
})
if committed {
for i, entry := range r.entries {
r.conn.metrics.InboundRPCQueued(entry.method, firstQueueLen+i, queueCap)
}
if reschedule {
var once sync.Once
activate = func() {
once.Do(func() {
r.conn.rpcScheduler.schedule(r.conn)
})
}
if !deferSchedule {
activate()
activate = nil
}
}
}
return activate, result
}
func (r *inboundRPCBatchReservation) abort() {
if r == nil {
return
}
r.once.Do(func() {
c := r.conn
c.rpcMu.Lock()
c.rpcReserved -= len(r.entries)
c.inflightRPCBytes.Add(-r.totalSize)
c.rpcMu.Unlock()
c.rpcReservationWG.Done()
globals := make([]*inboundRPCGlobalReservation, len(r.entries))
for i := range r.entries {
globals[i] = r.entries[i].global
}
releaseInboundRPCGlobalBatch(globals)
})
}
func (c *Conn) takeInboundRPC() (task inboundRPC, ok, reschedule bool) { func (c *Conn) takeInboundRPC() (task inboundRPC, ok, reschedule bool) {
c.rpcMu.Lock() c.rpcMu.Lock()
defer c.rpcMu.Unlock() defer c.rpcMu.Unlock()
@ -583,7 +923,7 @@ func (c *Conn) finishInboundRPC(task inboundRPC) {
if task.ticket != nil { if task.ticket != nil {
task.ticket.state.Store(inboundRPCTicketDone) task.ticket.state.Store(inboundRPCTicketDone)
} }
stopInboundRPCTask(task) timeoutHandoff := stopInboundRPCTask(task)
var reschedule bool var reschedule bool
c.rpcMu.Lock() c.rpcMu.Lock()
c.rpcRunning-- c.rpcRunning--
@ -594,11 +934,21 @@ func (c *Conn) finishInboundRPC(task inboundRPC) {
} }
c.rpcMu.Unlock() c.rpcMu.Unlock()
reservation := task.budget reservation := task.budget
release := task.release
// The scheduler budget may be reused immediately after release. Clear request-owned // The scheduler budget may be reused immediately after release. Clear request-owned
// closures/context references first so slow metrics/rescheduling cannot overlap the old body // closures/context references first so slow metrics/rescheduling cannot overlap the old body
// with a newly admitted body under the same byte accounting. // with a newly admitted body under the same byte accounting.
task = inboundRPC{} task = inboundRPC{}
reservation.release() reservation.release()
if timeoutHandoff != nil {
// stopTimeout(false) means the deadline callback may already have read
// Running but not yet entered ticket.onTimeout. Calling the sync.Once wrapper
// here either performs or joins that response before owner release/Abort.
timeoutHandoff()
}
if release != nil {
release()
}
c.rpcWG.Done() c.rpcWG.Done()
if reschedule { if reschedule {
c.rpcScheduler.schedule(c) c.rpcScheduler.schedule(c)
@ -648,7 +998,8 @@ func (c *Conn) expireInboundRPCTicket(ticket *inboundRPCTicket) {
if found { if found {
method := task.method method := task.method
reservation := task.budget reservation := task.budget
stopInboundRPCTask(task) release := task.release
_ = stopInboundRPCTask(task)
// Drop the run/context closures before returning the byte reservation. Otherwise an // Drop the run/context closures before returning the byte reservation. Otherwise an
// onTimeout callback that blocks or performs a slow write can keep the copied request body // onTimeout callback that blocks or performs a slow write can keep the copied request body
// reachable after the global scheduler has advertised those bytes as available again. // reachable after the global scheduler has advertised those bytes as available again.
@ -658,6 +1009,9 @@ func (c *Conn) expireInboundRPCTicket(ticket *inboundRPCTicket) {
if ticket.onTimeout != nil { if ticket.onTimeout != nil {
ticket.onTimeout() ticket.onTimeout()
} }
if release != nil {
release()
}
return return
} }
if ticket.state.Load() == inboundRPCTicketRunning && ticket.onTimeout != nil { if ticket.state.Load() == inboundRPCTicketRunning && ticket.onTimeout != nil {
@ -667,11 +1021,17 @@ func (c *Conn) expireInboundRPCTicket(ticket *inboundRPCTicket) {
// stopInboundRPCTask disarms callbacks before canceling the context so a normal // stopInboundRPCTask disarms callbacks before canceling the context so a normal
// completion or connection close cannot manufacture an RPC_TIMEOUT response. // completion or connection close cannot manufacture an RPC_TIMEOUT response.
// A deadline callback already in flight is harmless because enqueueRPC's response // If the runtime already started a deadline callback, the returned sync.Once
// gate makes timeout and normal rpc_result mutually exclusive. // wrapper is a mandatory handoff: callers invoke it before owner release so a
func stopInboundRPCTask(task inboundRPC) { // callback paused between ticket-state inspection and response-gate claim cannot
// publish into a later flight generation.
func stopInboundRPCTask(task inboundRPC) (timeoutHandoff func()) {
if task.stopTimeout != nil { if task.stopTimeout != nil {
task.stopTimeout() stopped := task.stopTimeout()
if !stopped && task.ctx != nil && errors.Is(task.ctx.Err(), context.DeadlineExceeded) &&
task.ticket != nil && task.ticket.onTimeout != nil {
timeoutHandoff = task.ticket.onTimeout
}
} }
if task.stopRoot != nil { if task.stopRoot != nil {
task.stopRoot() task.stopRoot()
@ -679,6 +1039,7 @@ func stopInboundRPCTask(task inboundRPC) {
if task.cancel != nil { if task.cancel != nil {
task.cancel() task.cancel()
} }
return timeoutHandoff
} }
func (c *Conn) closeInboundRPCScheduler() { func (c *Conn) closeInboundRPCScheduler() {
@ -722,9 +1083,16 @@ func (c *Conn) beginCloseInboundRPCScheduler() {
} }
method := task.method method := task.method
reservation := task.budget reservation := task.budget
stopInboundRPCTask(task) release := task.release
timeoutHandoff := stopInboundRPCTask(task)
task = inboundRPC{} task = inboundRPC{}
reservation.release() reservation.release()
if timeoutHandoff != nil {
timeoutHandoff()
}
if release != nil {
release()
}
c.metrics.InboundRPCDropped(method, "connection_closed") c.metrics.InboundRPCDropped(method, "connection_closed")
} }
}) })

View file

@ -0,0 +1,343 @@
package mtprotoedge
import (
"context"
"errors"
"testing"
"time"
)
func TestInboundRPCBatchReservationRejectsGloballyWithoutPartialBudget(t *testing.T) {
scheduler := newInboundRPCScheduler(1, 2, 10)
c := newInboundTestConn(scheduler, 1, 8, time.Second)
defer func() {
c.closeInboundRPCScheduler()
scheduler.stop(time.Second)
}()
_, err := c.reserveInboundRPCBatch(context.Background(), []inboundRPCSpec{
{method: "one", size: 3},
{method: "two", size: 3},
{method: "three", size: 3},
})
if !errors.Is(err, ErrInboundRPCQueueFull) {
t.Fatalf("reserve over global task budget err = %v, want ErrInboundRPCQueueFull", err)
}
if tasks, bytes := scheduler.budgetSnapshot(); tasks != 0 || bytes != 0 {
t.Fatalf("global budget after atomic rejection = (%d, %d), want zero", tasks, bytes)
}
if got := c.inflightRPCBytes.Load(); got != 0 {
t.Fatalf("connection bytes after global rejection = %d, want zero", got)
}
c.rpcMu.Lock()
reserved := c.rpcReserved
queued := len(c.rpcQueue)
c.rpcMu.Unlock()
if reserved != 0 || queued != 0 {
t.Fatalf("connection state after global rejection = reserved %d queued %d, want zero", reserved, queued)
}
}
func TestInboundRPCBatchReservationRejectsConnectionWithoutLeakingGlobalBudget(t *testing.T) {
scheduler := newInboundRPCScheduler(1, 16, 1<<20)
c := newInboundTestConn(scheduler, 1, 2, time.Second)
defer func() {
c.closeInboundRPCScheduler()
scheduler.stop(time.Second)
}()
_, err := c.reserveInboundRPCBatch(context.Background(), []inboundRPCSpec{
{method: "one", size: 3},
{method: "two", size: 5},
{method: "three", size: 7},
})
if !errors.Is(err, ErrInboundRPCQueueFull) {
t.Fatalf("reserve over connection queue budget err = %v, want ErrInboundRPCQueueFull", err)
}
if tasks, bytes := scheduler.budgetSnapshot(); tasks != 0 || bytes != 0 {
t.Fatalf("global budget after connection rejection = (%d, %d), want zero", tasks, bytes)
}
if got := c.inflightRPCBytes.Load(); got != 0 {
t.Fatalf("connection bytes after connection rejection = %d, want zero", got)
}
c.rpcMu.Lock()
reserved := c.rpcReserved
queued := len(c.rpcQueue)
c.rpcMu.Unlock()
if reserved != 0 || queued != 0 {
t.Fatalf("connection state after connection rejection = reserved %d queued %d, want zero", reserved, queued)
}
}
func TestInboundRPCBatchReservationRejectsAggregateConnectionBytes(t *testing.T) {
scheduler := newInboundRPCScheduler(1, 8, int64(maxInflightRPCBytes)*2)
c := newInboundTestConn(scheduler, 1, 4, time.Second)
defer func() {
c.closeInboundRPCScheduler()
scheduler.stop(time.Second)
}()
halfPlusOne := maxInflightRPCBytes/2 + 1
_, err := c.reserveInboundRPCBatch(context.Background(), []inboundRPCSpec{
{method: "one", size: halfPlusOne},
{method: "two", size: halfPlusOne},
})
if !errors.Is(err, ErrInboundRPCQueueFull) {
t.Fatalf("reserve over aggregate connection byte budget err = %v, want ErrInboundRPCQueueFull", err)
}
if tasks, bytes := scheduler.budgetSnapshot(); tasks != 0 || bytes != 0 {
t.Fatalf("global budget after aggregate byte rejection = (%d, %d), want zero", tasks, bytes)
}
if got := c.inflightRPCBytes.Load(); got != 0 {
t.Fatalf("connection bytes after aggregate byte rejection = %d, want zero", got)
}
}
func TestInboundRPCBatchAbortReturnsEveryReservationExactlyOnce(t *testing.T) {
scheduler := newInboundRPCScheduler(1, 8, 1<<20)
c := newInboundTestConn(scheduler, 1, 4, time.Second)
defer func() {
c.closeInboundRPCScheduler()
scheduler.stop(time.Second)
}()
reservation, err := c.reserveInboundRPCBatch(context.Background(), []inboundRPCSpec{
{method: "one", size: 3},
{method: "two", size: 5},
{method: "three", size: 7},
})
if err != nil {
t.Fatalf("reserve batch: %v", err)
}
if tasks, bytes := scheduler.budgetSnapshot(); tasks != 3 || bytes != 15 {
t.Fatalf("budget after reserve = (%d, %d), want (3, 15)", tasks, bytes)
}
reservation.abort()
reservation.abort()
if tasks, bytes := scheduler.budgetSnapshot(); tasks != 0 || bytes != 0 {
t.Fatalf("budget after idempotent abort = (%d, %d), want zero", tasks, bytes)
}
if got := c.inflightRPCBytes.Load(); got != 0 {
t.Fatalf("connection bytes after abort = %d, want zero", got)
}
c.rpcMu.Lock()
reserved := c.rpcReserved
queued := len(c.rpcQueue)
c.rpcMu.Unlock()
if reserved != 0 || queued != 0 {
t.Fatalf("connection state after abort = reserved %d queued %d, want zero", reserved, queued)
}
}
func TestInboundRPCBatchCommitAppendsAllAndDefersSchedule(t *testing.T) {
scheduler := newInboundRPCScheduler(1, 8, 1<<20)
scheduler.start()
c := newInboundTestConn(scheduler, 1, 4, time.Second)
defer func() {
c.closeInboundRPCScheduler()
scheduler.stop(time.Second)
}()
specs := []inboundRPCSpec{
{method: "one", size: 3},
{method: "two", size: 5},
{method: "three", size: 7},
}
reservation, err := c.reserveInboundRPCBatch(context.Background(), specs)
if err != nil {
t.Fatalf("reserve batch: %v", err)
}
defer reservation.abort()
runs := make(chan string, len(specs))
tasks := make([]inboundRPC, len(specs))
for i, spec := range specs {
method := spec.method
tasks[i].run = func(context.Context) error {
runs <- method
return nil
}
}
activate, err := reservation.commit(tasks, true)
if err != nil {
t.Fatalf("commit batch: %v", err)
}
if activate == nil {
t.Fatal("deferred commit did not return an activation function")
}
c.rpcMu.Lock()
queued := len(c.rpcQueue)
ready := c.rpcReady
c.rpcMu.Unlock()
if queued != len(specs) || !ready {
t.Fatalf("atomic queue state after commit = queued %d ready %v, want %d/true", queued, ready, len(specs))
}
if got := scheduler.readyLen(); got != 0 {
t.Fatalf("scheduler ready tokens before activation = %d, want zero", got)
}
select {
case method := <-runs:
t.Fatalf("RPC %q ran before deferred activation", method)
default:
}
activate()
activate() // activation is idempotent.
for _, want := range []string{"one", "two", "three"} {
select {
case got := <-runs:
if got != want {
t.Fatalf("execution order = %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatalf("timed out waiting for %q", want)
}
}
waitInboundRPCBatchBudget(t, scheduler, 0, 0)
}
func TestInboundRPCBatchCommitMismatchReleasesAllWithoutEnqueue(t *testing.T) {
scheduler := newInboundRPCScheduler(1, 8, 1<<20)
c := newInboundTestConn(scheduler, 1, 4, time.Second)
defer func() {
c.closeInboundRPCScheduler()
scheduler.stop(time.Second)
}()
reservation, err := c.reserveInboundRPCBatch(context.Background(), []inboundRPCSpec{
{method: "one", size: 3},
{method: "two", size: 5},
})
if err != nil {
t.Fatalf("reserve batch: %v", err)
}
if _, err := reservation.commit([]inboundRPC{{}}, false); !errors.Is(err, errInboundRPCBatchTaskCount) {
t.Fatalf("commit task mismatch err = %v, want %v", err, errInboundRPCBatchTaskCount)
}
if tasks, bytes := scheduler.budgetSnapshot(); tasks != 0 || bytes != 0 {
t.Fatalf("budget after mismatched commit = (%d, %d), want zero", tasks, bytes)
}
if got := c.inflightRPCBytes.Load(); got != 0 {
t.Fatalf("connection bytes after mismatched commit = %d, want zero", got)
}
c.rpcMu.Lock()
reserved := c.rpcReserved
queued := len(c.rpcQueue)
c.rpcMu.Unlock()
if reserved != 0 || queued != 0 {
t.Fatalf("connection state after mismatched commit = reserved %d queued %d, want zero", reserved, queued)
}
}
func TestInboundRPCBatchCommitRacingCloseNeverPartiallyEnqueues(t *testing.T) {
scheduler := newInboundRPCScheduler(1, 8, 1<<20)
c := newInboundTestConn(scheduler, 1, 4, time.Second)
defer scheduler.stop(time.Second)
reservation, err := c.reserveInboundRPCBatch(context.Background(), []inboundRPCSpec{
{method: "one", size: 3},
{method: "two", size: 5},
{method: "three", size: 7},
})
if err != nil {
t.Fatalf("reserve batch: %v", err)
}
closed := make(chan struct{})
go func() {
c.closeInboundRPCScheduler()
close(closed)
}()
waitInboundRPCBatchConnClosed(t, c)
if _, err := reservation.commit(make([]inboundRPC, 3), false); !errors.Is(err, ErrConnClosed) {
t.Fatalf("commit after close err = %v, want ErrConnClosed", err)
}
select {
case <-closed:
case <-time.After(time.Second):
t.Fatal("close did not finish after batch commit returned its reservation")
}
if tasks, bytes := scheduler.budgetSnapshot(); tasks != 0 || bytes != 0 {
t.Fatalf("budget after close/commit race = (%d, %d), want zero", tasks, bytes)
}
if got := c.inflightRPCBytes.Load(); got != 0 {
t.Fatalf("connection bytes after close/commit race = %d, want zero", got)
}
c.rpcMu.Lock()
queued := len(c.rpcQueue)
c.rpcMu.Unlock()
if queued != 0 {
t.Fatalf("queue after close/commit race = %d, want zero", queued)
}
}
func TestInboundRPCBatchCommitAfterTerminalFenceRejectsAll(t *testing.T) {
scheduler := newInboundRPCScheduler(1, 8, 1<<20)
c := newInboundTestConn(scheduler, 1, 4, time.Second)
defer func() {
c.closeInboundRPCScheduler()
scheduler.stop(time.Second)
}()
reservation, err := c.reserveInboundRPCBatch(context.Background(), []inboundRPCSpec{
{method: "one", size: 3},
{method: "two", size: 5},
})
if err != nil {
t.Fatalf("reserve batch: %v", err)
}
// Session replacement and revocation publish terminal before the slower
// physical-close path. A reservation held across that fence must not be able
// to append even one stale task.
c.terminal.Store(true)
if _, err := reservation.commit(make([]inboundRPC, 2), false); !errors.Is(err, ErrConnClosed) {
t.Fatalf("commit after terminal fence err = %v, want ErrConnClosed", err)
}
if tasks, bytes := scheduler.budgetSnapshot(); tasks != 0 || bytes != 0 {
t.Fatalf("budget after terminal commit rejection = (%d, %d), want zero", tasks, bytes)
}
if got := c.inflightRPCBytes.Load(); got != 0 {
t.Fatalf("connection bytes after terminal commit rejection = %d, want zero", got)
}
c.rpcMu.Lock()
reserved := c.rpcReserved
queued := len(c.rpcQueue)
c.rpcMu.Unlock()
if reserved != 0 || queued != 0 {
t.Fatalf("connection state after terminal commit rejection = reserved %d queued %d, want zero", reserved, queued)
}
}
func waitInboundRPCBatchBudget(t *testing.T, scheduler *inboundRPCScheduler, wantTasks int, wantBytes int64) {
t.Helper()
deadline := time.Now().Add(time.Second)
for {
tasks, bytes := scheduler.budgetSnapshot()
if tasks == wantTasks && bytes == wantBytes {
return
}
if time.Now().After(deadline) {
t.Fatalf("budget = (%d, %d), want (%d, %d)", tasks, bytes, wantTasks, wantBytes)
}
time.Sleep(time.Millisecond)
}
}
func waitInboundRPCBatchConnClosed(t *testing.T, c *Conn) {
t.Helper()
deadline := time.Now().Add(time.Second)
for {
c.rpcMu.Lock()
closed := c.rpcClosed
c.rpcMu.Unlock()
if closed {
return
}
if time.Now().After(deadline) {
t.Fatal("connection scheduler was not marked closed")
}
time.Sleep(time.Millisecond)
}
}

View file

@ -3,11 +3,58 @@ package mtprotoedge
import ( import (
"context" "context"
"errors" "errors"
"sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
) )
func TestStopInboundRPCTaskJoinsStartedDeadlineCallback(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancel()
started := make(chan struct{})
release := make(chan struct{})
var once sync.Once
ticket := &inboundRPCTicket{}
ticket.onTimeout = func() {
once.Do(func() {
close(started)
<-release
})
}
task := inboundRPC{
ctx: ctx,
ticket: ticket,
stopTimeout: func() bool { return false }, // runtime callback already scheduled
}
handoff := stopInboundRPCTask(task)
if handoff == nil {
t.Fatal("started deadline callback did not produce handoff")
}
go ticket.onTimeout()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("deadline callback did not start")
}
joined := make(chan struct{})
go func() {
handoff()
close(joined)
}()
select {
case <-joined:
t.Fatal("handoff returned before in-flight callback completed")
case <-time.After(20 * time.Millisecond):
}
close(release)
select {
case <-joined:
case <-time.After(time.Second):
t.Fatal("handoff did not join completed callback")
}
}
func newInboundTestConn(s *inboundRPCScheduler, maxInflight, queueSize int, timeout time.Duration) *Conn { func newInboundTestConn(s *inboundRPCScheduler, maxInflight, queueSize int, timeout time.Duration) *Conn {
c := &Conn{metrics: NopMetrics{}} c := &Conn{metrics: NopMetrics{}}
c.startInboundRPCScheduler(s, maxInflight, queueSize, timeout) c.startInboundRPCScheduler(s, maxInflight, queueSize, timeout)

View file

@ -61,8 +61,8 @@ func TestMetricsHooks(t *testing.T) {
if got := m.inbound.Load(); got != 1 { if got := m.inbound.Load(); got != 1 {
t.Errorf("InboundRPCStarted called %d times, want 1", got) t.Errorf("InboundRPCStarted called %d times, want 1", got)
} }
// new_session_created / ack 走 fire-and-forget异步可能在 client 收到 rpc_result 后 // new_session_created 已经跨过 required-control 物理写屏障msgs_ack 仍是异步控制帧,
// 才被 outbound actor 处理轮询等其最终发送完成。M5 验证发送计数,不约束同步时序 // 因而这里只轮询等待最终发送计数。具体 boundary→RPC 顺序由 session boundary 测试锁定
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for m.outbound.Load() < 3 && time.Now().Before(deadline) { for m.outbound.Load() < 3 && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond) time.Sleep(5 * time.Millisecond)

View file

@ -43,3 +43,50 @@ func TestNewSessionCreatedUniqueIDPerSession(t *testing.T) {
t.Fatalf("new_session_created.unique_id reused across sessions: %d", created1.UniqueID) t.Fatalf("new_session_created.unique_id reused across sessions: %d", created1.UniqueID)
} }
} }
// TestNewSessionCreatedMovesFloorForLaterLowerMessage locks the MTProto rule
// that a server-side session must publish a fresh boundary notification when it
// later accepts a smaller logical client msg_id. Official clients use this
// boundary to decide which requests must be regenerated and resent.
func TestNewSessionCreatedMovesFloorForLaterLowerMessage(t *testing.T) {
const dc = 2
addr, pub, _ := startTestServer(t, Options{DC: dc})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
freshIDs := proto.NewMessageIDGen(time.Now)
firstMsgID := freshIDs.New(proto.MessageFromClient)
sendEncryptedWithSeq(t, conn, cipher, auth, firstMsgID, 0, &mt.HTTPWaitRequest{
MaxDelay: 0,
WaitAfter: 0,
MaxWait: 25_000,
})
first := collectReplies(t, conn, cipher, auth.AuthKey, mt.NewSessionCreatedTypeID)
var created1 mt.NewSessionCreated
if err := created1.Decode(mustHave(t, first, mt.NewSessionCreatedTypeID, "first new_session_created")); err != nil {
t.Fatalf("decode first new_session_created: %v", err)
}
if created1.FirstMsgID != firstMsgID {
t.Fatalf("first boundary = %d, want %d", created1.FirstMsgID, firstMsgID)
}
oldIDs := proto.NewMessageIDGen(func() time.Time { return time.Now().Add(-10 * time.Minute) })
lowerMsgID := oldIDs.New(proto.MessageFromClient)
outerMsgID := freshIDs.New(proto.MessageFromClient)
pingBody := mustEncodeTL(t, &mt.PingRequest{PingID: 99})
sendEncrypted(t, conn, cipher, auth, outerMsgID, &proto.MessageContainer{
Messages: []proto.Message{{
ID: lowerMsgID, SeqNo: 1, Bytes: len(pingBody), Body: pingBody,
}},
})
second := collectReplies(t, conn, cipher, auth.AuthKey, mt.PongTypeID)
var created2 mt.NewSessionCreated
if err := created2.Decode(mustHave(t, second, mt.NewSessionCreatedTypeID, "lower-boundary new_session_created")); err != nil {
t.Fatalf("decode lower-boundary new_session_created: %v", err)
}
if created2.FirstMsgID != lowerMsgID {
t.Fatalf("lower boundary = %d, want %d", created2.FirstMsgID, lowerMsgID)
}
if created2.UniqueID == created1.UniqueID {
t.Fatalf("lower-boundary notification reused unique_id %d", created2.UniqueID)
}
}

View file

@ -40,6 +40,11 @@ const (
defaultOutboundControlQueueSize = 32 defaultOutboundControlQueueSize = 32
defaultOutboundTrackedMaxBytes = int64(512 << 20) // 512 MiB / Server defaultOutboundTrackedMaxBytes = int64(512 << 20) // 512 MiB / Server
defaultOutboundControlMaxBytes = int64(64 << 20) // ack/state/resend vectors / Server defaultOutboundControlMaxBytes = int64(64 << 20) // ack/state/resend vectors / Server
// requiredControlMaxWait bounds protocol barriers such as new_session_created from
// the beginning of encoding through the completed physical write. These frames gate
// subsequent session state transitions, so timing out must close the connection instead
// of degrading to the best-effort control path.
requiredControlMaxWait = 5 * time.Second
maxTrackedServerMsgIDs = 4096 maxTrackedServerMsgIDs = 4096
maxTrackedAckedMsgIDs = 1024 maxTrackedAckedMsgIDs = 1024
@ -332,6 +337,9 @@ func (c *Conn) Close() {
// transport.Close call, so a timed-out batch close cannot keep accepting memory/work. // transport.Close call, so a timed-out batch close cannot keep accepting memory/work.
func (c *Conn) beginTerminalShutdown() { func (c *Conn) beginTerminalShutdown() {
c.terminal.Store(true) c.terminal.Store(true)
// Retirement is irreversible. SessionManager activation only uses CAS from
// provisional/claiming, so a stale goroutine cannot publish this Conn again.
c.lifecycle.Store(uint32(connLifecycleRetired))
c.signalOutboundStop() c.signalOutboundStop()
c.beginCloseInboundRPCScheduler() c.beginCloseInboundRPCScheduler()
} }
@ -342,6 +350,23 @@ func (c *Conn) waitOutboundShutdown() {
} }
} }
func (c *Conn) waitOutboundShutdownUntil(timeout time.Duration) bool {
if c == nil || c.outboundDone == nil {
return true
}
if timeout <= 0 {
return false
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-c.outboundDone:
return true
case <-timer.C:
return false
}
}
// ForceClose 停止连接并关闭底层 transport。 // ForceClose 停止连接并关闭底层 transport。
// 仅用于授权撤销 / destroy_auth_key 这类“必须让对端立即断线”的路径;普通生命周期仍由 // 仅用于授权撤销 / destroy_auth_key 这类“必须让对端立即断线”的路径;普通生命周期仍由
// serveConn 统一关闭 transport避免正常 push/索引清理把长连接误伤成硬断。 // serveConn 统一关闭 transport避免正常 push/索引清理把长连接误伤成硬断。
@ -355,6 +380,13 @@ func (c *Conn) ForceClose() {
// closeTransport 只关闭物理 transport不等待 outbound actor。写失败路径运行在 // closeTransport 只关闭物理 transport不等待 outbound actor。写失败路径运行在
// actor 自身 goroutine 中,若在这里调用 Close 会等待 outboundDone 而自锁。 // actor 自身 goroutine 中,若在这里调用 Close 会等待 outboundDone 而自锁。
func (c *Conn) closeTransport() { func (c *Conn) closeTransport() {
if c == nil {
return
}
if c.transportLease != nil {
_ = c.transportLease.Close()
return
}
c.transportClose.Do(func() { c.transportClose.Do(func() {
if c.transport != nil { if c.transport != nil {
_ = c.transport.Close() _ = c.transport.Close()
@ -372,6 +404,38 @@ func (c *Conn) failTransport() {
c.closeTransport() c.closeTransport()
} }
// fenceUndeliveredRPCResult is the no-reentry terminal path used from a task's
// release callback. That callback may itself run while rpcClose.Do is draining
// queued tasks, so calling beginCloseInboundRPCScheduler again would deadlock on
// sync.Once. Closing the socket wakes serveConn, whose ordinary defer completes
// scheduler/index cleanup; when shutdown already owns the callback, that cleanup
// is already in progress.
func (c *Conn) fenceUndeliveredRPCResult() {
if c == nil {
return
}
// A replacement/shutdown that already published terminal owns physical
// lifecycle cleanup (and may intentionally transfer the lease). Only the
// resultless task that wins false->true is allowed to close this generation.
if !c.terminal.CompareAndSwap(false, true) {
return
}
c.lifecycle.Store(uint32(connLifecycleRetired))
c.signalOutboundStop()
if c.transportLease != nil {
c.transportLease.startCloseAlreadyFenced()
return
}
// Legacy construction-only Conns have no owner callback graph, so their
// exact transport close cannot re-enter logical lifecycle cleanup. Keep a
// pathological Close outside the shared RPC worker just like the lease path.
go c.transportClose.Do(func() {
if c.transport != nil {
_ = c.transport.Close()
}
})
}
// dropSlowConsumer 把出站队列持续拥塞的连接降级为离线连接。它不能等待 outbound // dropSlowConsumer 把出站队列持续拥塞的连接降级为离线连接。它不能等待 outbound
// actor调用方位于 fan-out 热路径,等待单个慢 socket 会把同一用户的健康设备和 // actor调用方位于 fan-out 热路径,等待单个慢 socket 会把同一用户的健康设备和
// transactional outbox lane 一起拖住。关闭 transport 会打断可能阻塞的写serveConn // transactional outbox lane 一起拖住。关闭 transport 会打断可能阻塞的写serveConn
@ -402,6 +466,41 @@ func (c *Conn) SendPriority(ctx context.Context, t proto.MessageType, msg bin.En
return c.send(ctx, t, msg, true) return c.send(ctx, t, msg, true)
} }
// SendRequiredControl writes a protocol-critical control message before the caller commits
// the state transition guarded by that message. One absolute deadline covers encode admission,
// body-budget reservation, control-queue admission and the physical transport write. A failure
// is terminal: continuing on the same connection could expose state whose required notification
// never reached the client.
//
// Success only confirms the physical write; it does not wait for the client's msgs_ack.
func (c *Conn) SendRequiredControl(ctx context.Context, t proto.MessageType, msg bin.Encoder) error {
if ctx == nil {
ctx = context.Background()
}
now := time.Now()
deadline := now.Add(requiredControlMaxWait)
if c.writeTimeout > 0 {
if writeDeadline := now.Add(c.writeTimeout); writeDeadline.Before(deadline) {
deadline = writeDeadline
}
}
if parentDeadline, ok := ctx.Deadline(); ok && parentDeadline.Before(deadline) {
deadline = parentDeadline
}
requiredCtx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
if err := requiredCtx.Err(); err != nil {
c.failTransport()
return err
}
err := c.sendOutbound(requiredCtx, t, msg, nil, true)
if err != nil {
c.failTransport()
}
return err
}
// SendBestEffort 只等待消息进入普通 outbound 队列,不等待网络写完成。 // SendBestEffort 只等待消息进入普通 outbound 队列,不等待网络写完成。
// 用于 updates fanout队列拥塞时返回 ErrOutboundQueueFulldurable outbox/getDifference 负责兜底。 // 用于 updates fanout队列拥塞时返回 ErrOutboundQueueFulldurable outbox/getDifference 负责兜底。
func (c *Conn) SendBestEffort(ctx context.Context, t proto.MessageType, msg bin.Encoder, timeout time.Duration) error { func (c *Conn) SendBestEffort(ctx context.Context, t proto.MessageType, msg bin.Encoder, timeout time.Duration) error {
@ -519,14 +618,27 @@ func (c *Conn) sendOutbound(ctx context.Context, t proto.MessageType, msg bin.En
case res := <-op.done: case res := <-op.done:
return res.err return res.err
case <-ctx.Done(): case <-ctx.Done():
// A physical write can complete at the same instant as the caller's
// deadline. Prefer the actor's terminal result when it is already
// available so required-control callers do not poison a healthy Conn.
select {
case res := <-op.done:
return res.err
default:
}
return ctx.Err() return ctx.Err()
case <-c.outboundStop: case <-c.outboundStop:
select {
case res := <-op.done:
return res.err
default:
}
return ErrConnClosed return ErrConnClosed
} }
} }
// SendAsync 入队一条 server 消息但不等待发送结果fire-and-forget用于读循环里的控制消息 // SendAsync 入队一条 server 消息但不等待发送结果fire-and-forget用于读循环里的控制消息
// ack/pong/new_session_created/bad_msg/future_salts/state_info避免读循环被 outbound 写 // ack/pong/bad_msg/future_salts/state_info避免读循环被 outbound 写
// 阻塞而连带卡死。走优先(control)队列保证不被普通 push 拖后;队列满时丢弃并记 metrics——此时 // 阻塞而连带卡死。走优先(control)队列保证不被普通 push 拖后;队列满时丢弃并记 metrics——此时
// 连接多已严重拥塞,控制消息丢失由客户端重传 / 读写超时兜底。返回非 nil 仅表示连接已关闭。 // 连接多已严重拥塞,控制消息丢失由客户端重传 / 读写超时兜底。返回非 nil 仅表示连接已关闭。
func (c *Conn) SendAsync(ctx context.Context, t proto.MessageType, msg bin.Encoder) error { func (c *Conn) SendAsync(ctx context.Context, t proto.MessageType, msg bin.Encoder) error {
@ -1038,7 +1150,10 @@ func (c *Conn) failOutboundBudget(err error) {
if c.metrics != nil { if c.metrics != nil {
c.metrics.OutboundDropped("tracked_global_byte_budget") c.metrics.OutboundDropped("tracked_global_byte_budget")
} }
c.failTransport() // No socket bytes exist yet. If an intentional session handoff already won
// the terminal CAS, it owns close/transfer and this old producer must not close
// the still-current lease. A live connection still gets fenced and closed.
c.fenceUndeliveredRPCResult()
} }
func (c *Conn) ensureOutboundTrackedBudget() *outboundTrackedBudget { func (c *Conn) ensureOutboundTrackedBudget() *outboundTrackedBudget {

View file

@ -0,0 +1,262 @@
package mtprotoedge
import (
"context"
"crypto/rand"
"errors"
"io"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gotd/td/bin"
"github.com/gotd/td/crypto"
"github.com/gotd/td/mt"
"github.com/gotd/td/proto"
)
type gatedRequiredControlTransport struct {
started chan struct{}
release chan struct{}
sendErr error
startOnce sync.Once
closeOnce sync.Once
sends atomic.Int32
closes atomic.Int32
}
func newGatedRequiredControlTransport(sendErr error) *gatedRequiredControlTransport {
return &gatedRequiredControlTransport{
started: make(chan struct{}),
release: make(chan struct{}),
sendErr: sendErr,
}
}
func (t *gatedRequiredControlTransport) Send(context.Context, *bin.Buffer) error {
t.sends.Add(1)
t.startOnce.Do(func() { close(t.started) })
<-t.release
return t.sendErr
}
func (t *gatedRequiredControlTransport) Recv(context.Context, *bin.Buffer) error {
return io.EOF
}
func (t *gatedRequiredControlTransport) Close() error {
t.closes.Add(1)
t.closeOnce.Do(func() { close(t.release) })
return nil
}
func (t *gatedRequiredControlTransport) unblock() {
t.closeOnce.Do(func() { close(t.release) })
}
func TestSendRequiredControlWaitsForPhysicalWriteAndReturnsBudget(t *testing.T) {
tr := newGatedRequiredControlTransport(nil)
controlBudget := newOutboundTrackedBudget(1 << 20)
c := newOutboundTestConn(t, tr, newOutboundTrackedBudget(1<<20))
c.outboundControlTrackedBudget = controlBudget
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
done := make(chan error, 1)
go func() {
done <- c.SendRequiredControl(ctx, proto.MessageServerResponse, &mt.Pong{MsgID: 1, PingID: 2})
}()
select {
case <-tr.started:
case <-time.After(time.Second):
t.Fatal("required control did not reach the physical writer")
}
select {
case err := <-done:
t.Fatalf("SendRequiredControl returned before physical write completed: %v", err)
case <-time.After(20 * time.Millisecond):
}
tr.unblock()
select {
case err := <-done:
if err != nil {
t.Fatalf("SendRequiredControl: %v", err)
}
case <-time.After(time.Second):
t.Fatal("SendRequiredControl did not return after physical write")
}
if c.terminal.Load() {
t.Fatal("successful required control terminally closed the connection")
}
if got := controlBudget.snapshot(); got != 0 {
t.Fatalf("control budget after non-pending physical write = %d, want 0", got)
}
if got := tr.sends.Load(); got != 1 {
t.Fatalf("physical sends = %d, want 1", got)
}
}
func TestSendRequiredControlReturnsAfterWriteWithoutWaitingForAck(t *testing.T) {
tr := &failAfterTransport{}
controlBudget := newOutboundTrackedBudget(1 << 20)
c := newOutboundTestConn(t, tr, newOutboundTrackedBudget(1<<20))
c.outboundControlTrackedBudget = controlBudget
created := &mt.NewSessionCreated{FirstMsgID: 1, UniqueID: 2, ServerSalt: 3}
encoded, err := encodeOutboundMessageWithoutSlot(created)
if err != nil {
t.Fatalf("encode new_session_created: %v", err)
}
if err := c.SendRequiredControl(context.Background(), proto.MessageFromServer, created); err != nil {
t.Fatalf("SendRequiredControl: %v", err)
}
if got := tr.stored.Load(); got != 1 {
t.Fatalf("completed physical sends = %d, want 1", got)
}
if got := controlBudget.snapshot(); got != int64(len(encoded.body)) {
t.Fatalf("pending control budget = %d, want %d until client ACK", got, len(encoded.body))
}
frame, err := crypto.NewClientCipher(rand.Reader).DecryptFromBuffer(c.key, &bin.Buffer{Buf: tr.lastFrame()})
if err != nil {
t.Fatalf("decrypt new_session_created: %v", err)
}
c.AckServerMessages([]int64{frame.MessageID})
deadline := time.Now().Add(time.Second)
for controlBudget.snapshot() != 0 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if got := controlBudget.snapshot(); got != 0 {
t.Fatalf("control budget after ACK = %d, want 0", got)
}
}
func TestSendRequiredControlQueueDeadlineTerminatesAndReturnsBudget(t *testing.T) {
tr := &failAfterTransport{}
controlBudget := newOutboundTrackedBudget(1 << 20)
c := &Conn{
transport: tr,
writer: tr,
metrics: NopMetrics{},
writeTimeout: time.Second,
outboundTrackedBudget: newOutboundTrackedBudget(1 << 20),
outboundControlTrackedBudget: controlBudget,
outbound: make(chan outboundOp, 1),
outboundControl: make(chan outboundOp, 1),
outboundStop: make(chan struct{}),
}
// No actor is running and the bounded control queue is full, so the parent
// deadline must cover queue admission and make the failure terminal.
c.outboundControl <- outboundOp{kind: outboundAck}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
defer cancel()
started := time.Now()
err := c.SendRequiredControl(ctx, proto.MessageServerResponse, &mt.Pong{MsgID: 1, PingID: 2})
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("full control queue error = %v, want context deadline", err)
}
if elapsed := time.Since(started); elapsed > 500*time.Millisecond {
t.Fatalf("full control queue waited %v, want parent-deadline-bounded admission", elapsed)
}
if !c.terminal.Load() {
t.Fatal("required control queue failure did not terminally close the connection")
}
if got := tr.sends.Load(); got != 0 {
t.Fatalf("physical sends = %d, want 0", got)
}
if got := tr.closes.Load(); got != 1 {
t.Fatalf("transport closes = %d, want 1", got)
}
if got := controlBudget.snapshot(); got != 0 {
t.Fatalf("control budget after queue timeout = %d, want 0", got)
}
}
func TestSendRequiredControlBlockedWriteUsesWholeOperationDeadline(t *testing.T) {
tr := newGatedRequiredControlTransport(io.ErrClosedPipe)
controlBudget := newOutboundTrackedBudget(1 << 20)
c := newOutboundTestConn(t, tr, newOutboundTrackedBudget(1<<20))
c.outboundControlTrackedBudget = controlBudget
c.writeTimeout = 25 * time.Millisecond
started := time.Now()
err := c.SendRequiredControl(context.Background(), proto.MessageServerResponse, &mt.Pong{MsgID: 1, PingID: 2})
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("blocked required control error = %v, want context deadline", err)
}
if elapsed := time.Since(started); elapsed > 500*time.Millisecond {
t.Fatalf("blocked required control waited %v, want write-timeout-bounded operation", elapsed)
}
select {
case <-c.outboundDone:
case <-time.After(time.Second):
t.Fatal("outbound actor did not stop after required-control write timeout")
}
if !c.terminal.Load() {
t.Fatal("blocked required control did not terminally close the connection")
}
if got := tr.closes.Load(); got != 1 {
t.Fatalf("transport closes = %d, want 1", got)
}
if got := controlBudget.snapshot(); got != 0 {
t.Fatalf("control budget after blocked write = %d, want 0", got)
}
}
func TestSendRequiredControlWriteFailureTerminatesAndReturnsBudget(t *testing.T) {
tr := &failAfterTransport{}
tr.failAt.Store(1)
controlBudget := newOutboundTrackedBudget(1 << 20)
c := newOutboundTestConn(t, tr, newOutboundTrackedBudget(1<<20))
c.outboundControlTrackedBudget = controlBudget
err := c.SendRequiredControl(context.Background(), proto.MessageServerResponse, &mt.Pong{MsgID: 1, PingID: 2})
if err == nil {
t.Fatal("write-failed required control unexpectedly succeeded")
}
select {
case <-c.outboundDone:
case <-time.After(time.Second):
t.Fatal("outbound actor did not stop after required-control write failure")
}
if !c.terminal.Load() {
t.Fatal("write-failed required control did not terminally close the connection")
}
if got := tr.closes.Load(); got != 1 {
t.Fatalf("transport closes = %d, want 1", got)
}
if got := controlBudget.snapshot(); got != 0 {
t.Fatalf("control budget after write failure = %d, want 0", got)
}
}
func TestSendRequiredControlBudgetFailureIsTerminal(t *testing.T) {
tr := &failAfterTransport{}
controlBudget := newOutboundTrackedBudget(1)
c := newOutboundTestConn(t, tr, newOutboundTrackedBudget(1<<20))
c.outboundControlTrackedBudget = controlBudget
err := c.SendRequiredControl(context.Background(), proto.MessageServerResponse, &mt.Pong{MsgID: 1, PingID: 2})
if !errors.Is(err, ErrOutboundTrackedBudget) {
t.Fatalf("required control over budget = %v, want ErrOutboundTrackedBudget", err)
}
select {
case <-c.outboundDone:
case <-time.After(time.Second):
t.Fatal("outbound actor did not stop after required-control budget failure")
}
if !c.terminal.Load() {
t.Fatal("required-control budget failure did not terminally close the connection")
}
if got := tr.sends.Load(); got != 0 {
t.Fatalf("budget-rejected required control wrote %d frames, want 0", got)
}
if got := controlBudget.snapshot(); got != 0 {
t.Fatalf("control budget after reservation failure = %d, want 0", got)
}
}

View file

@ -0,0 +1,202 @@
package mtprotoedge
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gotd/td/bin"
"github.com/gotd/td/mt"
"github.com/gotd/td/proto"
"github.com/gotd/td/proto/codec"
"github.com/gotd/td/tg"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
type activationGatedAuthKeyStore struct {
store.AuthKeyStore
gets atomic.Int32
finalStarted chan struct{}
finalRelease chan struct{}
startOnce sync.Once
}
func (s *activationGatedAuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
if s.gets.Add(1) == 2 {
s.startOnce.Do(func() { close(s.finalStarted) })
select {
case <-s.finalRelease:
case <-ctx.Done():
return store.AuthKeyData{}, false, ctx.Err()
}
}
return s.AuthKeyStore.Get(ctx, id)
}
func waitForManagedSessionAbsent(t *testing.T, manager *SessionManager, key sessionKey) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
manager.mu.RLock()
claim, active := manager.claims[key], manager.bySession[key]
manager.mu.RUnlock()
if claim == nil && active == nil {
return
}
if time.Now().After(deadline) {
t.Fatalf("managed session survived terminal rejection: claim=%p active=%p", claim, active)
}
time.Sleep(time.Millisecond)
}
}
func TestBadSaltStormRevalidatesStoreOnlyAtActivationBoundary(t *testing.T) {
const dc = 2
keys := &countingAuthKeyStore{AuthKeyStore: memory.NewAuthKeyStore()}
handler := &admissionCountingRPC{}
addr, pub, _ := startTestServer(t, Options{DC: dc, AuthKeys: keys, RPC: handler})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
ids := proto.NewMessageIDGen(time.Now)
firstID := ids.New(proto.MessageFromClient)
const wrongFrames = 16
for i := 0; i < wrongFrames; i++ {
msgID := firstID
if i != 0 {
msgID = ids.New(proto.MessageFromClient)
}
sendEncryptedWithSalt(t, conn, cipher, auth, auth.ServerSalt+1, msgID, &tg.HelpGetConfigRequest{})
_, typeID, _ := readServerMessage(t, conn, cipher, auth.AuthKey)
if typeID != mt.BadServerSaltTypeID {
t.Fatalf("correction %d type = %#x", i, typeID)
}
}
if got := keys.gets.Load(); got != 1 {
t.Fatalf("AuthKeyStore.Get during bad-salt storm = %d, want initial lookup only", got)
}
sendEncrypted(t, conn, cipher, auth, firstID, &tg.HelpGetConfigRequest{})
collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{proto.ResultTypeID: 1})
if got := keys.gets.Load(); got != 2 {
t.Fatalf("AuthKeyStore.Get after activation boundary = %d, want 2", got)
}
waitForAtomicCalls(t, &handler.calls, 1)
}
func TestActivationFinalAuthKeyCheckRunsAfterClaim(t *testing.T) {
const dc = 2
base := memory.NewAuthKeyStore()
keys := &activationGatedAuthKeyStore{
AuthKeyStore: base,
finalStarted: make(chan struct{}),
finalRelease: make(chan struct{}),
}
defer func() {
select {
case <-keys.finalRelease:
default:
close(keys.finalRelease)
}
}()
handler := &admissionCountingRPC{}
addr, pub, srv := startTestServer(t, Options{DC: dc, AuthKeys: keys, RPC: handler})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
msgID := proto.NewMessageIDGen(time.Now).New(proto.MessageFromClient)
// The first Get is serveConn's decrypt lookup. The second is deliberately
// blocked: it must start only after BeginActivation indexed the claim.
sendEncrypted(t, conn, cipher, auth, msgID, &tg.HelpGetConfigRequest{})
select {
case <-keys.finalStarted:
case <-time.After(2 * time.Second):
t.Fatal("activation final auth-key check did not start")
}
key := sessionKey{authKeyID: auth.AuthKey.ID, sessionID: auth.SessionID}
srv.conns.mu.RLock()
claim, active := srv.conns.claims[key], srv.conns.bySession[key]
srv.conns.mu.RUnlock()
if claim == nil || active != nil {
t.Fatalf("final auth-key check not protected by claim: claim=%p active=%p", claim, active)
}
// Model Delete committing after the initial decrypt lookup but before the
// activation check returns. The claimant must emit terminal -404, never publish
// or dispatch the request, even before revocation fan-out gets the manager lock.
if err := base.Delete(context.Background(), auth.AuthKey.ID); err != nil {
t.Fatalf("delete auth key during activation: %v", err)
}
close(keys.finalRelease)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var response bin.Buffer
err := conn.Recv(ctx, &response)
var protocolErr *codec.ProtocolErr
if !errors.As(err, &protocolErr) || protocolErr.Code != codec.CodeAuthKeyNotFound {
t.Fatalf("revoked activation recv = %T %v, want protocol -404", err, err)
}
if got := handler.calls.Load(); got != 0 {
t.Fatalf("revoked activation executed %d RPCs", got)
}
waitForManagedSessionAbsent(t, srv.conns, key)
}
func TestBadSaltProvisionalCannotReactivateDeletedAuthKey(t *testing.T) {
const dc = 2
handler := &admissionCountingRPC{}
addr, pub, srv := startTestServer(t, Options{DC: dc, RPC: handler})
provisional, auth, cipher := dialHandshake(t, addr, dc, pub)
ids := proto.NewMessageIDGen(time.Now)
reqMsgID := ids.New(proto.MessageFromClient)
// Socket A is retained as a bad-salt provisional and is intentionally absent
// from SessionManager active/claim indexes.
sendEncryptedWithSalt(t, provisional, cipher, auth, auth.ServerSalt+1, reqMsgID, &tg.HelpGetConfigRequest{})
_, typeID, _ := readServerMessage(t, provisional, cipher, auth.AuthKey)
if typeID != mt.BadServerSaltTypeID {
t.Fatalf("provisional correction type = %#x, want bad_server_salt", typeID)
}
key := sessionKey{authKeyID: auth.AuthKey.ID, sessionID: auth.SessionID}
srv.conns.mu.RLock()
activeBefore, claimBefore := srv.conns.bySession[key], srv.conns.claims[key]
srv.conns.mu.RUnlock()
if activeBefore != nil || claimBefore != nil {
t.Fatalf("bad-salt provisional leaked into manager: active=%p claim=%p", activeBefore, claimBefore)
}
// Socket B uses the same auth key with another session and deletes it. The
// provisional is not manager-indexed, so correctness depends on its next-frame
// AuthKeyStore recheck rather than fan-out close alone.
destroyer := dialTransportOnly(t, addr)
destroySessionID := auth.SessionID ^ 1
destroyBody := encodeClientMessageBodyForTest(t, &destroyAuthKeyRequest{})
sendEncryptedWithSessionSaltAndSeq(
t, destroyer, cipher, auth, destroySessionID, auth.ServerSalt,
ids.New(proto.MessageFromClient), 1, destroyBody,
)
destroyReplies := collectReplies(t, destroyer, cipher, auth.AuthKey, destroyAuthKeyOkTypeID)
mustHave(t, destroyReplies, destroyAuthKeyOkTypeID, "destroy_auth_key_ok")
if _, found, err := srv.authKeys.Get(context.Background(), auth.AuthKey.ID); err != nil || found {
t.Fatalf("auth key after destroy: found=%v err=%v", found, err)
}
// A corrected resend must now receive terminal -404; it must not activate or
// execute the previously rejected business request with its cached key.
sendEncrypted(t, provisional, cipher, auth, reqMsgID, &tg.HelpGetConfigRequest{})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var response bin.Buffer
err := provisional.Recv(ctx, &response)
var protocolErr *codec.ProtocolErr
if !errors.As(err, &protocolErr) || protocolErr.Code != codec.CodeAuthKeyNotFound {
t.Fatalf("corrected revoked provisional recv = %T %v, want protocol -404", err, err)
}
if got := handler.calls.Load(); got != 0 {
t.Fatalf("revoked provisional executed %d RPCs", got)
}
waitForManagedSessionAbsent(t, srv.conns, key)
}

View file

@ -0,0 +1,207 @@
package mtprotoedge
import (
"context"
"errors"
"sync/atomic"
)
const rpcResultFlightDefaultMaxPending = 8192
var (
// ErrRPCResultFlightCapacity is returned before installing a new owner when
// the process-wide in-flight claim table has reached its hard bound.
ErrRPCResultFlightCapacity = errors.New("mtproto rpc result in-flight capacity exhausted")
ErrRPCResultFlightInvalid = errors.New("mtproto rpc result in-flight claim is invalid")
)
type rpcResultAcquireState uint8
const (
rpcResultAcquireCompleted rpcResultAcquireState = iota + 1
rpcResultAcquirePending
rpcResultAcquireOwner
)
// rpcResultAcquire is the atomic outcome for one
// (auth_key_id, session_id, req_msg_id) claim.
//
// Exactly one state-specific field is non-nil:
// - completed: encoded contains the immutable completed rpc_result;
// - pending: waiter joins the already-running owner;
// - owner: owner must eventually complete through rpcResultCache.Put or Abort.
type rpcResultAcquire struct {
state rpcResultAcquireState
encoded *encodedOutboundMessage
waiter *rpcResultWaiter
owner *rpcResultOwnerLease
}
// rpcResultFlight is not part of the completed cache LRU/TTL lifecycle. Its
// done channel is closed exactly once while holding the owning cache shard lock;
// channel close publishes encoded/ok to all waiters without a waiter goroutine.
type rpcResultFlight struct {
done chan struct{}
encoded *encodedOutboundMessage
ok bool
}
type rpcResultWaiter struct {
flight *rpcResultFlight
}
// Wait blocks until the owner publishes through Put, aborts, or ctx expires.
// ok=false with err=nil means the owner aborted without a result.
func (w *rpcResultWaiter) Wait(ctx context.Context) (encoded *encodedOutboundMessage, ok bool, err error) {
if w == nil || w.flight == nil || ctx == nil {
return nil, false, ErrRPCResultFlightInvalid
}
// Prefer an already-published result over a concurrently canceled context.
select {
case <-w.flight.done:
return w.flight.encoded, w.flight.ok, nil
default:
}
select {
case <-w.flight.done:
return w.flight.encoded, w.flight.ok, nil
case <-ctx.Done():
// If completion raced with cancellation, prefer the terminal flight state.
select {
case <-w.flight.done:
return w.flight.encoded, w.flight.ok, nil
default:
return nil, false, ctx.Err()
}
}
}
type rpcResultOwnerLease struct {
cache *rpcResultCache
key rpcResultCacheKey
flight *rpcResultFlight
}
// Abort releases an unfinished owner claim and wakes every waiter with no
// result. Pointer identity prevents an old lease from deleting a later owner
// that reacquired the same key. It returns true only for the winning abort.
func (l *rpcResultOwnerLease) Abort() bool {
if l == nil || l.cache == nil || l.flight == nil {
return false
}
s := l.cache.shard(l.key)
s.mu.Lock()
defer s.mu.Unlock()
flight, ok := s.pending[l.key]
if !ok || flight != l.flight {
return false
}
delete(s.pending, l.key)
l.cache.flightLimit.release()
close(flight.done)
return true
}
type rpcResultFlightLimit struct {
max int64
used atomic.Int64
}
func (l *rpcResultFlightLimit) reserve() bool {
if l == nil || l.max <= 0 {
return false
}
for {
used := l.used.Load()
if used >= l.max {
return false
}
if l.used.CompareAndSwap(used, used+1) {
return true
}
}
}
func (l *rpcResultFlightLimit) release() {
if l == nil {
return
}
if remaining := l.used.Add(-1); remaining < 0 {
// Put/Abort use map removal and lease identity to make double release
// impossible. Fail fast instead of masking a capacity-accounting bug that
// could otherwise admit more owners than the configured hard limit.
panic("mtproto rpc result in-flight counter underflow")
}
}
func (l *rpcResultFlightLimit) snapshot() int64 {
if l == nil {
return 0
}
return l.used.Load()
}
// Acquire atomically returns a completed result, joins the existing in-flight
// owner, or installs the unique owner lease. Pending entries have a separate
// lifecycle from completed cache trim/TTL and consume one process-wide slot.
func (c *rpcResultCache) Acquire(authKeyID [8]byte, sessionID, reqMsgID int64) (rpcResultAcquire, error) {
if c == nil || reqMsgID == 0 {
return rpcResultAcquire{}, ErrRPCResultFlightInvalid
}
key := rpcResultCacheKey{authKeyID: authKeyID, sessionID: sessionID, reqMsgID: reqMsgID}
s := c.shard(key)
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
if elem, ok := s.byKey[key]; ok {
entry := elem.Value.(*rpcResultCacheEntry)
if entry.expiresAt.After(now) {
return rpcResultAcquire{state: rpcResultAcquireCompleted, encoded: entry.encoded}, nil
}
s.removeElement(elem)
}
if flight, ok := s.pending[key]; ok {
return rpcResultAcquire{
state: rpcResultAcquirePending,
waiter: &rpcResultWaiter{flight: flight},
}, nil
}
if !c.flightLimit.reserve() {
return rpcResultAcquire{}, ErrRPCResultFlightCapacity
}
flight := &rpcResultFlight{done: make(chan struct{})}
if s.pending == nil {
s.pending = make(map[rpcResultCacheKey]*rpcResultFlight)
}
s.pending[key] = flight
return rpcResultAcquire{
state: rpcResultAcquireOwner,
owner: &rpcResultOwnerLease{cache: c, key: key, flight: flight},
}, nil
}
// completeRPCResultFlightLocked publishes encoded to the current owner claim.
// The caller must hold s.mu and must publish the completed cache entry first.
func (c *rpcResultCache) completeRPCResultFlightLocked(
s *rpcResultCacheShard,
key rpcResultCacheKey,
encoded *encodedOutboundMessage,
) {
if c == nil || s == nil || encoded == nil {
return
}
flight, ok := s.pending[key]
if !ok {
return
}
delete(s.pending, key)
flight.encoded = encoded
flight.ok = true
c.flightLimit.release()
close(flight.done)
}

View file

@ -0,0 +1,396 @@
package mtprotoedge
import (
"context"
"errors"
"sync"
"testing"
"time"
)
func rpcFlightTestAuthID(seed byte) [8]byte {
return [8]byte{seed, seed + 1, seed + 2, seed + 3}
}
func TestRPCResultFlightConcurrentAcquireHasUniqueOwner(t *testing.T) {
const callers = 64
cache := newRPCResultCacheWithFlightLimit(time.Now, callers)
authKeyID := rpcFlightTestAuthID(1)
start := make(chan struct{})
results := make(chan rpcResultAcquire, callers)
errs := make(chan error, callers)
var wg sync.WaitGroup
wg.Add(callers)
for i := 0; i < callers; i++ {
go func() {
defer wg.Done()
<-start
claim, err := cache.Acquire(authKeyID, 10, 100)
if err != nil {
errs <- err
return
}
results <- claim
}()
}
close(start)
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("Acquire: %v", err)
}
owners := 0
waiters := 0
var owner *rpcResultOwnerLease
for claim := range results {
switch claim.state {
case rpcResultAcquireOwner:
owners++
owner = claim.owner
case rpcResultAcquirePending:
waiters++
if claim.waiter == nil {
t.Fatal("pending claim has nil waiter")
}
default:
t.Fatalf("unexpected claim state %d", claim.state)
}
}
if owners != 1 || waiters != callers-1 {
t.Fatalf("claims = owners:%d waiters:%d, want 1/%d", owners, waiters, callers-1)
}
if got := cache.flightLimit.snapshot(); got != 1 {
t.Fatalf("pending count = %d, want 1", got)
}
if owner == nil || !owner.Abort() {
t.Fatal("unique owner did not abort its claim")
}
if got := cache.flightLimit.snapshot(); got != 0 {
t.Fatalf("pending count after abort = %d, want 0", got)
}
}
func TestRPCResultFlightPutPublishesAndWakesAllWaiters(t *testing.T) {
const waiters = 24
cache := newRPCResultCacheWithFlightLimit(time.Now, 32)
authKeyID := rpcFlightTestAuthID(10)
owner, err := cache.Acquire(authKeyID, 20, 200)
if err != nil || owner.state != rpcResultAcquireOwner || owner.owner == nil {
t.Fatalf("owner Acquire = state:%d err:%v", owner.state, err)
}
waiterClaims := make([]*rpcResultWaiter, 0, waiters)
for i := 0; i < waiters; i++ {
claim, acquireErr := cache.Acquire(authKeyID, 20, 200)
if acquireErr != nil || claim.state != rpcResultAcquirePending || claim.waiter == nil {
t.Fatalf("waiter %d Acquire = state:%d err:%v", i, claim.state, acquireErr)
}
waiterClaims = append(waiterClaims, claim.waiter)
}
type waiterResult struct {
encoded *encodedOutboundMessage
cached *encodedOutboundMessage
ok bool
err error
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
results := make(chan waiterResult, waiters)
for _, waiter := range waiterClaims {
go func(w *rpcResultWaiter) {
encoded, ok, waitErr := w.Wait(ctx)
cached, _ := cache.Get(authKeyID, 20, 200)
results <- waiterResult{encoded: encoded, cached: cached, ok: ok, err: waitErr}
}(waiter)
}
want := &encodedOutboundMessage{body: []byte{1, 2, 3, 4}, typeID: 42, reqMsgID: 200}
cache.Put(authKeyID, 20, 200, want)
for i := 0; i < waiters; i++ {
got := <-results
if got.err != nil || !got.ok {
t.Fatalf("waiter %d result = ok:%v err:%v", i, got.ok, got.err)
}
if got.encoded != want || got.cached != want {
t.Fatalf("waiter %d observed direct/cache pointers %p/%p, want %p", i, got.encoded, got.cached, want)
}
}
if got := cache.flightLimit.snapshot(); got != 0 {
t.Fatalf("pending count after Put = %d, want 0", got)
}
completed, err := cache.Acquire(authKeyID, 20, 200)
if err != nil || completed.state != rpcResultAcquireCompleted || completed.encoded != want {
t.Fatalf("completed Acquire = state:%d encoded:%p err:%v", completed.state, completed.encoded, err)
}
if owner.owner.Abort() {
t.Fatal("completed owner's stale lease aborted a resolved claim")
}
}
func TestRPCResultFlightAbortWakesAndAllowsReclaim(t *testing.T) {
cache := newRPCResultCacheWithFlightLimit(time.Now, 2)
authKeyID := rpcFlightTestAuthID(20)
first, err := cache.Acquire(authKeyID, 30, 300)
if err != nil || first.state != rpcResultAcquireOwner {
t.Fatalf("first Acquire = state:%d err:%v", first.state, err)
}
waiting, err := cache.Acquire(authKeyID, 30, 300)
if err != nil || waiting.state != rpcResultAcquirePending {
t.Fatalf("waiting Acquire = state:%d err:%v", waiting.state, err)
}
if !first.owner.Abort() {
t.Fatal("first Abort lost")
}
if encoded, ok, waitErr := waiting.waiter.Wait(context.Background()); waitErr != nil || ok || encoded != nil {
t.Fatalf("aborted Wait = encoded:%p ok:%v err:%v", encoded, ok, waitErr)
}
if got := cache.flightLimit.snapshot(); got != 0 {
t.Fatalf("pending count after Abort = %d, want 0", got)
}
second, err := cache.Acquire(authKeyID, 30, 300)
if err != nil || second.state != rpcResultAcquireOwner || second.owner == nil {
t.Fatalf("reclaim = state:%d err:%v", second.state, err)
}
if first.owner.Abort() {
t.Fatal("stale first lease aborted the replacement owner")
}
if got := cache.flightLimit.snapshot(); got != 1 {
t.Fatalf("pending count after reclaim = %d, want 1", got)
}
if !second.owner.Abort() || cache.flightLimit.snapshot() != 0 {
t.Fatal("replacement owner did not release its claim")
}
}
func TestRPCResultFlightCompletedCachePressureDoesNotEvictPending(t *testing.T) {
now := time.Unix(1_000, 0)
cache := newRPCResultCacheWithFlightLimit(func() time.Time { return now }, 4)
authKeyID := rpcFlightTestAuthID(30)
pending, err := cache.Acquire(authKeyID, 40, 400)
if err != nil || pending.state != rpcResultAcquireOwner {
t.Fatalf("pending Acquire = state:%d err:%v", pending.state, err)
}
key := rpcResultCacheKey{authKeyID: authKeyID, sessionID: 40, reqMsgID: 400}
shard := cache.shard(key)
shard.mu.Lock()
shard.maxEntries = 2
shard.maxBytes = 2
shard.mu.Unlock()
for i := int64(0); i < 16; i++ {
cache.Put(authKeyID, 40, 500+i, &encodedOutboundMessage{body: []byte{byte(i)}})
}
if got := cache.flightLimit.snapshot(); got != 1 {
t.Fatalf("completed trim changed pending count to %d", got)
}
joined, err := cache.Acquire(authKeyID, 40, 400)
if err != nil || joined.state != rpcResultAcquirePending {
t.Fatalf("Acquire after completed trim = state:%d err:%v", joined.state, err)
}
// Expire the independent completed cache and prove the pending owner remains.
now = now.Add(rpcResultCacheTTL + time.Second)
_, _ = cache.Get(authKeyID, 40, 515)
joinedAfterTTL, err := cache.Acquire(authKeyID, 40, 400)
if err != nil || joinedAfterTTL.state != rpcResultAcquirePending {
t.Fatalf("Acquire after completed TTL = state:%d err:%v", joinedAfterTTL.state, err)
}
if !pending.owner.Abort() || cache.flightLimit.snapshot() != 0 {
t.Fatal("pending claim did not survive pressure through explicit Abort")
}
}
func TestRPCResultFlightCapacityAndCountReturn(t *testing.T) {
cache := newRPCResultCacheWithFlightLimit(time.Now, 2)
authKeyID := rpcFlightTestAuthID(40)
first, err := cache.Acquire(authKeyID, 50, 501)
if err != nil || first.state != rpcResultAcquireOwner {
t.Fatalf("first Acquire = state:%d err:%v", first.state, err)
}
second, err := cache.Acquire(authKeyID, 50, 502)
if err != nil || second.state != rpcResultAcquireOwner {
t.Fatalf("second Acquire = state:%d err:%v", second.state, err)
}
joined, err := cache.Acquire(authKeyID, 50, 501)
if err != nil || joined.state != rpcResultAcquirePending {
t.Fatalf("join at capacity = state:%d err:%v", joined.state, err)
}
if _, err := cache.Acquire(authKeyID, 50, 503); !errors.Is(err, ErrRPCResultFlightCapacity) {
t.Fatalf("over-capacity Acquire err = %v, want %v", err, ErrRPCResultFlightCapacity)
}
if got := cache.flightLimit.snapshot(); got != 2 {
t.Fatalf("pending count at capacity = %d, want 2", got)
}
want := &encodedOutboundMessage{body: []byte{9}, reqMsgID: 501}
cache.Put(authKeyID, 50, 501, want)
if got := cache.flightLimit.snapshot(); got != 1 {
t.Fatalf("pending count after Put = %d, want 1", got)
}
third, err := cache.Acquire(authKeyID, 50, 503)
if err != nil || third.state != rpcResultAcquireOwner {
t.Fatalf("Acquire after returned slot = state:%d err:%v", third.state, err)
}
completed, err := cache.Acquire(authKeyID, 50, 501)
if err != nil || completed.state != rpcResultAcquireCompleted || completed.encoded != want {
t.Fatalf("completed Acquire at capacity = state:%d err:%v", completed.state, err)
}
if encoded, ok, waitErr := joined.waiter.Wait(context.Background()); waitErr != nil || !ok || encoded != want {
t.Fatalf("joined Wait = encoded:%p ok:%v err:%v", encoded, ok, waitErr)
}
if !second.owner.Abort() || !third.owner.Abort() {
t.Fatal("owners failed to return remaining capacity")
}
if got := cache.flightLimit.snapshot(); got != 0 {
t.Fatalf("final pending count = %d, want 0", got)
}
}
func TestRPCResultFlightOversizedPutStillResolvesWaiters(t *testing.T) {
cache := newRPCResultCacheWithFlightLimit(time.Now, 1)
authKeyID := rpcFlightTestAuthID(50)
owner, err := cache.Acquire(authKeyID, 60, 600)
if err != nil || owner.state != rpcResultAcquireOwner {
t.Fatalf("owner Acquire = state:%d err:%v", owner.state, err)
}
joined, err := cache.Acquire(authKeyID, 60, 600)
if err != nil || joined.state != rpcResultAcquirePending {
t.Fatalf("joined Acquire = state:%d err:%v", joined.state, err)
}
key := rpcResultCacheKey{authKeyID: authKeyID, sessionID: 60, reqMsgID: 600}
shard := cache.shard(key)
shard.mu.Lock()
shard.maxBytes = 1
shard.mu.Unlock()
want := &encodedOutboundMessage{body: []byte{1, 2}, reqMsgID: 600}
cache.Put(authKeyID, 60, 600, want)
if encoded, ok, waitErr := joined.waiter.Wait(context.Background()); waitErr != nil || !ok || encoded != want {
t.Fatalf("oversized Wait = encoded:%p ok:%v err:%v", encoded, ok, waitErr)
}
if _, ok := cache.Get(authKeyID, 60, 600); ok {
t.Fatal("oversized result changed completed-cache compatibility")
}
if got := cache.flightLimit.snapshot(); got != 0 {
t.Fatalf("oversized Put leaked pending count %d", got)
}
if owner.owner.Abort() {
t.Fatal("oversized Put left its old owner abortable")
}
retry, err := cache.Acquire(authKeyID, 60, 600)
if err != nil || retry.state != rpcResultAcquireOwner {
t.Fatalf("retry after uncacheable completion = state:%d err:%v", retry.state, err)
}
if !retry.owner.Abort() {
t.Fatal("retry owner failed to abort")
}
}
func TestRPCResultFlightWaitContextDoesNotReleaseOwner(t *testing.T) {
cache := newRPCResultCacheWithFlightLimit(time.Now, 1)
authKeyID := rpcFlightTestAuthID(60)
owner, err := cache.Acquire(authKeyID, 70, 700)
if err != nil {
t.Fatalf("owner Acquire: %v", err)
}
joined, err := cache.Acquire(authKeyID, 70, 700)
if err != nil {
t.Fatalf("joined Acquire: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if encoded, ok, waitErr := joined.waiter.Wait(ctx); !errors.Is(waitErr, context.Canceled) || ok || encoded != nil {
t.Fatalf("canceled Wait = encoded:%p ok:%v err:%v", encoded, ok, waitErr)
}
if got := cache.flightLimit.snapshot(); got != 1 {
t.Fatalf("waiter cancellation released owner count to %d", got)
}
if !owner.owner.Abort() || cache.flightLimit.snapshot() != 0 {
t.Fatal("owner did not retain and release claim after waiter cancellation")
}
}
func TestRPCResultFlightConcurrentCapacityReturnsAllSlots(t *testing.T) {
const (
limit = 32
callers = 512
)
cache := newRPCResultCacheWithFlightLimit(time.Now, limit)
authKeyID := rpcFlightTestAuthID(70)
start := make(chan struct{})
errs := make(chan error, callers)
var wg sync.WaitGroup
wg.Add(callers)
for i := 0; i < callers; i++ {
i := i
go func() {
defer wg.Done()
<-start
claim, err := cache.Acquire(authKeyID, 80+int64(i%4), 1_000+int64(i))
if errors.Is(err, ErrRPCResultFlightCapacity) {
return
}
if err != nil {
errs <- err
return
}
if claim.state != rpcResultAcquireOwner || claim.owner == nil {
errs <- errors.New("unique-key claim did not become owner")
return
}
if i%2 == 0 {
cache.Put(authKeyID, 80+int64(i%4), 1_000+int64(i), &encodedOutboundMessage{body: []byte{1}})
} else if !claim.owner.Abort() {
errs <- errors.New("owner Abort lost")
}
}()
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
t.Errorf("concurrent claim: %v", err)
}
if got := cache.flightLimit.snapshot(); got != 0 {
t.Fatalf("concurrent completion leaked %d pending slots", got)
}
}
func TestQueuedRPCConnectionCloseAbortsOwnerClaim(t *testing.T) {
s := New(Options{})
c := newInboundTestConn(s.rpcScheduler, 1, 4, time.Second)
claim, err := s.rpcResults.Acquire([8]byte{9}, 90, 900)
if err != nil || claim.state != rpcResultAcquireOwner || claim.owner == nil {
t.Fatalf("Acquire owner = state:%d err:%v", claim.state, err)
}
reservation, err := c.reserveInboundRPC(context.Background(), "test.queuedFlight", 4)
if err != nil {
t.Fatalf("reserve queued RPC: %v", err)
}
task := s.newInboundRPCTask(c, 900, "test.queuedFlight", []byte{1, 2, 3, 4}, claim.owner)
if err := reservation.commit(task); err != nil {
t.Fatalf("commit queued RPC: %v", err)
}
// The scheduler is intentionally not started, so close must drain the queued
// task and invoke its independent flight-release callback.
c.closeInboundRPCScheduler()
if got := s.rpcResults.flightLimit.snapshot(); got != 0 {
t.Fatalf("connection close leaked %d queued owner claims", got)
}
if tasks, bytes := s.rpcScheduler.budgetSnapshot(); tasks != 0 || bytes != 0 {
t.Fatalf("connection close leaked scheduler budget %d/%d", tasks, bytes)
}
retry, err := s.rpcResults.Acquire([8]byte{9}, 90, 900)
if err != nil || retry.state != rpcResultAcquireOwner {
t.Fatalf("reclaim after queued close = state:%d err:%v", retry.state, err)
}
if !retry.owner.Abort() {
t.Fatal("replacement owner did not abort")
}
}

View file

@ -30,11 +30,14 @@ type rpcResultCacheEntry struct {
expiresAt time.Time expiresAt time.Time
} }
// rpcResultCache 缓存已回发的 rpc_result按 auth_key+session+req_msg_id用于 // rpcResultCache 缓存已有交付证明的 rpc_result按 auth_key+session+req_msg_id
// 跨连接重放重复请求。encodedOutboundMessage 构造后不可变push fan-out 与 pending // 用于跨连接重放重复请求。Put 的调用方必须先证明结果已物理写出,或原 logical Conn
// resend 均依赖该契约),因此 Get/Put 直接共享指针,不做防御性拷贝。 // 已不可逆 fenced绝不能发布“Conn 仍 current/open 但结果尚未上 wire”的完成态。
// encodedOutboundMessage 构造后不可变push fan-out 与 pending resend 均依赖该契约),
// 因此 Get/Put 直接共享指针,不做防御性拷贝。
type rpcResultCache struct { type rpcResultCache struct {
shards [rpcResultCacheShards]rpcResultCacheShard shards [rpcResultCacheShards]rpcResultCacheShard
flightLimit rpcResultFlightLimit
} }
type rpcResultCacheShard struct { type rpcResultCacheShard struct {
@ -46,13 +49,25 @@ type rpcResultCacheShard struct {
bytes int bytes int
order *list.List order *list.List
byKey map[rpcResultCacheKey]*list.Element byKey map[rpcResultCacheKey]*list.Element
// pending is deliberately independent from the completed-result order/byKey
// cache. In-flight owners and waiters must not disappear when completed
// results expire or are trimmed under entry/byte pressure.
pending map[rpcResultCacheKey]*rpcResultFlight
} }
func newRPCResultCache(now func() time.Time) *rpcResultCache { func newRPCResultCache(now func() time.Time) *rpcResultCache {
return newRPCResultCacheWithFlightLimit(now, rpcResultFlightDefaultMaxPending)
}
func newRPCResultCacheWithFlightLimit(now func() time.Time, maxPending int) *rpcResultCache {
if now == nil { if now == nil {
now = time.Now now = time.Now
} }
if maxPending <= 0 {
maxPending = rpcResultFlightDefaultMaxPending
}
c := &rpcResultCache{} c := &rpcResultCache{}
c.flightLimit.max = int64(maxPending)
for i := range c.shards { for i := range c.shards {
s := &c.shards[i] s := &c.shards[i]
s.now = now s.now = now
@ -61,6 +76,7 @@ func newRPCResultCache(now func() time.Time) *rpcResultCache {
s.maxBytes = rpcResultCacheMaxBytes / rpcResultCacheShards s.maxBytes = rpcResultCacheMaxBytes / rpcResultCacheShards
s.order = list.New() s.order = list.New()
s.byKey = make(map[rpcResultCacheKey]*list.Element) s.byKey = make(map[rpcResultCacheKey]*list.Element)
s.pending = make(map[rpcResultCacheKey]*rpcResultFlight)
} }
return c return c
} }
@ -102,28 +118,32 @@ func (c *rpcResultCache) Put(authKeyID [8]byte, sessionID, reqMsgID int64, encod
key := rpcResultCacheKey{authKeyID: authKeyID, sessionID: sessionID, reqMsgID: reqMsgID} key := rpcResultCacheKey{authKeyID: authKeyID, sessionID: sessionID, reqMsgID: reqMsgID}
s := c.shard(key) s := c.shard(key)
size := len(encoded.body) size := len(encoded.body)
if s.maxBytes > 0 && size > s.maxBytes { cacheable := s.maxBytes <= 0 || size <= s.maxBytes
return
}
now := s.now() now := s.now()
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.expireLocked(now) if cacheable {
if elem, ok := s.byKey[key]; ok { s.expireLocked(now)
s.removeElement(elem) if elem, ok := s.byKey[key]; ok {
s.removeElement(elem)
}
entry := &rpcResultCacheEntry{
key: key,
encoded: encoded,
size: size,
expiresAt: now.Add(s.ttl),
}
elem := s.order.PushBack(entry)
s.byKey[key] = elem
s.bytes += size
s.trimLocked()
} }
entry := &rpcResultCacheEntry{ // Resolve the independent in-flight entry only after the completed cache has
key: key, // been published. Waiters awakened by this close can therefore immediately
encoded: encoded, // observe either the shared encoded result or the completed Get entry.
size: size, c.completeRPCResultFlightLocked(s, key, encoded)
expiresAt: now.Add(s.ttl),
}
elem := s.order.PushBack(entry)
s.byKey[key] = elem
s.bytes += size
s.trimLocked()
} }
func (s *rpcResultCacheShard) expireLocked(now time.Time) { func (s *rpcResultCacheShard) expireLocked(now time.Time) {

View file

@ -0,0 +1,199 @@
package mtprotoedge
import (
"context"
"errors"
"io"
"sync"
"testing"
"time"
"github.com/gotd/td/bin"
"github.com/gotd/td/proto"
"github.com/gotd/td/tg"
)
type blockingCloseRPCResultTransport struct {
started chan struct{}
release chan struct{}
once sync.Once
}
func newBlockingCloseRPCResultTransport() *blockingCloseRPCResultTransport {
return &blockingCloseRPCResultTransport{started: make(chan struct{}), release: make(chan struct{})}
}
func (*blockingCloseRPCResultTransport) Send(context.Context, *bin.Buffer) error { return nil }
func (*blockingCloseRPCResultTransport) Recv(context.Context, *bin.Buffer) error { return io.EOF }
func (t *blockingCloseRPCResultTransport) Close() error {
t.once.Do(func() { close(t.started) })
<-t.release
return nil
}
func TestRPCResultCachePublishesOnlyAfterPhysicalWrite(t *testing.T) {
tr := newGatedRequiredControlTransport(nil)
s := New(Options{WriteTimeout: time.Second})
key := newTestAuthKey(t)
c := s.newConn(tr, key, 74001, 1)
t.Cleanup(c.ForceClose)
reqMsgID := proto.NewMessageIDGen(time.Now).New(proto.MessageFromClient)
owner, err := s.rpcResults.Acquire(key.ID, c.sessionID, reqMsgID)
if err != nil || owner.state != rpcResultAcquireOwner {
t.Fatalf("initial flight owner = %+v err=%v", owner, err)
}
done := make(chan error, 1)
go func() {
done <- s.sendResult(context.Background(), c, reqMsgID, &tg.Config{ThisDC: 2})
}()
select {
case <-tr.started:
case <-time.After(time.Second):
t.Fatal("rpc_result did not reach physical writer")
}
if _, ok := s.rpcResults.Get(key.ID, c.sessionID, reqMsgID); ok {
t.Fatal("rpc_result became completed before physical write")
}
pending, err := s.rpcResults.Acquire(key.ID, c.sessionID, reqMsgID)
if err != nil || pending.state != rpcResultAcquirePending || pending.waiter == nil {
t.Fatalf("flight while write blocked = %+v err=%v", pending, err)
}
tr.unblock()
select {
case err := <-done:
if err != nil {
t.Fatalf("sendResult after write: %v", err)
}
case <-time.After(time.Second):
t.Fatal("sendResult did not finish")
}
if encoded, ok, err := pending.waiter.Wait(context.Background()); err != nil || !ok || encoded == nil {
t.Fatalf("pending waiter after write = encoded:%p ok:%v err:%v", encoded, ok, err)
}
completed, err := s.rpcResults.Acquire(key.ID, c.sessionID, reqMsgID)
if err != nil || completed.state != rpcResultAcquireCompleted || completed.encoded == nil {
t.Fatalf("flight after write = %+v err=%v", completed, err)
}
}
func TestRPCResultPrewriteFailureFencesConnBeforeCachePublication(t *testing.T) {
tr := &collectingSessionTransport{}
s := New(Options{WriteTimeout: 20 * time.Millisecond})
// No rpc_result can reserve its conservative 3x wire scratch from one byte.
// The actor therefore fails before touching the socket, which used to leave a
// live Conn with a prematurely completed cache entry.
s.outboundScratchPool = newOutboundScratchPool(1)
key := newTestAuthKey(t)
c := s.newConn(tr, key, 74002, 1)
reqMsgID := proto.NewMessageIDGen(time.Now).New(proto.MessageFromClient)
owner, err := s.rpcResults.Acquire(key.ID, c.sessionID, reqMsgID)
if err != nil || owner.state != rpcResultAcquireOwner {
t.Fatalf("initial flight owner = %+v err=%v", owner, err)
}
err = s.sendResult(context.Background(), c, reqMsgID, &tg.Config{ThisDC: 2})
if err == nil || (!errors.Is(err, context.DeadlineExceeded) &&
!errors.Is(err, ErrConnClosed) && !errors.Is(err, ErrOutboundTrackedBudget)) {
t.Fatalf("prewrite sendResult error = %v", err)
}
closeDeadline := time.Now().Add(time.Second)
for !tr.closed.Load() && time.Now().Before(closeDeadline) {
time.Sleep(time.Millisecond)
}
if !c.terminal.Load() || !tr.closed.Load() || c.isPhysicalTransportCurrentOpen() {
t.Fatalf("failed delivery did not fence Conn: terminal=%v closed=%v current_open=%v", c.terminal.Load(), tr.closed.Load(), c.isPhysicalTransportCurrentOpen())
}
completed, acquireErr := s.rpcResults.Acquire(key.ID, c.sessionID, reqMsgID)
if acquireErr != nil || completed.state != rpcResultAcquireCompleted || completed.encoded == nil {
t.Fatalf("terminal replay cache = %+v err=%v", completed, acquireErr)
}
if got := s.rpcResults.flightLimit.snapshot(); got != 0 {
t.Fatalf("failed delivery leaked flight slot: %d", got)
}
c.Close()
}
func TestRPCResultFailureAfterIntentionalTerminalDoesNotCloseTransferLease(t *testing.T) {
tr := &collectingSessionTransport{}
s := New(Options{WriteTimeout: time.Second})
s.outboundTrackedBudget = newOutboundTrackedBudget(1)
key := newTestAuthKey(t)
oldConn := s.newConn(tr, key, 74003, 1)
reqMsgID := proto.NewMessageIDGen(time.Now).New(proto.MessageFromClient)
owner, err := s.rpcResults.Acquire(key.ID, oldConn.sessionID, reqMsgID)
if err != nil || owner.state != rpcResultAcquireOwner {
t.Fatalf("initial flight owner = %+v err=%v", owner, err)
}
// Session replacement publishes terminal before transferring the physical
// lease. A late old-generation result sees ErrConnClosed at producer admission;
// it may publish cache-only, but must not upgrade that intentional fence into
// a physical close that makes Transfer fail.
oldConn.beginTerminalShutdown()
err = s.sendResult(context.Background(), oldConn, reqMsgID, &tg.Config{ThisDC: 2})
if !errors.Is(err, ErrOutboundTrackedBudget) {
t.Fatalf("late result error = %v, want ErrOutboundTrackedBudget", err)
}
if tr.closed.Load() {
t.Fatal("late result closed intentionally transferable transport")
}
if !oldConn.waitOutboundShutdownUntil(time.Second) {
t.Fatal("old outbound actor did not stop")
}
nextLease, ok := oldConn.transferTransportOwnership()
if !ok || nextLease == nil {
t.Fatal("late result prevented physical transfer")
}
newConn := s.newConnWithLease(nextLease, key, 74004, 1)
oldConn.ForceClose()
if tr.closed.Load() || !newConn.isPhysicalTransportCurrentOpen() {
t.Fatalf("stale close after transfer: raw_closed=%v current_open=%v", tr.closed.Load(), newConn.isPhysicalTransportCurrentOpen())
}
completed, acquireErr := s.rpcResults.Acquire(key.ID, oldConn.sessionID, reqMsgID)
if acquireErr != nil || completed.state != rpcResultAcquireCompleted || completed.encoded == nil {
t.Fatalf("late result cache = %+v err=%v", completed, acquireErr)
}
newConn.ForceClose()
}
func TestRPCResultPublishesBeforePathologicalPhysicalCloseReturns(t *testing.T) {
tr := newBlockingCloseRPCResultTransport()
s := New(Options{WriteTimeout: time.Second})
s.outboundTrackedBudget = newOutboundTrackedBudget(1)
key := newTestAuthKey(t)
c := s.newConn(tr, key, 74005, 1)
reqMsgID := proto.NewMessageIDGen(time.Now).New(proto.MessageFromClient)
owner, err := s.rpcResults.Acquire(key.ID, c.sessionID, reqMsgID)
if err != nil || owner.state != rpcResultAcquireOwner {
t.Fatalf("initial flight owner = %+v err=%v", owner, err)
}
done := make(chan error, 1)
go func() { done <- s.sendResult(context.Background(), c, reqMsgID, &tg.Config{ThisDC: 2}) }()
select {
case <-tr.started:
case <-time.After(time.Second):
t.Fatal("physical Close did not start")
}
select {
case err := <-done:
if !errors.Is(err, ErrOutboundTrackedBudget) {
t.Fatalf("sendResult error = %v, want budget error", err)
}
case <-time.After(time.Second):
t.Fatal("pathological physical Close blocked result publication")
}
completed, acquireErr := s.rpcResults.Acquire(key.ID, c.sessionID, reqMsgID)
if acquireErr != nil || completed.state != rpcResultAcquireCompleted || completed.encoded == nil {
t.Fatalf("completed result before raw Close return = %+v err=%v", completed, acquireErr)
}
close(tr.release)
if c.transportLease != nil {
if err := c.transportLease.owner.waitClosed(); err != nil {
t.Fatalf("physical Close: %v", err)
}
}
c.Close()
}

View file

@ -257,7 +257,8 @@ func TestDuplicateRPCResultAcrossReconnectUsesSessionCache(t *testing.T) {
func TestCanceledRPCErrorIsNotCachedAcrossReconnect(t *testing.T) { func TestCanceledRPCErrorIsNotCachedAcrossReconnect(t *testing.T) {
const dc = 2 const dc = 2
handler := &canceledInternalRPC{ handler := &canceledInternalRPC{
firstDone: make(chan struct{}), firstStarted: make(chan struct{}),
firstDone: make(chan struct{}),
} }
addr, pub, _ := startTestServer(t, Options{DC: dc, RPC: handler}) addr, pub, _ := startTestServer(t, Options{DC: dc, RPC: handler})
conn, auth, cipher := dialHandshake(t, addr, dc, pub) conn, auth, cipher := dialHandshake(t, addr, dc, pub)
@ -266,6 +267,11 @@ func TestCanceledRPCErrorIsNotCachedAcrossReconnect(t *testing.T) {
reqMsgID := clientMsgID.New(proto.MessageFromClient) reqMsgID := clientMsgID.New(proto.MessageFromClient)
sendEncrypted(t, conn, cipher, auth, reqMsgID, &tg.HelpGetConfigRequest{}) sendEncrypted(t, conn, cipher, auth, reqMsgID, &tg.HelpGetConfigRequest{})
select {
case <-handler.firstStarted:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for first rpc to start after session barrier")
}
_ = conn.Close() _ = conn.Close()
select { select {
case <-handler.firstDone: case <-handler.firstDone:
@ -355,12 +361,14 @@ func (h *runningDeadlineRPC) Dispatch(ctx context.Context, _ [8]byte, _ int64, _
func (h *runningDeadlineRPC) NegotiatedLayer([8]byte, int64) (int, bool) { return 227, true } func (h *runningDeadlineRPC) NegotiatedLayer([8]byte, int64) (int, bool) { return 227, true }
type canceledInternalRPC struct { type canceledInternalRPC struct {
calls atomic.Int32 calls atomic.Int32
firstDone chan struct{} firstStarted chan struct{}
firstDone chan struct{}
} }
func (h *canceledInternalRPC) Dispatch(ctx context.Context, _ [8]byte, _ int64, _ *bin.Buffer) (bin.Encoder, error) { func (h *canceledInternalRPC) Dispatch(ctx context.Context, _ [8]byte, _ int64, _ *bin.Buffer) (bin.Encoder, error) {
if h.calls.Add(1) == 1 { if h.calls.Add(1) == 1 {
close(h.firstStarted)
<-ctx.Done() <-ctx.Done()
close(h.firstDone) close(h.firstDone)
return nil, tgerr.New(500, "INTERNAL_SERVER_ERROR") return nil, tgerr.New(500, "INTERNAL_SERVER_ERROR")

View file

@ -172,7 +172,7 @@ func (o *Options) setDefaults() {
o.RPCGlobalWorkers = 256 o.RPCGlobalWorkers = 256
} }
if o.RPCGlobalMaxTasks <= 0 { if o.RPCGlobalMaxTasks <= 0 {
o.RPCGlobalMaxTasks = 8192 o.RPCGlobalMaxTasks = rpcResultFlightDefaultMaxPending
} }
if o.RPCGlobalMaxBytes <= 0 { if o.RPCGlobalMaxBytes <= 0 {
o.RPCGlobalMaxBytes = 512 << 20 o.RPCGlobalMaxBytes = 512 << 20
@ -295,7 +295,7 @@ func New(opts Options) *Server {
clock: opts.Clock, clock: opts.Clock,
rand: opts.Rand, rand: opts.Rand,
types: tmap.New(tg.TypesMap(), mt.TypesMap(), proto.TypesMap()), types: tmap.New(tg.TypesMap(), mt.TypesMap(), proto.TypesMap()),
rpcResults: newRPCResultCache(opts.Clock.Now), rpcResults: newRPCResultCacheWithFlightLimit(opts.Clock.Now, opts.RPCGlobalMaxTasks),
admission: newAdmissionController(opts.MaxConnections, opts.MaxConnectionsPerIP, opts.MaxConcurrentHandshakes), admission: newAdmissionController(opts.MaxConnections, opts.MaxConnectionsPerIP, opts.MaxConcurrentHandshakes),
} }
} }
@ -307,8 +307,33 @@ func (s *Server) Conns() *SessionManager {
// newConn 基于一次解密结果创建一个可发送的连接对象。 // newConn 基于一次解密结果创建一个可发送的连接对象。
func (s *Server) newConn(tc transport.Conn, key crypto.AuthKey, sessionID, salt int64) *Conn { func (s *Server) newConn(tc transport.Conn, key crypto.AuthKey, sessionID, salt int64) *Conn {
if lease, ok := tc.(*physicalTransportLease); ok {
return s.newConnWithLease(lease, key, sessionID, salt)
}
if tc != nil {
_, lease := newPhysicalTransportOwner(tc)
return s.newConnWithLease(lease, key, sessionID, salt)
}
// Preserve the nil transport used by construction-only tests.
return s.buildConn(nil, nil, key, sessionID, salt)
}
// newConnWithLease attaches a logical Conn to an explicitly owned physical
// transport generation. Production session replacement must Transfer the old
// lease first; it must never wrap the same raw transport in a second owner.
func (s *Server) newConnWithLease(lease *physicalTransportLease, key crypto.AuthKey, sessionID, salt int64) *Conn {
if lease == nil {
panic("mtprotoedge: nil physical transport lease")
}
c := s.buildConn(lease, lease, key, sessionID, salt)
lease.bindLogicalConn(c)
return c
}
func (s *Server) buildConn(tc transport.Conn, lease *physicalTransportLease, key crypto.AuthKey, sessionID, salt int64) *Conn {
c := &Conn{ c := &Conn{
transport: tc, transport: tc,
transportLease: lease,
writer: tc, writer: tc,
cipher: s.cipher, cipher: s.cipher,
msgID: proto.NewMessageIDGen(s.clock.Now), msgID: proto.NewMessageIDGen(s.clock.Now),
@ -558,7 +583,8 @@ func (s *Server) promoteConn(raw net.Conn, obfuscated bool) (transport.Conn, err
// - auth_key_id 未注册:回 AuthKeyNotFound促使客户端重新握手。 // - auth_key_id 未注册:回 AuthKeyNotFound促使客户端重新握手。
// //
// 连接建立 session 后注册到 SessionManager结束时注销。 // 连接建立 session 后注册到 SessionManager结束时注销。
func (s *Server) serveConn(ctx context.Context, conn transport.Conn) (err error) { func (s *Server) serveConn(ctx context.Context, raw transport.Conn) (err error) {
transportOwner, conn := newPhysicalTransportOwner(raw)
s.metrics.ConnOpened() s.metrics.ConnOpened()
s.log.Debug("Connection accepted") s.log.Debug("Connection accepted")
@ -568,9 +594,13 @@ func (s *Server) serveConn(ctx context.Context, conn transport.Conn) (err error)
// this stack has stopped using b/plain; transport.Close may have raced us earlier and must // this stack has stopped using b/plain; transport.Close may have raced us earlier and must
// not return that memory budget prematurely. // not return that memory budget prematurely.
releaseInboundFrameOwnership(conn) releaseInboundFrameOwnership(conn)
// 先同步关闭物理 socket解除可能阻塞在 writer.Send 的 outbound actor // Publish the terminal/RPC-cancel gates before index removal or lifecycle
// 再停止 logical Conn避免 Close 等 actor 时反过来等到 write deadline。 // observers. Physical close then releases a writer already inside Send; the
_ = conn.Close() // final Close only waits for the now-fenced actors to converge.
if current != nil {
current.beginTerminalShutdown()
}
_ = transportOwner.CloseAny()
if current != nil { if current != nil {
s.conns.Unregister(current) s.conns.Unregister(current)
current.Close() current.Close()
@ -584,7 +614,7 @@ func (s *Server) serveConn(ctx context.Context, conn transport.Conn) (err error)
defer cancel() defer cancel()
go func() { go func() {
<-ctx.Done() <-ctx.Done()
_ = conn.Close() _ = transportOwner.CloseAny()
}() }()
cs := newConnState() cs := newConnState()
@ -601,7 +631,7 @@ func (s *Server) serveConn(ctx context.Context, conn transport.Conn) (err error)
// 建立 session 前current==nil握手 + 首个加密消息之前)用较短的 handshakeTimeout // 建立 session 前current==nil握手 + 首个加密消息之前)用较短的 handshakeTimeout
// 快速回收静默的半开 / 异常连接;建立 session 后用 readTimeout客户端有 ping 心跳)。 // 快速回收静默的半开 / 异常连接;建立 session 后用 readTimeout客户端有 ping 心跳)。
timeout := s.readTimeout timeout := s.readTimeout
if current == nil { if current == nil || !current.isActive() {
timeout = s.handshakeTimeout timeout = s.handshakeTimeout
} }
if err := s.recv(ctx, conn, &b, timeout); err != nil { if err := s.recv(ctx, conn, &b, timeout); err != nil {
@ -618,6 +648,12 @@ func (s *Server) serveConn(ctx context.Context, conn transport.Conn) (err error)
} }
if authKeyID == emptyAuthKeyID { if authKeyID == emptyAuthKeyID {
// A physical socket may perform key exchange only before it owns an
// encrypted logical session. Mixing the direct exchange writer with an
// active outbound actor would bypass generation/write serialization.
if current != nil {
return errors.New("unencrypted exchange on established encrypted connection")
}
releaseHandshake, admitted := s.admission.tryAcquireHandshake() releaseHandshake, admitted := s.admission.tryAcquireHandshake()
if !admitted { if !admitted {
if err := s.sendProtoError(ctx, conn, codec.CodeTransportFlood); err != nil { if err := s.sendProtoError(ctx, conn, codec.CodeTransportFlood); err != nil {
@ -647,7 +683,9 @@ func (s *Server) serveConn(ctx context.Context, conn transport.Conn) (err error)
// 已建立连接复用缓存密钥走快路径fetchedKey=nil避开每帧回查 AuthKeyStore—— // 已建立连接复用缓存密钥走快路径fetchedKey=nil避开每帧回查 AuthKeyStore——
// 这是 mtprotoedge 层最热的库访问点。密钥材料创建后不可变;销毁(destroy_auth_key)/ // 这是 mtprotoedge 层最热的库访问点。密钥材料创建后不可变;销毁(destroy_auth_key)/
// 撤销由 SessionManager 主动 Close 连接保证失效,不依赖此被动回查。仅 destroy_auth_key // 撤销由 SessionManager 主动 Close 连接保证失效,不依赖此被动回查。仅 destroy_auth_key
// 的发起连接置 keyDestroyed使其下一帧回落到 Get→AuthKeyNotFound维持原契约。 // 的发起连接置 keyDestroyed使其下一帧回落到 Get→AuthKeyNotFound。尚未进入
// SessionManager 的 bad-salt provisional 会在 handleEncrypted 建立 activation claim
// 后精确复查一次,既把撤销与激活线性化,也不把 salt storm 放大成 PG 写风暴。
var fetchedKey *store.AuthKeyData var fetchedKey *store.AuthKeyData
if current == nil || current.authKeyID != authKeyID || current.keyDestroyed.Load() { if current == nil || current.authKeyID != authKeyID || current.keyDestroyed.Load() {
d, found, err := s.authKeys.Get(ctx, authKeyID) d, found, err := s.authKeys.Get(ctx, authKeyID)
@ -655,7 +693,11 @@ func (s *Server) serveConn(ctx context.Context, conn transport.Conn) (err error)
return fmt.Errorf("lookup auth key: %w", err) return fmt.Errorf("lookup auth key: %w", err)
} }
if !found { if !found {
if err := s.sendProtoError(ctx, conn, codec.CodeAuthKeyNotFound); err != nil { writer := transport.Conn(conn)
if current != nil {
writer = current.transport
}
if err := s.sendProtoError(ctx, writer, codec.CodeAuthKeyNotFound); err != nil {
return err return err
} }
// -404 对 TDesktop 是 terminal key failure继续保留 socket 只会允许 // -404 对 TDesktop 是 terminal key failure继续保留 socket 只会允许
@ -666,6 +708,11 @@ func (s *Server) serveConn(ctx context.Context, conn transport.Conn) (err error)
} }
current, err = s.handleEncrypted(ctx, conn, cs, current, fetchedKey, &b, &plain) current, err = s.handleEncrypted(ctx, conn, cs, current, fetchedKey, &b, &plain)
if errors.Is(err, errActivationAuthKeyRejected) {
// handleEncrypted writes -404 while its activation claim still owns the
// physical writer, then its deferred abort removes/closes the claim.
return nil
}
if err != nil { if err != nil {
return err return err
} }

View file

@ -0,0 +1,253 @@
package mtprotoedge
import (
"context"
"errors"
"testing"
"time"
"go.uber.org/zap/zaptest"
"github.com/gotd/td/mt"
"github.com/gotd/td/proto"
)
func newSessionActivationTestConn(t *testing.T, authKeyID [8]byte, sessionID int64) *Conn {
t.Helper()
c := &Conn{
authKeyID: authKeyID,
sessionID: sessionID,
metrics: NopMetrics{},
}
c.startOutbound()
t.Cleanup(c.Close)
return c
}
func TestSessionActivationGatesReplacementBeforePublishing(t *testing.T) {
manager := NewSessionManager(zaptest.NewLogger(t))
key := [8]byte{1, 2, 3, 4}
oldConn := newSessionActivationTestConn(t, key, 7001)
newConn := newSessionActivationTestConn(t, key, 7001)
if err := manager.Register(oldConn); err != nil {
t.Fatalf("register initial: %v", err)
}
if !oldConn.isActive() {
t.Fatal("initial connection was not activated")
}
if newConn.lifecycleState() != connLifecycleProvisional {
t.Fatal("provisional replacement was active before registration")
}
if err := manager.Register(newConn); err != nil {
t.Fatalf("register replacement: %v", err)
}
if !newConn.isActive() {
t.Fatal("replacement was not activated")
}
if oldConn.lifecycleState() != connLifecycleRetired || !oldConn.terminal.Load() {
t.Fatalf("old connection gates = lifecycle:%v terminal:%v", oldConn.lifecycleState(), oldConn.terminal.Load())
}
if err := oldConn.SendAsync(context.Background(), proto.MessageFromServer, &mt.MsgsAck{}); !errors.Is(err, ErrConnClosed) {
t.Fatalf("stale old connection send error = %v, want ErrConnClosed", err)
}
manager.mu.RLock()
got := manager.bySession[sessionKey{authKeyID: key, sessionID: 7001}]
manager.mu.RUnlock()
if got != newConn {
t.Fatalf("published session = %p, want replacement %p", got, newConn)
}
}
func TestSessionActivationClaimPreemptionCannotReversePublish(t *testing.T) {
manager := NewSessionManager(zaptest.NewLogger(t))
key := [8]byte{9, 8, 7, 6}
first := newSessionActivationTestConn(t, key, 8001)
second := newSessionActivationTestConn(t, key, 8001)
if err := manager.BeginActivation(first); err != nil {
t.Fatalf("begin first activation: %v", err)
}
if first.lifecycleState() != connLifecycleClaiming {
t.Fatalf("first lifecycle = %v, want claiming", first.lifecycleState())
}
if got := manager.Online(); got != 0 {
t.Fatalf("online during claim = %d, want 0", got)
}
if err := manager.BeginActivation(second); err != nil {
t.Fatalf("begin superseding activation: %v", err)
}
if first.lifecycleState() != connLifecycleRetired || !first.terminal.Load() {
t.Fatalf("superseded first lifecycle=%v terminal=%v", first.lifecycleState(), first.terminal.Load())
}
if err := manager.PublishActivation(first); !errors.Is(err, ErrSessionActivationSuperseded) {
t.Fatalf("stale publish error = %v, want superseded", err)
}
if err := manager.Register(first); !errors.Is(err, ErrSessionActivationSuperseded) {
t.Fatalf("stale register error = %v, want superseded", err)
}
if err := manager.PublishActivation(second); err != nil {
t.Fatalf("publish second activation: %v", err)
}
manager.mu.RLock()
got := manager.bySession[sessionKey{authKeyID: key, sessionID: 8001}]
claim := manager.claims[sessionKey{authKeyID: key, sessionID: 8001}]
manager.mu.RUnlock()
if got != second || claim != nil || !second.isActive() {
t.Fatalf("activation owner=%p claim=%p second_active=%v", got, claim, second.isActive())
}
}
func TestBeginActivationWaitsForPreviousPhysicalWriterFence(t *testing.T) {
manager := NewSessionManager(zaptest.NewLogger(t))
key := [8]byte{3, 3, 3, 3}
releaseClose := make(chan struct{})
transport := newSlowCloseTransport(0, releaseClose)
oldConn := &Conn{
authKeyID: key,
sessionID: 8501,
metrics: NopMetrics{},
transport: transport,
writer: transport,
}
oldConn.startOutbound()
t.Cleanup(oldConn.Close)
if err := manager.Register(oldConn); err != nil {
t.Fatalf("register old: %v", err)
}
newConn := newSessionActivationTestConn(t, key, oldConn.sessionID)
beginDone := make(chan error, 1)
go func() { beginDone <- manager.BeginActivation(newConn) }()
deadline := time.Now().Add(time.Second)
for transport.closes.Load() == 0 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if transport.closes.Load() == 0 {
t.Fatal("replacement did not start closing previous transport")
}
select {
case err := <-beginDone:
t.Fatalf("BeginActivation returned before old physical close: %v", err)
default:
}
close(releaseClose)
select {
case err := <-beginDone:
if err != nil {
t.Fatalf("BeginActivation after old close: %v", err)
}
case <-time.After(time.Second):
t.Fatal("BeginActivation did not converge after old close")
}
if err := manager.PublishActivation(newConn); err != nil {
t.Fatalf("publish replacement: %v", err)
}
}
func TestSessionActivationClaimIndexesCleanOnPublishAndAbort(t *testing.T) {
manager := NewSessionManager(zaptest.NewLogger(t))
key := [8]byte{4, 4, 4, 4}
published := newSessionActivationTestConn(t, key, 9001)
if err := manager.BeginActivation(published); err != nil {
t.Fatalf("begin publish claim: %v", err)
}
manager.mu.RLock()
if manager.claims[connSessionKey(published)] != published || manager.claimsByAuth[key][published.sessionID] != published {
manager.mu.RUnlock()
t.Fatal("claim indexes missing after BeginActivation")
}
manager.mu.RUnlock()
if err := manager.PublishActivation(published); err != nil {
t.Fatalf("publish claim: %v", err)
}
manager.mu.RLock()
globalClaims, authClaims := len(manager.claims), len(manager.claimsByAuth[key])
manager.mu.RUnlock()
if globalClaims != 0 || authClaims != 0 {
t.Fatalf("claim indexes after publish = %d/%d, want 0/0", globalClaims, authClaims)
}
aborted := newSessionActivationTestConn(t, key, 9002)
if err := manager.BeginActivation(aborted); err != nil {
t.Fatalf("begin abort claim: %v", err)
}
manager.AbortActivation(aborted)
manager.mu.RLock()
globalClaims, authClaims = len(manager.claims), len(manager.claimsByAuth[key])
manager.mu.RUnlock()
if globalClaims != 0 || authClaims != 0 || aborted.lifecycleState() != connLifecycleRetired {
t.Fatalf("claim indexes/lifecycle after abort = %d/%d/%v", globalClaims, authClaims, aborted.lifecycleState())
}
}
func TestRawAuthKeyCloseExactConnDoesNotExcludeSameSessionReplacement(t *testing.T) {
manager := NewSessionManager(zaptest.NewLogger(t))
key := [8]byte{4, 3, 2, 1}
const sessionID = 9050
// The destroy request may finish on a retired Conn after another physical
// connection has become the owner of the same logical session.
destroyer := newSessionActivationTestConn(t, key, sessionID)
destroyer.beginTerminalShutdown()
replacement := newSessionActivationTestConn(t, key, sessionID)
if err := manager.Register(replacement); err != nil {
t.Fatalf("register replacement: %v", err)
}
if got := manager.CloseSessionsForRawAuthKeyExceptConn(key, destroyer); got != 1 {
t.Fatalf("closed sessions = %d, want replacement only", got)
}
manager.mu.RLock()
active, claim := manager.bySession[sessionKey{authKeyID: key, sessionID: sessionID}], manager.claims[sessionKey{authKeyID: key, sessionID: sessionID}]
manager.mu.RUnlock()
if active != nil || claim != nil || !replacement.terminal.Load() {
t.Fatalf("same-session replacement escaped exact exclusion: active=%p claim=%p terminal=%v", active, claim, replacement.terminal.Load())
}
}
func TestDestroyFencesOutboundAndReservedRPCBeforeRemoval(t *testing.T) {
manager := NewSessionManager(zaptest.NewLogger(t))
key := [8]byte{5, 5, 5, 5}
c := newSessionActivationTestConn(t, key, 9101)
scheduler := newInboundRPCScheduler(1, 8, 1<<20)
c.startInboundRPCScheduler(scheduler, 1, 8, time.Second)
if err := manager.Register(c); err != nil {
t.Fatalf("register: %v", err)
}
reservation, err := c.reserveInboundRPC(context.Background(), "test.destroyFence", 8)
if err != nil {
t.Fatalf("reserve inbound RPC: %v", err)
}
destroyed := make(chan bool, 1)
go func() {
destroyed <- manager.DestroySessionForAuthKey(key, c.sessionID)
}()
select {
case <-c.rpcRootCtx.Done():
case <-time.After(time.Second):
t.Fatal("destroy did not synchronously cancel RPC admission")
}
if err := c.SendAsync(context.Background(), proto.MessageFromServer, &mt.MsgsAck{}); !errors.Is(err, ErrConnClosed) {
t.Fatalf("send after destroy fence = %v, want ErrConnClosed", err)
}
if err := reservation.commit(inboundRPC{}); !errors.Is(err, ErrConnClosed) {
t.Fatalf("reserved RPC commit after destroy = %v, want ErrConnClosed", err)
}
select {
case ok := <-destroyed:
if !ok {
t.Fatal("destroy returned false")
}
case <-time.After(time.Second):
t.Fatal("destroy did not converge after reservation commit")
}
if tasks, bytes := scheduler.budgetSnapshot(); tasks != 0 || bytes != 0 {
t.Fatalf("scheduler budget after destroy = %d/%d, want 0/0", tasks, bytes)
}
}

View file

@ -0,0 +1,630 @@
package mtprotoedge
import (
"context"
"crypto/rand"
"errors"
"io"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gotd/td/bin"
"github.com/gotd/td/crypto"
"github.com/gotd/td/mt"
"github.com/gotd/td/proto"
"github.com/gotd/td/tg"
"telesrv/internal/store"
)
func encryptedRPCFrameForBarrierTest(
t *testing.T,
key crypto.AuthKey,
salt, sessionID, msgID int64,
) (*bin.Buffer, store.AuthKeyData) {
return encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(t, key, salt, salt, sessionID, msgID, 1)
}
func encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
t *testing.T,
key crypto.AuthKey,
frameSalt, authoritativeSalt, sessionID, msgID int64,
seqNo int32,
) (*bin.Buffer, store.AuthKeyData) {
t.Helper()
body := encodeClientMessageBodyForTest(t, &tg.HelpGetConfigRequest{})
var frame bin.Buffer
if err := crypto.NewClientCipher(rand.Reader).Encrypt(key, crypto.EncryptedMessageData{
Salt: frameSalt,
SessionID: sessionID,
MessageID: msgID,
SeqNo: seqNo,
MessageDataLen: int32(len(body)),
MessageDataWithPadding: body,
}, &frame); err != nil {
t.Fatalf("encrypt client RPC: %v", err)
}
return &frame, store.AuthKeyData{
ID: key.ID,
Value: [256]byte(key.Value),
ServerSalt: authoritativeSalt,
}
}
func TestBadServerSaltRetainsOneProvisionalConnUntilCorrected(t *testing.T) {
handler := &admissionCountingRPC{}
s := New(Options{RPC: handler, WriteTimeout: time.Second})
s.rpcScheduler.start()
t.Cleanup(func() { s.rpcScheduler.stop(time.Second) })
tr := &collectingSessionTransport{}
key := newTestAuthKey(t)
const (
serverSalt = int64(0x1020304050)
wrongSalt = serverSalt + 1
sessionID = int64(71001)
)
ids := proto.NewMessageIDGen(time.Now)
firstID := ids.New(proto.MessageFromClient)
firstWrong, stored := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
t, key, wrongSalt, serverSalt, sessionID, firstID, 1,
)
if err := s.authKeys.Save(context.Background(), stored); err != nil {
t.Fatalf("save auth key: %v", err)
}
cs := newConnState()
var plain bin.Buffer
firstConn, err := s.handleEncrypted(context.Background(), tr, cs, nil, &stored, firstWrong, &plain)
if err != nil {
t.Fatalf("first bad salt: %v", err)
}
if firstConn == nil || firstConn.lifecycleState() != connLifecycleProvisional || firstConn.terminal.Load() {
t.Fatalf("first correction lifecycle conn=%p state=%v terminal=%v", firstConn, firstConn.lifecycleState(), firstConn.terminal.Load())
}
if cs.createdFloor != 0 || len(cs.seen) != 0 || handler.calls.Load() != 0 {
t.Fatalf("bad salt admitted state: floor=%d seen=%d calls=%d", cs.createdFloor, len(cs.seen), handler.calls.Load())
}
secondID := ids.New(proto.MessageFromClient)
secondWrong, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
t, key, wrongSalt, serverSalt, sessionID, secondID, 3,
)
secondConn, err := s.handleEncrypted(context.Background(), tr, cs, firstConn, nil, secondWrong, &plain)
if err != nil {
t.Fatalf("second bad salt: %v", err)
}
if secondConn != firstConn {
t.Fatalf("bad-salt retry replaced provisional Conn: first=%p second=%p", firstConn, secondConn)
}
if got := len(tr.snapshot()); got != 2 {
t.Fatalf("distinct bad msg ids produced %d corrections, want 2", got)
}
for i, wire := range tr.snapshot()[:2] {
data, decryptErr := crypto.NewClientCipher(rand.Reader).DecryptFromBuffer(key, &bin.Buffer{Buf: wire})
if decryptErr != nil {
t.Fatalf("decrypt correction %d: %v", i, decryptErr)
}
if data.Salt != serverSalt {
t.Fatalf("correction %d envelope salt = %#x, want %#x", i, data.Salt, serverSalt)
}
var bad mt.BadServerSalt
body := &bin.Buffer{Buf: append([]byte(nil), data.Data()...)}
if decodeErr := bad.Decode(body); decodeErr != nil {
t.Fatalf("decode correction %d: %v", i, decodeErr)
}
if bad.NewServerSalt != serverSalt {
t.Fatalf("correction %d payload salt = %#x, want %#x", i, bad.NewServerSalt, serverSalt)
}
}
corrected, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
t, key, serverSalt, serverSalt, sessionID, firstID, 1,
)
activeConn, err := s.handleEncrypted(context.Background(), tr, cs, secondConn, nil, corrected, &plain)
if err != nil {
t.Fatalf("corrected retry: %v", err)
}
if activeConn != firstConn || !activeConn.isActive() {
t.Fatalf("corrected retry connection=%p first=%p active=%v", activeConn, firstConn, activeConn != nil && activeConn.isActive())
}
if cs.createdFloor != firstID {
t.Fatalf("corrected created floor = %d, want %d", cs.createdFloor, firstID)
}
waitForAtomicCalls(t, &handler.calls, 1)
flightDeadline := time.Now().Add(2 * time.Second)
for s.rpcResults.flightLimit.snapshot() != 0 && time.Now().Before(flightDeadline) {
time.Sleep(time.Millisecond)
}
if got := s.rpcResults.flightLimit.snapshot(); got != 0 {
t.Fatalf("corrected retry leaked flight slots: %d", got)
}
activeConn.ForceClose()
}
func TestWrongSaltSessionChangeTransfersPhysicalOwnership(t *testing.T) {
// The first handler remains pending until session replacement cancels it. Its
// owner Abort must observe the already-published terminal gate and must not
// close the physical lease that is about to transfer to the new session.
handler := &cancelThenRetryRPC{started: make(chan struct{})}
s := New(Options{RPC: handler, WriteTimeout: time.Second})
s.rpcScheduler.start()
t.Cleanup(func() { s.rpcScheduler.stop(time.Second) })
tr := &collectingSessionTransport{}
key := newTestAuthKey(t)
const (
serverSalt = int64(0x66778899)
wrongSalt = serverSalt + 7
firstSID = int64(72001)
secondSID = int64(72002)
)
ids := proto.NewMessageIDGen(time.Now)
firstID := ids.New(proto.MessageFromClient)
firstFrame, stored := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
t, key, serverSalt, serverSalt, firstSID, firstID, 1,
)
if err := s.authKeys.Save(context.Background(), stored); err != nil {
t.Fatalf("save auth key: %v", err)
}
cs := newConnState()
var plain bin.Buffer
oldConn, err := s.handleEncrypted(context.Background(), tr, cs, nil, &stored, firstFrame, &plain)
if err != nil {
t.Fatalf("activate first session: %v", err)
}
select {
case <-handler.started:
case <-time.After(2 * time.Second):
t.Fatal("first session RPC did not start")
}
secondID := ids.New(proto.MessageFromClient)
wrongFrame, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
t, key, wrongSalt, serverSalt, secondSID, secondID, 1,
)
newConn, err := s.handleEncrypted(context.Background(), tr, cs, oldConn, nil, wrongFrame, &plain)
if err != nil {
t.Fatalf("new session bad salt: %v", err)
}
if newConn == nil || newConn == oldConn || newConn.lifecycleState() != connLifecycleProvisional {
t.Fatalf("replacement conn old=%p new=%p state=%v", oldConn, newConn, newConn.lifecycleState())
}
if !oldConn.terminal.Load() || tr.closed.Load() {
t.Fatalf("transfer state old_terminal=%v raw_closed=%v", oldConn.terminal.Load(), tr.closed.Load())
}
// A delayed stale close must not tear down the generation already transferred
// to the new logical session.
oldConn.ForceClose()
if tr.closed.Load() || !newConn.isPhysicalTransportCurrentOpen() {
t.Fatalf("stale ForceClose closed replacement: raw_closed=%v current_open=%v", tr.closed.Load(), newConn.isPhysicalTransportCurrentOpen())
}
corrected, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
t, key, serverSalt, serverSalt, secondSID, secondID, 1,
)
activated, err := s.handleEncrypted(context.Background(), tr, cs, newConn, nil, corrected, &plain)
if err != nil {
t.Fatalf("activate transferred session: %v", err)
}
if activated != newConn || !activated.isActive() {
t.Fatalf("transferred session active=%v conn=%p want=%p", activated != nil && activated.isActive(), activated, newConn)
}
waitForAtomicCalls(t, &handler.calls, 2)
activated.ForceClose()
}
type collectingSessionTransport struct {
mu sync.Mutex
frames [][]byte
closed atomic.Bool
}
func (t *collectingSessionTransport) Send(_ context.Context, b *bin.Buffer) error {
if t.closed.Load() {
return io.ErrClosedPipe
}
t.mu.Lock()
t.frames = append(t.frames, append([]byte(nil), b.Raw()...))
t.mu.Unlock()
return nil
}
func (*collectingSessionTransport) Recv(context.Context, *bin.Buffer) error { return io.EOF }
func (t *collectingSessionTransport) Close() error {
t.closed.Store(true)
return nil
}
func (t *collectingSessionTransport) snapshot() [][]byte {
t.mu.Lock()
defer t.mu.Unlock()
out := make([][]byte, len(t.frames))
for i := range t.frames {
out[i] = append([]byte(nil), t.frames[i]...)
}
return out
}
type reconnectFlightRPC struct {
calls atomic.Int32
started chan struct{}
release chan struct{}
once sync.Once
}
func (h *reconnectFlightRPC) Dispatch(context.Context, [8]byte, int64, *bin.Buffer) (bin.Encoder, error) {
h.calls.Add(1)
h.once.Do(func() { close(h.started) })
<-h.release // deliberately ignore replacement cancellation after committing work
return &tg.Config{ThisDC: 2}, nil
}
func (*reconnectFlightRPC) NegotiatedLayer([8]byte, int64) (int, bool) { return 227, true }
type cancelThenRetryRPC struct {
calls atomic.Int32
active atomic.Int32
max atomic.Int32
started chan struct{}
once sync.Once
}
func (h *cancelThenRetryRPC) Dispatch(ctx context.Context, _ [8]byte, _ int64, _ *bin.Buffer) (bin.Encoder, error) {
call := h.calls.Add(1)
active := h.active.Add(1)
defer h.active.Add(-1)
for {
max := h.max.Load()
if active <= max || h.max.CompareAndSwap(max, active) {
break
}
}
if call == 1 {
h.once.Do(func() { close(h.started) })
<-ctx.Done()
return nil, ctx.Err()
}
return &tg.Config{ThisDC: 2}, nil
}
func (*cancelThenRetryRPC) NegotiatedLayer([8]byte, int64) (int, bool) { return 227, true }
func TestHandleEncryptedRequiredSessionBarrierPrecedesStateRegistrationAndRPC(t *testing.T) {
handler := &admissionCountingRPC{}
s := New(Options{RPC: handler, WriteTimeout: time.Second})
s.rpcScheduler.start()
t.Cleanup(func() { s.rpcScheduler.stop(time.Second) })
tr := newGatedRequiredControlTransport(nil)
key := newTestAuthKey(t)
const (
salt = int64(0x10203040)
sessionID = int64(70001)
)
msgID := proto.NewMessageIDGen(time.Now).New(proto.MessageFromClient)
frame, stored := encryptedRPCFrameForBarrierTest(t, key, salt, sessionID, msgID)
if err := s.authKeys.Save(context.Background(), stored); err != nil {
t.Fatalf("save auth key: %v", err)
}
cs := newConnState()
var plain bin.Buffer
type result struct {
conn *Conn
err error
}
done := make(chan result, 1)
go func() {
conn, err := s.handleEncrypted(context.Background(), tr, cs, nil, &stored, frame, &plain)
done <- result{conn: conn, err: err}
}()
select {
case <-tr.started:
case <-time.After(time.Second):
t.Fatal("new_session_created did not reach the physical-write barrier")
}
if got := handler.calls.Load(); got != 0 {
t.Fatalf("handler calls while session barrier blocked = %d, want 0", got)
}
if cs.createdFloor != 0 || len(cs.seen) != 0 {
t.Fatalf("connState committed before session barrier: floor=%d seen=%d", cs.createdFloor, len(cs.seen))
}
s.conns.mu.RLock()
registered := s.conns.bySession[sessionKey{authKeyID: key.ID, sessionID: sessionID}]
claim := s.conns.claims[sessionKey{authKeyID: key.ID, sessionID: sessionID}]
s.conns.mu.RUnlock()
if registered != nil || claim == nil {
t.Fatalf("blocked barrier visibility = registered:%p claim:%p, want nil/non-nil", registered, claim)
}
select {
case got := <-done:
t.Fatalf("handleEncrypted returned before physical barrier: %v", got.err)
default:
}
tr.unblock()
var got result
select {
case got = <-done:
case <-time.After(2 * time.Second):
t.Fatal("handleEncrypted did not finish after session barrier write")
}
if got.err != nil {
t.Fatalf("handleEncrypted: %v", got.err)
}
if got.conn == nil || !got.conn.isActive() {
t.Fatalf("connection after successful barrier = %p lifecycle=%v", got.conn, got.conn.lifecycleState())
}
if cs.createdFloor != msgID {
t.Fatalf("created floor after barrier = %d, want %d", cs.createdFloor, msgID)
}
waitForAtomicCalls(t, &handler.calls, 1)
s.conns.mu.RLock()
registered = s.conns.bySession[sessionKey{authKeyID: key.ID, sessionID: sessionID}]
s.conns.mu.RUnlock()
if registered != got.conn {
t.Fatalf("registered connection = %p, want %p", registered, got.conn)
}
got.conn.ForceClose()
}
func TestHandleEncryptedRequiredSessionBarrierFailureIsAtomic(t *testing.T) {
handler := &admissionCountingRPC{}
s := New(Options{RPC: handler, WriteTimeout: time.Second})
s.rpcScheduler.start()
t.Cleanup(func() { s.rpcScheduler.stop(time.Second) })
tr := newGatedRequiredControlTransport(io.ErrClosedPipe)
key := newTestAuthKey(t)
const (
salt = int64(0x50607080)
sessionID = int64(70002)
)
msgID := proto.NewMessageIDGen(time.Now).New(proto.MessageFromClient)
frame, stored := encryptedRPCFrameForBarrierTest(t, key, salt, sessionID, msgID)
if err := s.authKeys.Save(context.Background(), stored); err != nil {
t.Fatalf("save auth key: %v", err)
}
cs := newConnState()
var plain bin.Buffer
done := make(chan error, 1)
go func() {
_, err := s.handleEncrypted(context.Background(), tr, cs, nil, &stored, frame, &plain)
done <- err
}()
select {
case <-tr.started:
case <-time.After(time.Second):
t.Fatal("failed new_session_created did not reach physical writer")
}
if handler.calls.Load() != 0 || cs.createdFloor != 0 || len(cs.seen) != 0 {
t.Fatalf("state changed while failing barrier blocked: calls=%d floor=%d seen=%d", handler.calls.Load(), cs.createdFloor, len(cs.seen))
}
tr.unblock()
select {
case err := <-done:
if err == nil || !(errors.Is(err, io.ErrClosedPipe) || errors.Is(err, ErrConnClosed)) {
t.Fatalf("failed session barrier error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("failed session barrier did not return")
}
if got := handler.calls.Load(); got != 0 {
t.Fatalf("handler ran after failed session barrier: %d", got)
}
if cs.createdFloor != 0 || len(cs.seen) != 0 || len(cs.order) != 0 {
t.Fatalf("failed barrier committed connState: floor=%d seen=%d order=%d", cs.createdFloor, len(cs.seen), len(cs.order))
}
s.conns.mu.RLock()
registered := s.conns.bySession[sessionKey{authKeyID: key.ID, sessionID: sessionID}]
claim := s.conns.claims[sessionKey{authKeyID: key.ID, sessionID: sessionID}]
s.conns.mu.RUnlock()
if registered != nil || claim != nil {
t.Fatalf("failed barrier leaked manager ownership: registered=%p claim=%p", registered, claim)
}
if tasks, bytes := s.rpcScheduler.budgetSnapshot(); tasks != 0 || bytes != 0 {
t.Fatalf("failed barrier leaked RPC budget: tasks=%d bytes=%d", tasks, bytes)
}
if got := s.frameBudget.usedBytes(); got != 0 {
t.Fatalf("failed barrier leaked frame budget: %d", got)
}
if got := s.rpcResults.flightLimit.snapshot(); got != 0 {
t.Fatalf("failed barrier leaked RPC claim slots: %d", got)
}
}
func TestCrossConnectionInflightRPCHasOneBusinessOwnerAndReplaysResult(t *testing.T) {
handler := &reconnectFlightRPC{started: make(chan struct{}), release: make(chan struct{})}
s := New(Options{RPC: handler, WriteTimeout: time.Second, RPCTimeout: 5 * time.Second})
s.rpcScheduler.start()
t.Cleanup(func() { s.rpcScheduler.stop(time.Second) })
key := newTestAuthKey(t)
const (
salt = int64(0x11223344)
sessionID = int64(70003)
)
msgID := proto.NewMessageIDGen(time.Now).New(proto.MessageFromClient)
firstFrame, stored := encryptedRPCFrameForBarrierTest(t, key, salt, sessionID, msgID)
if err := s.authKeys.Save(context.Background(), stored); err != nil {
t.Fatalf("save auth key: %v", err)
}
firstTransport := &collectingSessionTransport{}
firstState := newConnState()
var firstPlain bin.Buffer
firstConn, err := s.handleEncrypted(context.Background(), firstTransport, firstState, nil, &stored, firstFrame, &firstPlain)
if err != nil {
t.Fatalf("first handleEncrypted: %v", err)
}
select {
case <-handler.started:
case <-time.After(2 * time.Second):
t.Fatal("first RPC owner did not start")
}
secondFrame, _ := encryptedRPCFrameForBarrierTest(t, key, salt, sessionID, msgID)
secondTransport := &collectingSessionTransport{}
secondState := newConnState()
var secondPlain bin.Buffer
type handleResult struct {
conn *Conn
err error
}
secondDone := make(chan handleResult, 1)
go func() {
conn, handleErr := s.handleEncrypted(context.Background(), secondTransport, secondState, nil, &stored, secondFrame, &secondPlain)
secondDone <- handleResult{conn: conn, err: handleErr}
}()
deadline := time.Now().Add(2 * time.Second)
for !firstConn.terminal.Load() && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if !firstConn.terminal.Load() {
t.Fatal("replacement did not fence the first physical connection")
}
if got := handler.calls.Load(); got != 1 {
t.Fatalf("overlapping reconnect executed %d business handlers, want 1", got)
}
select {
case got := <-secondDone:
t.Fatalf("duplicate reconnect returned before owner completion: %v", got.err)
default:
}
close(handler.release)
var second handleResult
select {
case second = <-secondDone:
case <-time.After(3 * time.Second):
t.Fatal("duplicate reconnect did not receive owner result")
}
if second.err != nil {
t.Fatalf("second handleEncrypted: %v", second.err)
}
if second.conn == nil || !second.conn.isActive() {
t.Fatalf("second connection was not active after replay: %p", second.conn)
}
if got := handler.calls.Load(); got != 1 {
t.Fatalf("cross-connection duplicate business calls = %d, want 1", got)
}
resultCount := 0
for _, wire := range secondTransport.snapshot() {
data, decryptErr := crypto.NewClientCipher(rand.Reader).DecryptFromBuffer(key, &bin.Buffer{Buf: wire})
if decryptErr != nil {
t.Fatalf("decrypt second connection reply: %v", decryptErr)
}
plain := &bin.Buffer{Buf: append([]byte(nil), data.Data()...)}
typeID, peekErr := plain.PeekID()
if peekErr != nil {
t.Fatalf("peek second connection reply: %v", peekErr)
}
if typeID != proto.ResultTypeID {
continue
}
var result proto.Result
if decodeErr := result.Decode(plain); decodeErr != nil {
t.Fatalf("decode replayed rpc_result: %v", decodeErr)
}
if result.RequestMessageID != msgID {
t.Fatalf("replayed rpc_result request id = %d, want %d", result.RequestMessageID, msgID)
}
resultCount++
}
if resultCount != 1 {
t.Fatalf("replayed rpc_result count on replacement = %d, want 1", resultCount)
}
waitInboundRPCBatchBudget(t, s.rpcScheduler, 0, 0)
if got := s.rpcResults.flightLimit.snapshot(); got != 0 {
t.Fatalf("completed reconnect leaked flight claims: %d", got)
}
second.conn.ForceClose()
}
func TestCrossConnectionInflightAbortRetriesOnlyAfterOldOwnerStops(t *testing.T) {
handler := &cancelThenRetryRPC{started: make(chan struct{})}
s := New(Options{RPC: handler, WriteTimeout: time.Second, RPCTimeout: 5 * time.Second})
s.rpcScheduler.start()
t.Cleanup(func() { s.rpcScheduler.stop(time.Second) })
key := newTestAuthKey(t)
const (
salt = int64(0x55667788)
sessionID = int64(70004)
)
msgID := proto.NewMessageIDGen(time.Now).New(proto.MessageFromClient)
firstFrame, stored := encryptedRPCFrameForBarrierTest(t, key, salt, sessionID, msgID)
if err := s.authKeys.Save(context.Background(), stored); err != nil {
t.Fatalf("save auth key: %v", err)
}
firstTransport := &collectingSessionTransport{}
firstState := newConnState()
var firstPlain bin.Buffer
firstConn, err := s.handleEncrypted(context.Background(), firstTransport, firstState, nil, &stored, firstFrame, &firstPlain)
if err != nil {
t.Fatalf("first handleEncrypted: %v", err)
}
select {
case <-handler.started:
case <-time.After(2 * time.Second):
t.Fatal("first cancellation-aware owner did not start")
}
secondFrame, _ := encryptedRPCFrameForBarrierTest(t, key, salt, sessionID, msgID)
secondTransport := &collectingSessionTransport{}
secondState := newConnState()
var secondPlain bin.Buffer
secondConn, err := s.handleEncrypted(context.Background(), secondTransport, secondState, nil, &stored, secondFrame, &secondPlain)
if err != nil {
t.Fatalf("second handleEncrypted: %v", err)
}
waitForAtomicCalls(t, &handler.calls, 2)
waitInboundRPCBatchBudget(t, s.rpcScheduler, 0, 0)
if got := handler.max.Load(); got != 1 {
t.Fatalf("old and retry handlers overlapped: max active=%d, want 1", got)
}
if got := s.rpcResults.flightLimit.snapshot(); got != 0 {
t.Fatalf("sequential retry leaked flight claims: %d", got)
}
resultCount := 0
for _, wire := range secondTransport.snapshot() {
data, decryptErr := crypto.NewClientCipher(rand.Reader).DecryptFromBuffer(key, &bin.Buffer{Buf: wire})
if decryptErr != nil {
t.Fatalf("decrypt sequential-retry reply: %v", decryptErr)
}
plain := &bin.Buffer{Buf: append([]byte(nil), data.Data()...)}
typeID, peekErr := plain.PeekID()
if peekErr != nil {
t.Fatalf("peek sequential-retry reply: %v", peekErr)
}
if typeID == proto.ResultTypeID {
resultCount++
}
}
if resultCount != 1 {
t.Fatalf("sequential retry result count = %d, want 1", resultCount)
}
if !firstConn.terminal.Load() || secondConn == nil || !secondConn.isActive() {
t.Fatalf("replacement lifecycle = old terminal:%v new:%p active:%v", firstConn.terminal.Load(), secondConn, secondConn != nil && secondConn.isActive())
}
secondConn.ForceClose()
}
func waitForAtomicCalls(t *testing.T, calls interface{ Load() int32 }, want int32) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for calls.Load() != want && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if got := calls.Load(); got != want {
t.Fatalf("handler calls = %d, want %d", got, want)
}
}

View file

@ -0,0 +1,383 @@
package mtprotoedge
import (
"context"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gotd/td/bin"
"github.com/gotd/td/mt"
"github.com/gotd/td/proto"
"github.com/gotd/td/tg"
)
// TestFirstContainerBoundaryKeepsAndroidRequestMap models DrKLO's
// new_session_created handling: it drops every running request whose msg_id is
// lower than first_msg_id. Every inner request accepted from the first
// container must therefore remain addressable when its pong arrives.
func TestFirstContainerBoundaryKeepsAndroidRequestMap(t *testing.T) {
const dc = 2
addr, pub, _ := startTestServer(t, Options{DC: dc})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
msgIDs := proto.NewMessageIDGen(time.Now)
running := make(map[int64]int64, 3)
messages := make([]proto.Message, 0, 3)
for i := 0; i < 3; i++ {
msgID := msgIDs.New(proto.MessageFromClient)
pingID := int64(10_001 + i)
body := mustEncodeTL(t, &mt.PingRequest{PingID: pingID})
messages = append(messages, proto.Message{
ID: msgID,
SeqNo: 1 + i*2,
Bytes: len(body),
Body: body,
})
running[msgID] = pingID
}
outerMsgID := msgIDs.New(proto.MessageFromClient)
sendEncrypted(t, conn, cipher, auth, outerMsgID, &proto.MessageContainer{Messages: messages})
boundaryFrames := collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{
mt.NewSessionCreatedTypeID: 1,
})
var created mt.NewSessionCreated
for _, frame := range boundaryFrames {
if frame.TypeID == mt.PongTypeID {
t.Fatalf("pong arrived before new_session_created boundary")
}
if frame.TypeID != mt.NewSessionCreatedTypeID {
continue
}
if err := created.Decode(frame.Plain); err != nil {
t.Fatalf("decode new_session_created: %v", err)
}
}
// DrKLO ConnectionsManager.cpp clears running requests with
// request.messageId < first_msg_id when it receives this notification.
for msgID := range running {
if msgID < created.FirstMsgID {
delete(running, msgID)
}
}
for _, accepted := range messages {
if _, ok := running[accepted.ID]; !ok {
t.Fatalf(
"accepted inner msg_id %d was evicted by Android boundary %d (outer=%d)",
accepted.ID,
created.FirstMsgID,
outerMsgID,
)
}
}
pongFrames := collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{
mt.PongTypeID: len(messages),
})
seen := make(map[int64]struct{}, len(messages))
for _, frame := range pongFrames {
if frame.TypeID != mt.PongTypeID {
continue
}
var pong mt.Pong
if err := pong.Decode(frame.Plain); err != nil {
t.Fatalf("decode pong: %v", err)
}
wantPingID, ok := running[pong.MsgID]
if !ok {
t.Fatalf("orphan pong for msg_id %d after Android boundary cleanup", pong.MsgID)
}
if pong.PingID != wantPingID {
t.Fatalf("pong ping_id = %d for msg_id %d, want %d", pong.PingID, pong.MsgID, wantPingID)
}
if _, duplicate := seen[pong.MsgID]; duplicate {
t.Fatalf("duplicate pong for msg_id %d", pong.MsgID)
}
seen[pong.MsgID] = struct{}{}
delete(running, pong.MsgID)
}
if len(running) != 0 {
t.Fatalf("requests without correlated pong: %+v", running)
}
}
type admissionCountingRPC struct {
calls atomic.Int32
}
func (h *admissionCountingRPC) Dispatch(_ context.Context, _ [8]byte, _ int64, _ *bin.Buffer) (bin.Encoder, error) {
h.calls.Add(1)
return &tg.Config{ThisDC: 2}, nil
}
func (*admissionCountingRPC) NegotiatedLayer([8]byte, int64) (int, bool) { return 227, true }
func TestContainerRPCAdmissionFailureIsAtomic(t *testing.T) {
const dc = 2
handler := &admissionCountingRPC{}
addr, pub, _ := startTestServer(t, Options{
DC: dc,
RPC: handler,
RPCMaxInflight: 1,
RPCQueueSize: 1,
RPCGlobalWorkers: 1,
})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
msgIDs := proto.NewMessageIDGen(time.Now)
messages := make([]proto.Message, 0, 2)
requestIDs := make(map[int64]struct{}, 2)
for i := 0; i < 2; i++ {
msgID := msgIDs.New(proto.MessageFromClient)
body := mustEncodeTL(t, &tg.HelpGetConfigRequest{})
messages = append(messages, proto.Message{ID: msgID, SeqNo: 1 + i*2, Bytes: len(body), Body: body})
requestIDs[msgID] = struct{}{}
}
outerMsgID := msgIDs.New(proto.MessageFromClient)
sendEncrypted(t, conn, cipher, auth, outerMsgID, &proto.MessageContainer{Messages: messages})
frames := collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{proto.ResultTypeID: 2})
results := 0
for _, frame := range frames {
if frame.TypeID != proto.ResultTypeID {
continue
}
var result proto.Result
if err := result.Decode(frame.Plain); err != nil {
t.Fatalf("decode rpc_result: %v", err)
}
if _, ok := requestIDs[result.RequestMessageID]; !ok {
t.Fatalf("unexpected or duplicate capacity rpc_result req_msg_id %d", result.RequestMessageID)
}
var rpcErr mt.RPCError
if err := rpcErr.Decode(&bin.Buffer{Buf: result.Result}); err != nil {
t.Fatalf("decode capacity rpc_error: %v", err)
}
if rpcErr.ErrorCode != 420 || rpcErr.ErrorMessage != "FLOOD_WAIT_1" {
t.Fatalf("capacity rpc_error = %+v", rpcErr)
}
delete(requestIDs, result.RequestMessageID)
results++
}
if results != 2 {
t.Fatalf("capacity rpc_results = %d, want 2", results)
}
if len(requestIDs) != 0 {
t.Fatalf("capacity requests without exactly one result: %+v", requestIDs)
}
if got := handler.calls.Load(); got != 0 {
t.Fatalf("partially executed handler calls = %d, want 0", got)
}
}
func TestGZIPWrappedRPCUsesLogicalEnvelopeBoundary(t *testing.T) {
const dc = 2
handler := &admissionCountingRPC{}
addr, pub, _ := startTestServer(t, Options{DC: dc, RPC: handler})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
requestBody := mustEncodeTL(t, &tg.HelpGetConfigRequest{})
msgID := proto.NewMessageIDGen(time.Now).New(proto.MessageFromClient)
sendEncryptedWithSeq(t, conn, cipher, auth, msgID, 1, &proto.GZIP{Data: requestBody})
frames := collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{proto.ResultTypeID: 1})
var boundary mt.NewSessionCreated
for _, frame := range frames {
if frame.TypeID == mt.NewSessionCreatedTypeID {
if err := boundary.Decode(frame.Plain); err != nil {
t.Fatalf("decode gzip RPC boundary: %v", err)
}
}
}
if boundary.FirstMsgID != msgID {
t.Fatalf("gzip RPC boundary = %d, want envelope %d", boundary.FirstMsgID, msgID)
}
if got := handler.calls.Load(); got != 1 {
t.Fatalf("gzip RPC handler calls = %d, want 1", got)
}
}
func TestGZIPWrappedContainerUsesLowestInnerBoundary(t *testing.T) {
const dc = 2
addr, pub, _ := startTestServer(t, Options{DC: dc})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
ids := proto.NewMessageIDGen(time.Now)
innerMsgID := ids.New(proto.MessageFromClient)
outerMsgID := ids.New(proto.MessageFromClient)
pingBody := mustEncodeTL(t, &mt.PingRequest{PingID: 7001})
containerBody := mustEncodeTL(t, &proto.MessageContainer{Messages: []proto.Message{{
ID: innerMsgID, SeqNo: 1, Bytes: len(pingBody), Body: pingBody,
}}})
sendEncryptedWithSeq(t, conn, cipher, auth, outerMsgID, 2, &proto.GZIP{Data: containerBody})
frames := collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{mt.PongTypeID: 1})
var boundary mt.NewSessionCreated
for _, frame := range frames {
if frame.TypeID == mt.NewSessionCreatedTypeID {
if err := boundary.Decode(frame.Plain); err != nil {
t.Fatalf("decode gzip container boundary: %v", err)
}
}
}
if boundary.FirstMsgID != innerMsgID {
t.Fatalf("gzip container boundary = %d, want inner %d (outer %d)", boundary.FirstMsgID, innerMsgID, outerMsgID)
}
}
func TestEmptyContainerUsesOuterBoundary(t *testing.T) {
const dc = 2
addr, pub, _ := startTestServer(t, Options{DC: dc})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
outerMsgID := proto.NewMessageIDGen(time.Now).New(proto.MessageFromClient)
sendEncryptedWithSeq(t, conn, cipher, auth, outerMsgID, 0, &proto.MessageContainer{})
frames := collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{mt.NewSessionCreatedTypeID: 1})
var boundary mt.NewSessionCreated
for _, frame := range frames {
if frame.TypeID != mt.NewSessionCreatedTypeID {
continue
}
if err := boundary.Decode(frame.Plain); err != nil {
t.Fatalf("decode empty container boundary: %v", err)
}
}
if boundary.FirstMsgID != outerMsgID {
t.Fatalf("empty container boundary = %d, want outer %d", boundary.FirstMsgID, outerMsgID)
}
}
func TestDuplicateContainerAcksWithoutBusinessReexecution(t *testing.T) {
const dc = 2
handler := &admissionCountingRPC{}
addr, pub, _ := startTestServer(t, Options{DC: dc, RPC: handler})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
ids := proto.NewMessageIDGen(time.Now)
messages := make([]proto.Message, 0, 2)
for i := 0; i < 2; i++ {
body := mustEncodeTL(t, &tg.HelpGetConfigRequest{})
messages = append(messages, proto.Message{
ID: ids.New(proto.MessageFromClient), SeqNo: 1 + i*2, Bytes: len(body), Body: body,
})
}
outerMsgID := ids.New(proto.MessageFromClient)
container := &proto.MessageContainer{Messages: messages}
sendEncrypted(t, conn, cipher, auth, outerMsgID, container)
initialFrames := collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{proto.ResultTypeID: 2})
answerIDs := make([]int64, 0, 2)
for _, frame := range initialFrames {
if frame.TypeID == proto.ResultTypeID {
answerIDs = append(answerIDs, frame.Message.MessageID)
}
}
if len(answerIDs) != 2 {
t.Fatalf("initial rpc_result answer ids = %d, want 2", len(answerIDs))
}
// ACK both original server results before retransmitting the container. This is
// a wire barrier: any later rpc_result is necessarily a duplicate replay rather
// than an unconsumed response from the initial batch.
ackMsgID := ids.New(proto.MessageFromClient)
sendEncryptedWithSeq(t, conn, cipher, auth, ackMsgID, 4, &mt.MsgsAck{MsgIDs: answerIDs})
if got := handler.calls.Load(); got != 2 {
t.Fatalf("initial handler calls = %d, want 2", got)
}
sendEncrypted(t, conn, cipher, auth, outerMsgID, container)
duplicateFrames := collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{mt.MsgsAckTypeID: 1})
for _, frame := range duplicateFrames {
if frame.TypeID == proto.ResultTypeID {
t.Fatal("same-connection duplicate container replayed an extra rpc_result")
}
}
if got := handler.calls.Load(); got != 2 {
t.Fatalf("duplicate container reexecuted handlers: calls=%d, want 2", got)
}
}
type largeStartupBurstRPC struct {
calls atomic.Int32
body string
}
func (h *largeStartupBurstRPC) Dispatch(ctx context.Context, _ [8]byte, _ int64, _ *bin.Buffer) (bin.Encoder, error) {
call := h.calls.Add(1)
// Deliberately perturb completion order while remaining cancellation-aware.
timer := time.NewTimer(time.Duration(call%4) * time.Millisecond)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
return nil, ctx.Err()
}
return &tg.DataJSON{Data: h.body}, nil
}
func (*largeStartupBurstRPC) NegotiatedLayer([8]byte, int64) (int, bool) { return 227, true }
func TestAndroidStartupBurstKeepsAllLargeRPCResultsAddressable(t *testing.T) {
const (
dc = 2
requests = 30
)
handler := &largeStartupBurstRPC{body: strings.Repeat("x", 192<<10)}
addr, pub, _ := startTestServer(t, Options{DC: dc, RPC: handler})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
ids := proto.NewMessageIDGen(time.Now)
running := make(map[int64]struct{}, requests)
messages := make([]proto.Message, 0, requests)
for i := 0; i < requests; i++ {
msgID := ids.New(proto.MessageFromClient)
body := mustEncodeTL(t, &tg.HelpGetConfigRequest{})
messages = append(messages, proto.Message{
ID: msgID, SeqNo: 1 + i*2, Bytes: len(body), Body: body,
})
running[msgID] = struct{}{}
}
outerMsgID := ids.New(proto.MessageFromClient)
sendEncrypted(t, conn, cipher, auth, outerMsgID, &proto.MessageContainer{Messages: messages})
boundaryFrames := collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{mt.NewSessionCreatedTypeID: 1})
var boundary mt.NewSessionCreated
for _, frame := range boundaryFrames {
if frame.TypeID == proto.ResultTypeID {
t.Fatal("large rpc_result arrived before session boundary")
}
if frame.TypeID == mt.NewSessionCreatedTypeID {
if err := boundary.Decode(frame.Plain); err != nil {
t.Fatalf("decode startup boundary: %v", err)
}
}
}
for msgID := range running {
if msgID < boundary.FirstMsgID {
delete(running, msgID)
}
}
if len(running) != requests {
t.Fatalf("Android boundary removed accepted startup requests: kept=%d want=%d floor=%d outer=%d", len(running), requests, boundary.FirstMsgID, outerMsgID)
}
resultFrames := collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{proto.ResultTypeID: requests})
for _, frame := range resultFrames {
if frame.TypeID != proto.ResultTypeID {
continue
}
var result proto.Result
if err := result.Decode(frame.Plain); err != nil {
t.Fatalf("decode startup rpc_result: %v", err)
}
if _, ok := running[result.RequestMessageID]; !ok {
t.Fatalf("orphan large rpc_result for request %d", result.RequestMessageID)
}
delete(running, result.RequestMessageID)
}
if len(running) != 0 {
t.Fatalf("startup RPCs without result: %d", len(running))
}
if got := handler.calls.Load(); got != requests {
t.Fatalf("startup handler calls = %d, want %d", got, requests)
}
}

View file

@ -22,6 +22,11 @@ var ErrSessionNotFound = errors.New("session not found")
// ErrSessionAmbiguous 表示仅用 session_id 无法唯一定位连接。 // ErrSessionAmbiguous 表示仅用 session_id 无法唯一定位连接。
var ErrSessionAmbiguous = errors.New("session id is shared by multiple auth keys") var ErrSessionAmbiguous = errors.New("session id is shared by multiple auth keys")
var (
ErrSessionActivationSuperseded = errors.New("session activation superseded")
ErrSessionActivationFence = errors.New("session activation could not fence previous writer")
)
const ( const (
maxPendingPushesPerSession = 32 maxPendingPushesPerSession = 32
// maxFlushAttempts / flushRetryBackoff排空暂存推送时 c.Send 失败(出站拥塞 5s 超时 // maxFlushAttempts / flushRetryBackoff排空暂存推送时 c.Send 失败(出站拥塞 5s 超时
@ -117,8 +122,13 @@ type SessionLifecycleObserver interface {
// 它管理运行态的在线连接,与持久化的 store.SessionStore 互补:后者记录 session 数据, // 它管理运行态的在线连接,与持久化的 store.SessionStore 互补:后者记录 session 数据,
// 前者持有可发送的活跃连接。所有方法并发安全。 // 前者持有可发送的活跃连接。所有方法并发安全。
type SessionManager struct { type SessionManager struct {
mu sync.RWMutex mu sync.RWMutex
bySession map[sessionKey]*Conn bySession map[sessionKey]*Conn
// claims owns the provisional -> active gap. A claimant is intentionally
// absent from every push/online index until its required session control frame
// is on the wire and PublishActivation validates the same owner.
claims map[sessionKey]*Conn
claimsByAuth map[[8]byte]map[int64]*Conn // raw authKeyID -> sessionID -> provisional claim
bySessionID map[int64]map[[8]byte]*Conn // sessionID → raw authKeyID → Conn用于兼容旧 API 的唯一性检查 bySessionID map[int64]map[[8]byte]*Conn // sessionID → raw authKeyID → Conn用于兼容旧 API 的唯一性检查
byAuthKey map[[8]byte]map[int64]*Conn // raw authKeyID → sessionID → Conn byAuthKey map[[8]byte]map[int64]*Conn // raw authKeyID → sessionID → Conn
byBusinessAuthKey map[[8]byte]map[sessionKey]*Conn byBusinessAuthKey map[[8]byte]map[sessionKey]*Conn
@ -142,6 +152,8 @@ func NewSessionManager(log *zap.Logger) *SessionManager {
} }
return &SessionManager{ return &SessionManager{
bySession: make(map[sessionKey]*Conn), bySession: make(map[sessionKey]*Conn),
claims: make(map[sessionKey]*Conn),
claimsByAuth: make(map[[8]byte]map[int64]*Conn),
bySessionID: make(map[int64]map[[8]byte]*Conn), bySessionID: make(map[int64]map[[8]byte]*Conn),
byAuthKey: make(map[[8]byte]map[int64]*Conn), byAuthKey: make(map[[8]byte]map[int64]*Conn),
byBusinessAuthKey: make(map[[8]byte]map[sessionKey]*Conn), byBusinessAuthKey: make(map[[8]byte]map[sessionKey]*Conn),
@ -164,32 +176,94 @@ func (m *SessionManager) SetLifecycleObserver(observer SessionLifecycleObserver)
m.mu.Unlock() m.mu.Unlock()
} }
// Register 注册一个活跃连接。若同 raw auth_key_id + session_id 已存在(重连),旧连接被替换并移除索引。 // BeginActivation atomically claims auth_key_id + session_id without publishing the
func (m *SessionManager) Register(c *Conn) { // new Conn. Under the manager lock it irreversibly fences every previous owner,
m.mu.Lock() // removes active indexes and closes producer/RPC admission gates. Physical close and
// outbound-actor convergence happen outside the lock; the caller may send the
// required new_session_created frame only after this method returns nil.
func (m *SessionManager) BeginActivation(c *Conn) error {
if c == nil || !c.beginActivationClaim() {
return ErrSessionActivationSuperseded
}
key := connSessionKey(c) key := connSessionKey(c)
var replaced *Conn retired := make([]*Conn, 0, 2)
var evicted *Conn m.mu.Lock()
if old, ok := m.bySession[key]; ok && old != c { if c.terminal.Load() || !c.isPhysicalTransportCurrentOpen() || c.lifecycleState() != connLifecycleClaiming {
replaced = old c.beginTerminalShutdown()
m.removeLocked(old, false) m.mu.Unlock()
} else if existing := m.byAuthKey[c.authKeyID]; len(existing) >= maxSessionsPerAuthKey { return ErrConnClosed
// 同 raw auth_key 的 session 数达上限且本次是新 session驱逐建连最早的 session 让位, }
// 防对抗客户端用海量 session_id 撑爆索引。驱逐对象与新连接同属一个设备凭据, if oldClaim := m.claims[key]; oldClaim != nil && oldClaim != c {
// 触顶基本是该凭据自身异常;选最旧而非 map 随机,避免误杀刚建立的活跃下载/主连接。 m.retireClaimLocked(key, oldClaim, false)
// 被驱逐连接的 serveConn 会在下一帧因 actor 已关而退出。O(cap) 扫描仅在触顶时发生。 retired = append(retired, oldClaim)
for _, ec := range existing { }
if evicted == nil || ec.createdAt.Before(evicted.createdAt) { if old := m.bySession[key]; old != nil && old != c {
evicted = ec m.retireConnLocked(old, false)
} retired = append(retired, old)
}
// Claims reserve a cap slot just like published sessions. Otherwise many
// concurrent handshakes could all pass the old byAuthKey-only check and publish
// beyond maxSessionsPerAuthKey.
for len(m.byAuthKey[c.authKeyID])+m.claimCountForAuthLocked(c.authKeyID) >= maxSessionsPerAuthKey {
victimKey, victim, isClaim := m.oldestAuthOwnerLocked(c.authKeyID, c)
if victim == nil {
break
} }
m.removeLocked(evicted, true) if isClaim {
m.log.Debug("Evicted oldest session for auth key at cap", m.retireClaimLocked(victimKey, victim, true)
} else {
m.retireConnLocked(victim, true)
}
retired = append(retired, victim)
m.log.Debug("Evicted oldest session activation for auth key at cap",
zap.String("auth_key_id", sessionKeyLog(c.authKeyID)), zap.String("auth_key_id", sessionKeyLog(c.authKeyID)),
zap.Int("cap", maxSessionsPerAuthKey), zap.Int("cap", maxSessionsPerAuthKey),
) )
} }
m.addClaimLocked(key, c)
m.mu.Unlock()
// Do not wait for old business handlers: their root context/admission gate is
// already canceled. We only need the old physical writer and outbound actor to
// converge before the new Conn is allowed to write the session barrier.
if !closeConnBatch(retired, forceCloseBatchTimeout, false) {
m.AbortActivation(c)
return ErrSessionActivationFence
}
return nil
}
// PublishActivation makes the current claim visible to push/online lookups. It is
// deliberately a separate operation from BeginActivation so required protocol
// control can be written while the Conn remains provisional and unindexed.
func (m *SessionManager) PublishActivation(c *Conn) error {
if c == nil {
return ErrSessionActivationSuperseded
}
key := connSessionKey(c)
m.mu.Lock()
defer m.mu.Unlock()
if m.claims[key] != c {
return ErrSessionActivationSuperseded
}
if c.terminal.Load() || c.lifecycleState() != connLifecycleClaiming {
m.removeClaimLocked(key, c)
return ErrConnClosed
}
if old := m.bySession[key]; old != nil && old != c {
// An active owner without this claim can only be a stale/unsafe publisher.
// Never reverse-replace it from here; the caller must reconnect and claim again.
m.removeClaimLocked(key, c)
c.beginTerminalShutdown()
return ErrSessionActivationSuperseded
}
if !c.publishActivation() {
m.removeClaimLocked(key, c)
return ErrConnClosed
}
m.removeClaimLocked(key, c)
m.bySession[key] = c m.bySession[key] = c
addSessionIDIndex(m.bySessionID, c.sessionID, c.authKeyID, c) addSessionIDIndex(m.bySessionID, c.sessionID, c.authKeyID, c)
addConnIndex(m.byAuthKey, c.authKeyID, c.sessionID, c) addConnIndex(m.byAuthKey, c.authKeyID, c.sessionID, c)
@ -200,34 +274,88 @@ func (m *SessionManager) Register(c *Conn) {
c.userIDResolved.Store(true) c.userIDResolved.Store(true)
addUserIndex(m.byUser, uid, key, c) addUserIndex(m.byUser, uid, key, c)
} }
m.log.Debug("Session registered", m.log.Debug("Session activated",
zap.String("auth_key_id", sessionKeyLog(key.authKeyID)), zap.String("auth_key_id", sessionKeyLog(key.authKeyID)),
zap.Int64("session_id", c.sessionID), zap.Int64("session_id", c.sessionID),
zap.Int("online", len(m.bySession)), zap.Int("online", len(m.bySession)),
) )
m.mu.Unlock() return nil
}
// 同 identity 的新物理连接已经原子接管索引;立即关闭旧 transport不能只停 // AbortActivation removes a claim only when c still owns it, then terminally
// actor 后让旧 FD/read goroutine 滞留到 read timeout。replacement 与 cap eviction // retires the provisional Conn. A superseded caller cannot delete the newer claim.
// 共用一个并发关闭批次,不能把每条 Conn 的 RPC 等待上界串行相加。 func (m *SessionManager) AbortActivation(c *Conn) {
if replaced != nil || evicted != nil { if c == nil {
if !forceCloseConnBatch([]*Conn{replaced, evicted}, forceCloseBatchTimeout) { return
m.log.Warn("Session replacement/eviction close exceeded shared deadline")
}
} }
key := connSessionKey(c)
owned := false
m.mu.Lock()
if m.claims[key] == c {
c.beginTerminalShutdown()
m.removeClaimLocked(key, c)
if m.bySession[key] == nil {
m.deletePendingLocked(key)
delete(m.flushing, key)
}
owned = true
}
m.mu.Unlock()
if owned {
_ = closeConnBatch([]*Conn{c}, forceCloseBatchTimeout, false)
}
}
// Register is retained for tests and embedders that do not have a wire-level
// required-control barrier. Production encrypted traffic uses the explicit
// BeginActivation -> SendRequiredControl -> PublishActivation sequence.
func (m *SessionManager) Register(c *Conn) error {
if c == nil {
return ErrSessionActivationSuperseded
}
if c.isActive() {
m.mu.RLock()
current := m.bySession[connSessionKey(c)]
m.mu.RUnlock()
if current == c {
return nil
}
return ErrSessionActivationSuperseded
}
if err := m.BeginActivation(c); err != nil {
return err
}
if err := m.PublishActivation(c); err != nil {
m.AbortActivation(c)
return err
}
return nil
} }
// Unregister 注销一个连接(仅当它仍是当前注册的同一对象,避免误删重连后的新连接)。 // Unregister 注销一个连接(仅当它仍是当前注册的同一对象,避免误删重连后的新连接)。
// 观察者对未登录连接userID=0也回调业务层据此清理按 session 维度的缓存条目, // 观察者对未登录连接userID=0也回调业务层据此清理按 session 维度的缓存条目,
// 否则未登录连接的元数据只能等容量上限驱逐。 // 否则未登录连接的元数据只能等容量上限驱逐。
func (m *SessionManager) Unregister(c *Conn) { func (m *SessionManager) Unregister(c *Conn) {
if c == nil {
return
}
// Close admission/outbound producer gates before removing indexes or invoking
// a lifecycle observer. An observer may block, but no old RPC/push may continue
// to write or enqueue work during that interval.
c.beginTerminalShutdown()
m.mu.Lock() m.mu.Lock()
var ( var (
observer SessionLifecycleObserver observer SessionLifecycleObserver
offlineUser int64 offlineUser int64
lastForUser bool lastForUser bool
) )
if cur, ok := m.bySession[connSessionKey(c)]; ok && cur == c { key := connSessionKey(c)
if m.claims[key] == c {
m.removeClaimLocked(key, c)
m.deletePendingLocked(key)
delete(m.flushing, key)
}
if cur, ok := m.bySession[key]; ok && cur == c {
offlineUser = m.removeLocked(c, true) offlineUser = m.removeLocked(c, true)
if offlineUser != 0 { if offlineUser != 0 {
lastForUser = len(m.byUser[offlineUser]) == 0 lastForUser = len(m.byUser[offlineUser]) == 0
@ -256,7 +384,7 @@ func (m *SessionManager) DestroySession(sessionID int64) bool {
m.mu.Unlock() m.mu.Unlock()
return false return false
} }
offlineUser := m.removeLocked(c, true) offlineUser := m.retireConnLocked(c, true)
lastForUser := offlineUser != 0 && len(m.byUser[offlineUser]) == 0 lastForUser := offlineUser != 0 && len(m.byUser[offlineUser]) == 0
observer := m.lifecycle observer := m.lifecycle
m.log.Debug("Session destroyed", m.log.Debug("Session destroyed",
@ -283,11 +411,22 @@ func (m *SessionManager) DestroySessionForAuthKey(authKeyID [8]byte, sessionID i
key := sessionKey{authKeyID: authKeyID, sessionID: sessionID} key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
c, ok := m.bySession[key] c, ok := m.bySession[key]
if !ok { if !ok {
if claim := m.claims[key]; claim != nil {
m.retireClaimLocked(key, claim, true)
m.mu.Unlock()
if !forceCloseConnBatch([]*Conn{claim}, forceCloseBatchTimeout) {
m.log.Warn("Claimed session close exceeded shared deadline",
zap.String("auth_key_id", sessionKeyLog(authKeyID)),
zap.Int64("session_id", sessionID),
)
}
return true
}
m.deletePendingLocked(key) m.deletePendingLocked(key)
m.mu.Unlock() m.mu.Unlock()
return false return false
} }
offlineUser := m.removeLocked(c, true) offlineUser := m.retireConnLocked(c, true)
lastForUser := offlineUser != 0 && len(m.byUser[offlineUser]) == 0 lastForUser := offlineUser != 0 && len(m.byUser[offlineUser]) == 0
observer := m.lifecycle observer := m.lifecycle
m.log.Debug("Session destroyed", m.log.Debug("Session destroyed",
@ -505,10 +644,17 @@ func (m *SessionManager) CloseSessionsForBusinessAuthKey(authKeyID [8]byte) int
if !connUsesBusinessAuthKey(c, authKeyID) { if !connUsesBusinessAuthKey(c, authKeyID) {
continue continue
} }
uid := m.removeLocked(c, true) uid := m.retireConnLocked(c, true)
conns = append(conns, c) conns = append(conns, c)
events = append(events, offlineEvent{key: key, userID: uid, last: uid != 0 && len(m.byUser[uid]) == 0}) events = append(events, offlineEvent{key: key, userID: uid, last: uid != 0 && len(m.byUser[uid]) == 0})
} }
for key, c := range m.claims {
if !connUsesBusinessAuthKey(c, authKeyID) {
continue
}
m.retireClaimLocked(key, c, true)
conns = append(conns, c)
}
observer := m.lifecycle observer := m.lifecycle
if len(conns) > 0 { if len(conns) > 0 {
m.log.Debug("Force close sessions for revoked auth key", m.log.Debug("Force close sessions for revoked auth key",
@ -531,10 +677,26 @@ func (m *SessionManager) CloseSessionsForBusinessAuthKey(authKeyID [8]byte) int
return len(conns) return len(conns)
} }
// CloseSessionsForRawAuthKeyExcept 强制断开指定 raw auth_key 的活跃连接,可排除 // CloseSessionsForRawAuthKeyExcept 强制断开指定 raw auth_key 的活跃连接,可
// 一个 sessiondestroy_auth_key 的发起连接:响应要先送达,它的密钥已删,下一帧 // session ID 排除一个 session。该接口供业务层授权撤销使用wire-level
// 自然失效)。出站推送不回查密钥库,必须主动断开底层 transport 才能让销毁立即生效 // destroy_auth_key 必须改用精确 Conn 排除,避免同 session replacement 被误放过
func (m *SessionManager) CloseSessionsForRawAuthKeyExcept(authKeyID [8]byte, exceptSessionID int64) int { func (m *SessionManager) CloseSessionsForRawAuthKeyExcept(authKeyID [8]byte, exceptSessionID int64) int {
return m.closeSessionsForRawAuthKey(authKeyID, func(sessionID int64, _ *Conn) bool {
return sessionID == exceptSessionID
})
}
// CloseSessionsForRawAuthKeyExceptConn closes every active/claiming owner for a raw
// auth key except the exact Conn executing destroy_auth_key. A session ID is not an
// identity: a concurrent replacement may already own the same logical session while
// the retired request handler finishes deletion.
func (m *SessionManager) CloseSessionsForRawAuthKeyExceptConn(authKeyID [8]byte, except *Conn) int {
return m.closeSessionsForRawAuthKey(authKeyID, func(_ int64, c *Conn) bool {
return c == except
})
}
func (m *SessionManager) closeSessionsForRawAuthKey(authKeyID [8]byte, skip func(int64, *Conn) bool) int {
type offlineEvent struct { type offlineEvent struct {
key sessionKey key sessionKey
userID int64 userID int64
@ -544,14 +706,22 @@ func (m *SessionManager) CloseSessionsForRawAuthKeyExcept(authKeyID [8]byte, exc
var conns []*Conn var conns []*Conn
var events []offlineEvent var events []offlineEvent
for sessionID, c := range m.byAuthKey[authKeyID] { for sessionID, c := range m.byAuthKey[authKeyID] {
if sessionID == exceptSessionID { if skip != nil && skip(sessionID, c) {
continue continue
} }
key := sessionKey{authKeyID: authKeyID, sessionID: sessionID} key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
uid := m.removeLocked(c, true) uid := m.retireConnLocked(c, true)
conns = append(conns, c) conns = append(conns, c)
events = append(events, offlineEvent{key: key, userID: uid, last: uid != 0 && len(m.byUser[uid]) == 0}) events = append(events, offlineEvent{key: key, userID: uid, last: uid != 0 && len(m.byUser[uid]) == 0})
} }
for sessionID, c := range m.claimsByAuth[authKeyID] {
if skip != nil && skip(sessionID, c) {
continue
}
key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
m.retireClaimLocked(key, c, true)
conns = append(conns, c)
}
observer := m.lifecycle observer := m.lifecycle
m.mu.Unlock() m.mu.Unlock()
if !forceCloseConnBatch(conns, forceCloseBatchTimeout) { if !forceCloseConnBatch(conns, forceCloseBatchTimeout) {
@ -574,6 +744,14 @@ func (m *SessionManager) CloseSessionsForRawAuthKeyExcept(authKeyID [8]byte, exc
// deadline, but no timed-out Conn can enqueue more work in that interval. Nil/duplicate entries are // deadline, but no timed-out Conn can enqueue more work in that interval. Nil/duplicate entries are
// removed so Register's replacement/eviction slots cannot close the same Conn twice. // removed so Register's replacement/eviction slots cannot close the same Conn twice.
func forceCloseConnBatch(conns []*Conn, timeout time.Duration) bool { func forceCloseConnBatch(conns []*Conn, timeout time.Duration) bool {
return closeConnBatch(conns, timeout, true)
}
// closeConnBatch always converges physical writers/outbound actors. waitInbound
// is reserved for destructive control-plane operations; activation takeover sets
// it false so a canceled business handler that ignores its context cannot stall a
// healthy replacement. Its admission and response writer are already terminal.
func closeConnBatch(conns []*Conn, timeout time.Duration, waitInbound bool) bool {
if len(conns) == 0 { if len(conns) == 0 {
return true return true
} }
@ -640,7 +818,7 @@ func forceCloseConnBatch(conns []*Conn, timeout time.Duration) bool {
if remaining <= 0 { if remaining <= 0 {
return false return false
} }
if c.rpcScheduler != nil && !c.waitInboundShutdown(remaining) { if waitInbound && c.rpcScheduler != nil && !c.waitInboundShutdown(remaining) {
return false return false
} }
if c.outboundDone == nil { if c.outboundDone == nil {
@ -1631,6 +1809,9 @@ func (m *SessionManager) onlineChannelUsers(index map[int64]map[sessionKey]int64
func (m *SessionManager) removeLocked(c *Conn, dropPending bool) int64 { func (m *SessionManager) removeLocked(c *Conn, dropPending bool) int64 {
key := connSessionKey(c) key := connSessionKey(c)
if m.bySession[key] != c {
return 0
}
delete(m.bySession, key) delete(m.bySession, key)
removeSessionIDIndex(m.bySessionID, c.sessionID, c.authKeyID) removeSessionIDIndex(m.bySessionID, c.sessionID, c.authKeyID)
removeConnIndex(m.byAuthKey, c.authKeyID, c.sessionID) removeConnIndex(m.byAuthKey, c.authKeyID, c.sessionID)
@ -1650,6 +1831,75 @@ func (m *SessionManager) removeLocked(c *Conn, dropPending bool) int64 {
return uid return uid
} }
// retireConnLocked closes every admission/producer gate before the Conn leaves
// manager indexes. Callers may close the physical transport and wait outside m.mu,
// but no pointer collected by an earlier fan-out can enqueue after this returns.
func (m *SessionManager) retireConnLocked(c *Conn, dropPending bool) int64 {
if c == nil {
return 0
}
c.beginTerminalShutdown()
return m.removeLocked(c, dropPending)
}
func (m *SessionManager) retireClaimLocked(key sessionKey, c *Conn, dropPending bool) {
if c == nil || m.claims[key] != c {
return
}
c.beginTerminalShutdown()
m.removeClaimLocked(key, c)
if dropPending && m.bySession[key] == nil {
m.deletePendingLocked(key)
}
delete(m.flushing, key)
}
func (m *SessionManager) claimCountForAuthLocked(authKeyID [8]byte) int {
return len(m.claimsByAuth[authKeyID])
}
func (m *SessionManager) oldestAuthOwnerLocked(authKeyID [8]byte, exclude *Conn) (sessionKey, *Conn, bool) {
var (
oldestKey sessionKey
oldest *Conn
isClaim bool
)
for sessionID, candidate := range m.byAuthKey[authKeyID] {
if candidate == nil || candidate == exclude {
continue
}
if oldest == nil || candidate.createdAt.Before(oldest.createdAt) {
oldestKey = sessionKey{authKeyID: authKeyID, sessionID: sessionID}
oldest = candidate
isClaim = false
}
}
for sessionID, candidate := range m.claimsByAuth[authKeyID] {
if candidate == nil || candidate == exclude {
continue
}
if oldest == nil || candidate.createdAt.Before(oldest.createdAt) {
oldestKey = sessionKey{authKeyID: authKeyID, sessionID: sessionID}
oldest = candidate
isClaim = true
}
}
return oldestKey, oldest, isClaim
}
func (m *SessionManager) addClaimLocked(key sessionKey, c *Conn) {
m.claims[key] = c
addConnIndex(m.claimsByAuth, key.authKeyID, key.sessionID, c)
}
func (m *SessionManager) removeClaimLocked(key sessionKey, c *Conn) {
if m.claims[key] != c {
return
}
delete(m.claims, key)
removeConnIndex(m.claimsByAuth, key.authKeyID, key.sessionID)
}
func (m *SessionManager) businessAuthKeyCandidatesLocked(authKeyID [8]byte) map[sessionKey]*Conn { func (m *SessionManager) businessAuthKeyCandidatesLocked(authKeyID [8]byte) map[sessionKey]*Conn {
out := make(map[sessionKey]*Conn, len(m.byBusinessAuthKey[authKeyID])+len(m.byAuthKey[authKeyID])) out := make(map[sessionKey]*Conn, len(m.byBusinessAuthKey[authKeyID])+len(m.byAuthKey[authKeyID]))
for key, c := range m.byBusinessAuthKey[authKeyID] { for key, c := range m.byBusinessAuthKey[authKeyID] {

View file

@ -2,8 +2,12 @@ package mtprotoedge
import ( import (
"context" "context"
"encoding/binary"
"errors"
"io"
"strings" "strings"
"testing" "testing"
"time"
"github.com/gotd/td/bin" "github.com/gotd/td/bin"
"github.com/gotd/td/mt" "github.com/gotd/td/mt"
@ -39,6 +43,101 @@ func TestContainerMessageCountAndServiceVectorCaps(t *testing.T) {
} }
} }
func TestServiceLongVectorViewsAreBoundedExactAndZeroCopy(t *testing.T) {
tests := []struct {
name string
typeID uint32
value bin.Encoder
}{
{name: "msgs_ack", typeID: mt.MsgsAckTypeID, value: &mt.MsgsAck{MsgIDs: []int64{11, 22}}},
{name: "msgs_state_req", typeID: mt.MsgsStateReqTypeID, value: &mt.MsgsStateReq{MsgIDs: []int64{11, 22}}},
{name: "msg_resend_req", typeID: mt.MsgResendReqTypeID, value: &mt.MsgResendReq{MsgIDs: []int64{11, 22}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var encoded bin.Buffer
if err := tt.value.Encode(&encoded); err != nil {
t.Fatalf("encode service vector: %v", err)
}
view, err := decodeInt64VectorView(&encoded, tt.typeID, maxServiceMessageIDs)
if err != nil {
t.Fatalf("decode service vector view: %v", err)
}
if view.count != 2 {
t.Fatalf("view count = %d, want 2", view.count)
}
// The retained plan payload is a view into the already-budgeted frame,
// not a generated-decoder []int64 copy.
binary.LittleEndian.PutUint64(encoded.Buf[12:20], uint64(99))
ids := view.materialize()
if len(ids) != 2 || ids[0] != 99 || ids[1] != 22 {
t.Fatalf("materialized ids = %v, want [99 22]", ids)
}
trailing := bin.Buffer{Buf: append(append([]byte(nil), encoded.Buf...), 0, 0, 0, 0)}
if _, err := decodeInt64VectorView(&trailing, tt.typeID, maxServiceMessageIDs); err == nil || !strings.Contains(err.Error(), "trailing") {
t.Fatalf("trailing vector err = %v, want exact-length rejection", err)
}
truncated := bin.Buffer{Buf: encoded.Buf[:len(encoded.Buf)-1]}
if _, err := decodeInt64VectorView(&truncated, tt.typeID, maxServiceMessageIDs); !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("truncated vector err = %v, want unexpected EOF", err)
}
})
}
var oversized bin.Buffer
if err := (&mt.MsgsAck{MsgIDs: []int64{1}}).Encode(&oversized); err != nil {
t.Fatalf("encode oversized seed: %v", err)
}
binary.LittleEndian.PutUint32(oversized.Buf[8:12], uint32(maxServiceMessageIDs+1))
if _, err := decodeInt64VectorView(&oversized, mt.MsgsAckTypeID, maxServiceMessageIDs); err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("oversized vector err = %v, want capped rejection", err)
}
}
func TestInboundPlanContainerCapacityBoundary(t *testing.T) {
ids := proto.NewMessageIDGen(time.Now)
waitBody := encodeClientMessageBodyForTest(t, &mt.HTTPWaitRequest{MaxWait: 25_000})
messages := make([]proto.Message, maxContainerMessages)
for i := range messages {
messages[i] = proto.Message{
ID: ids.New(proto.MessageFromClient), SeqNo: 0, Bytes: len(waitBody), Body: waitBody,
}
}
outerMsgID := ids.New(proto.MessageFromClient)
body := encodeClientMessageBodyForTest(t, &proto.MessageContainer{Messages: messages})
s := New(Options{Logger: zaptest.NewLogger(t)})
before := s.frameBudget.usedBytes()
plan, err := s.preflightInbound(newConnState(), outerMsgID, 0, body)
if err != nil {
t.Fatalf("container at cap preflight: %v", err)
}
if len(plan.items) != maxContainerMessages || len(plan.staged) != maxContainerMessages+1 {
t.Fatalf("container plan sizes = items:%d staged:%d", len(plan.items), len(plan.staged))
}
if plan.logicalMin != messages[0].ID {
t.Fatalf("container plan floor = %d, want %d", plan.logicalMin, messages[0].ID)
}
plan.close()
if after := s.frameBudget.usedBytes(); after != before {
t.Fatalf("container plan leaked frame budget: before=%d after=%d", before, after)
}
over := append(append([]proto.Message(nil), messages...), proto.Message{
ID: outerMsgID, SeqNo: 0, Bytes: len(waitBody), Body: waitBody,
})
overBody := encodeClientMessageBodyForTest(t, &proto.MessageContainer{Messages: over})
if plan, err := s.preflightInbound(newConnState(), ids.New(proto.MessageFromClient), 0, overBody); err == nil {
plan.close()
t.Fatal("container above cap unexpectedly accepted")
}
if after := s.frameBudget.usedBytes(); after != before {
t.Fatalf("rejected container leaked frame budget: before=%d after=%d", before, after)
}
}
func TestContainerDecodeUsesBudgetedZeroCopyBodies(t *testing.T) { func TestContainerDecodeUsesBudgetedZeroCopyBodies(t *testing.T) {
encoded := bin.Buffer{} encoded := bin.Buffer{}
wantBody := []byte{0x11, 0x22, 0x33, 0x44} wantBody := []byte{0x11, 0x22, 0x33, 0x44}
@ -83,6 +182,319 @@ func TestContainerDecodeUsesBudgetedZeroCopyBodies(t *testing.T) {
} }
} }
func TestContainerAndGZIPViewsRejectTrailingBytes(t *testing.T) {
var container bin.Buffer
if err := (&proto.MessageContainer{}).Encode(&container); err != nil {
t.Fatalf("encode empty container: %v", err)
}
container.PutInt(0x11223344)
s := New(Options{Logger: zaptest.NewLogger(t)})
if _, release, err := s.decodeMessageContainerViews(&container, 0); err == nil {
release()
t.Fatal("container trailing bytes unexpectedly accepted")
}
var packed bin.Buffer
if err := (proto.GZIP{Data: []byte{1, 2, 3, 4}}).Encode(&packed); err != nil {
t.Fatalf("encode gzip wrapper: %v", err)
}
packed.PutInt(0x55667788)
if _, err := gzipPackedBytesView(&packed); err == nil {
t.Fatal("gzip trailing bytes unexpectedly accepted")
}
}
func TestContainerInvalidSequenceTailIsAtomic(t *testing.T) {
ids := proto.NewMessageIDGen(time.Now)
firstMsgID := ids.New(proto.MessageFromClient)
secondMsgID := ids.New(proto.MessageFromClient)
outerMsgID := ids.New(proto.MessageFromClient)
pingOne := encodeClientMessageBodyForTest(t, &mt.PingRequest{PingID: 1})
pingTwo := encodeClientMessageBodyForTest(t, &mt.PingRequest{PingID: 2})
container := proto.MessageContainer{Messages: []proto.Message{
{ID: firstMsgID, SeqNo: 3, Bytes: len(pingOne), Body: pingOne},
{ID: secondMsgID, SeqNo: 1, Bytes: len(pingTwo), Body: pingTwo},
}}
encoded := encodeClientMessageBodyForTest(t, &container)
s := New(Options{Logger: zaptest.NewLogger(t)})
cs := newConnState()
c := &Conn{
metrics: NopMetrics{},
outbound: make(chan outboundOp, 4),
outboundControl: make(chan outboundOp, 4),
outboundStop: make(chan struct{}),
}
var acks []int64
if err := s.dispatch(context.Background(), cs, c, outerMsgID, 4, &bin.Buffer{Buf: encoded}, &acks); err != nil {
t.Fatalf("dispatch invalid-tail container: %v", err)
}
if len(cs.seen) != 0 || len(cs.order) != 0 || cs.maxContentMsgID != 0 || cs.maxContentSeqNo != 0 {
t.Fatalf("invalid container partially committed connState: %+v", cs)
}
if len(acks) != 0 {
t.Fatalf("invalid container produced ACKs: %v", acks)
}
if got := len(c.outboundControl); got != 1 {
t.Fatalf("control frames = %d, want only bad_msg_notification", got)
}
op := <-c.outboundControl
if op.encoded == nil || op.encoded.typeID != mt.BadMsgNotificationTypeID {
t.Fatalf("control frame type = %#v, want bad_msg_notification", op.encoded)
}
var bad mt.BadMsgNotification
if err := bad.Decode(&bin.Buffer{Buf: op.encoded.body}); err != nil {
t.Fatalf("decode bad_msg_notification: %v", err)
}
if bad.BadMsgID != secondMsgID || bad.BadMsgSeqno != 1 || bad.ErrorCode != badMsgSeqTooLow {
t.Fatalf("bad_msg_notification = %+v", bad)
}
op.releaseReservation(c.outboundTrackedBudget)
}
func TestContainerMalformedGZIPTailHasNoStateEffects(t *testing.T) {
ids := proto.NewMessageIDGen(time.Now)
waitMsgID := ids.New(proto.MessageFromClient)
badMsgID := ids.New(proto.MessageFromClient)
outerMsgID := ids.New(proto.MessageFromClient)
waitBody := encodeClientMessageBodyForTest(t, &mt.HTTPWaitRequest{MaxWait: 25_000})
var malformedGZIP bin.Buffer
malformedGZIP.PutID(proto.GZIPTypeID)
container := proto.MessageContainer{Messages: []proto.Message{
{ID: waitMsgID, SeqNo: 0, Bytes: len(waitBody), Body: waitBody},
{ID: badMsgID, SeqNo: 1, Bytes: malformedGZIP.Len(), Body: malformedGZIP.Buf},
}}
encoded := encodeClientMessageBodyForTest(t, &container)
s := New(Options{Logger: zaptest.NewLogger(t)})
cs := newConnState()
var acks []int64
if err := s.dispatch(context.Background(), cs, nil, outerMsgID, 2, &bin.Buffer{Buf: encoded}, &acks); err == nil {
t.Fatal("malformed gzip tail unexpectedly accepted")
}
if len(cs.seen) != 0 || len(cs.order) != 0 || cs.maxContentMsgID != 0 || cs.maxContentSeqNo != 0 {
t.Fatalf("malformed gzip tail partially committed connState: %+v", cs)
}
if len(acks) != 0 {
t.Fatalf("malformed gzip tail produced ACKs: %v", acks)
}
if got := s.frameBudget.usedBytes(); got != 0 {
t.Fatalf("malformed gzip tail leaked frame budget: %d", got)
}
}
func TestInvalidMessageIDRejectsBeforeGZIPExpansion(t *testing.T) {
leaf := encodeClientMessageBodyForTest(t, &mt.PingRequest{PingID: 1})
wrapped := encodeClientMessageBodyForTest(t, &proto.GZIP{Data: leaf})
current := proto.NewMessageIDGen(time.Now).New(proto.MessageFromClient)
tests := []struct {
name string
msgID int64
badCode int
}{
{name: "stale", msgID: proto.NewMessageIDGen(func() time.Time { return time.Now().Add(-10 * time.Minute) }).New(proto.MessageFromClient), badCode: badMsgIDTooLow},
{name: "future", msgID: proto.NewMessageIDGen(func() time.Time { return time.Now().Add(time.Minute) }).New(proto.MessageFromClient), badCode: badMsgIDTooHigh},
{name: "invalid bits", msgID: current + 1, badCode: badMsgIDInvalidBits},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := New(Options{Logger: zaptest.NewLogger(t)})
// Any attempted gzip expansion would fail this deliberately tiny
// budget and mask the required bad_msg_notification.
s.frameBudget = newInboundFrameBudget(1)
plan, err := s.preflightInbound(newConnState(), tt.msgID, 1, wrapped)
if plan != nil {
plan.close()
t.Fatal("invalid gzip envelope unexpectedly produced a plan")
}
var bad *dispatchBadMsgError
if !errors.As(err, &bad) || bad.code != tt.badCode {
t.Fatalf("invalid gzip envelope err = %v, want bad_msg code %d", err, tt.badCode)
}
if got := s.frameBudget.usedBytes(); got != 0 {
t.Fatalf("invalid gzip envelope consumed expansion budget: %d", got)
}
})
}
}
func TestInvalidContainerInnerIDRejectsBeforeGZIPExpansion(t *testing.T) {
ids := proto.NewMessageIDGen(time.Now)
validInnerID := ids.New(proto.MessageFromClient)
invalidInnerID := validInnerID + 1
if proto.MessageID(invalidInnerID).Type() == proto.MessageFromClient {
t.Fatalf("test inner msg_id %d unexpectedly has client bits", invalidInnerID)
}
outerMsgID := ids.New(proto.MessageFromClient)
leaf := encodeClientMessageBodyForTest(t, &mt.PingRequest{PingID: 2})
gzipped := encodeClientMessageBodyForTest(t, &proto.GZIP{Data: leaf})
container := encodeClientMessageBodyForTest(t, &proto.MessageContainer{Messages: []proto.Message{{
ID: invalidInnerID, SeqNo: 1, Bytes: len(gzipped), Body: gzipped,
}}})
s := New(Options{Logger: zaptest.NewLogger(t)})
// Enough for the one descriptor, intentionally not enough for a gzip
// expansion. Invalid inner bits must win and every descriptor charge unwind.
s.frameBudget = newInboundFrameBudget(containerDescriptorBudgetBytes)
plan, err := s.preflightInbound(newConnState(), outerMsgID, 2, container)
if plan != nil {
plan.close()
t.Fatal("invalid inner msg_id unexpectedly produced a plan")
}
var bad *dispatchBadMsgError
if !errors.As(err, &bad) || bad.code != badMsgContainer || bad.msgID != invalidInnerID {
t.Fatalf("invalid inner gzip err = %v, want container rejection for %d", err, invalidInnerID)
}
if got := s.frameBudget.usedBytes(); got != 0 {
t.Fatalf("invalid inner gzip leaked descriptor/expansion budget: %d", got)
}
}
func TestSeenGZIPInnerShortCircuitsBeforeExpansion(t *testing.T) {
tests := []struct {
name string
seqNo int32
content bool
leaf bin.Encoder
wantACK bool
}{
{name: "content", seqNo: 1, content: true, leaf: &mt.PingRequest{PingID: 3}, wantACK: true},
{name: "non_content", seqNo: 0, content: false, leaf: &mt.HTTPWaitRequest{MaxWait: 25_000}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ids := proto.NewMessageIDGen(time.Now)
innerMsgID := ids.New(proto.MessageFromClient)
outerMsgID := ids.New(proto.MessageFromClient)
leaf := encodeClientMessageBodyForTest(t, tt.leaf)
gzipped := encodeClientMessageBodyForTest(t, &proto.GZIP{Data: leaf})
container := encodeClientMessageBodyForTest(t, &proto.MessageContainer{Messages: []proto.Message{{
ID: innerMsgID, SeqNo: int(tt.seqNo), Bytes: len(gzipped), Body: gzipped,
}}})
cs := newConnState()
cs.track(innerMsgID, tt.seqNo, tt.content, msgStateReceived)
s := New(Options{Logger: zaptest.NewLogger(t)})
s.frameBudget = newInboundFrameBudget(containerDescriptorBudgetBytes)
plan, err := s.preflightInbound(cs, outerMsgID, 2, container)
if err != nil {
t.Fatalf("preflight seen gzip inner: %v", err)
}
if len(plan.items) != 1 || plan.items[0].kind != inboundItemDuplicate || plan.items[0].content != tt.content {
plan.close()
t.Fatalf("seen gzip plan items = %+v, want one duplicate", plan.items)
}
if gotACK := len(plan.ackIDs) == 1 && plan.ackIDs[0] == innerMsgID; gotACK != tt.wantACK {
plan.close()
t.Fatalf("seen gzip ACKs = %v, wantACK=%v", plan.ackIDs, tt.wantACK)
}
if got := s.frameBudget.usedBytes(); got != containerDescriptorBudgetBytes {
plan.close()
t.Fatalf("seen gzip held budget = %d, want descriptor-only %d", got, containerDescriptorBudgetBytes)
}
plan.close()
if got := s.frameBudget.usedBytes(); got != 0 {
t.Fatalf("seen gzip close leaked budget: %d", got)
}
})
}
}
func TestSeenTopLevelContentGZIPShortCircuitsBeforeExpansion(t *testing.T) {
msgID := proto.NewMessageIDGen(time.Now).New(proto.MessageFromClient)
leaf := encodeClientMessageBodyForTest(t, &mt.PingRequest{PingID: 31})
wrapped := encodeClientMessageBodyForTest(t, &proto.GZIP{Data: leaf})
cs := newConnState()
cs.track(msgID, 1, true, msgStateReceived)
s := New(Options{Logger: zaptest.NewLogger(t)})
s.frameBudget = newInboundFrameBudget(1)
plan, err := s.preflightInbound(cs, msgID, 1, wrapped)
if err != nil {
t.Fatalf("preflight seen top-level gzip: %v", err)
}
if len(plan.items) != 1 || plan.items[0].kind != inboundItemDuplicate || len(plan.ackIDs) != 1 || plan.ackIDs[0] != msgID {
plan.close()
t.Fatalf("seen top-level gzip plan = items:%+v acks:%v", plan.items, plan.ackIDs)
}
plan.close()
if got := s.frameBudget.usedBytes(); got != 0 {
t.Fatalf("seen top-level gzip consumed expansion budget: %d", got)
}
}
func TestDuplicateOuterContainerOnlyInspectsInnerDescriptors(t *testing.T) {
ids := proto.NewMessageIDGen(time.Now)
innerMsgID := ids.New(proto.MessageFromClient)
outerMsgID := ids.New(proto.MessageFromClient)
leaf := encodeClientMessageBodyForTest(t, &mt.PingRequest{PingID: 4})
gzipped := encodeClientMessageBodyForTest(t, &proto.GZIP{Data: leaf})
container := encodeClientMessageBodyForTest(t, &proto.MessageContainer{Messages: []proto.Message{{
ID: innerMsgID, SeqNo: 1, Bytes: len(gzipped), Body: gzipped,
}}})
wrappedContainer := encodeClientMessageBodyForTest(t, &proto.GZIP{Data: container})
t.Run("seen inner replays without expansion", func(t *testing.T) {
cs := newConnState()
cs.track(outerMsgID, 2, false, msgStateReceived)
cs.track(innerMsgID, 1, true, msgStateReceived)
s := New(Options{Logger: zaptest.NewLogger(t)})
s.frameBudget = newInboundFrameBudget(containerDescriptorBudgetBytes)
plan, err := s.preflightInbound(cs, outerMsgID, 2, container)
if err != nil {
t.Fatalf("preflight duplicate outer: %v", err)
}
if len(plan.items) != 1 || plan.items[0].kind != inboundItemDuplicate {
plan.close()
t.Fatalf("duplicate outer items = %+v", plan.items)
}
plan.close()
if got := s.frameBudget.usedBytes(); got != 0 {
t.Fatalf("duplicate outer leaked descriptor budget: %d", got)
}
})
t.Run("top-level gzip container still locates inner", func(t *testing.T) {
cs := newConnState()
cs.track(outerMsgID, 2, false, msgStateReceived)
cs.track(innerMsgID, 1, true, msgStateReceived)
s := New(Options{Logger: zaptest.NewLogger(t)})
s.frameBudget = newInboundFrameBudget(maxSingleGZIPExpandedBytes + containerDescriptorBudgetBytes)
plan, err := s.preflightInbound(cs, outerMsgID, 2, wrappedContainer)
if err != nil {
t.Fatalf("preflight gzip duplicate outer: %v", err)
}
if len(plan.items) != 1 || plan.items[0].kind != inboundItemDuplicate || plan.items[0].msgID != innerMsgID {
plan.close()
t.Fatalf("gzip duplicate outer items = %+v, want inner duplicate", plan.items)
}
plan.close()
if got := s.frameBudget.usedBytes(); got != 0 {
t.Fatalf("gzip duplicate outer leaked expansion/descriptor budget: %d", got)
}
})
t.Run("unseen inner rejects without expansion", func(t *testing.T) {
cs := newConnState()
cs.track(outerMsgID, 2, false, msgStateReceived)
s := New(Options{Logger: zaptest.NewLogger(t)})
s.frameBudget = newInboundFrameBudget(containerDescriptorBudgetBytes)
plan, err := s.preflightInbound(cs, outerMsgID, 2, container)
if plan != nil {
plan.close()
t.Fatal("duplicate outer with unseen inner unexpectedly produced a plan")
}
var bad *dispatchBadMsgError
if !errors.As(err, &bad) || bad.code != badMsgContainer {
t.Fatalf("duplicate outer unseen-inner err = %v, want container rejection", err)
}
if got := s.frameBudget.usedBytes(); got != 0 {
t.Fatalf("unseen duplicate inner leaked descriptor/expansion budget: %d", got)
}
})
}
func TestServiceInfoViewsRejectOversizedBytesWithoutDecodeCopy(t *testing.T) { func TestServiceInfoViewsRejectOversizedBytesWithoutDecodeCopy(t *testing.T) {
state := mt.MsgsStateInfo{ReqMsgID: 7, Info: make([]byte, maxServiceMessageIDs)} state := mt.MsgsStateInfo{ReqMsgID: 7, Info: make([]byte, maxServiceMessageIDs)}
var encodedState bin.Buffer var encodedState bin.Buffer
@ -142,10 +554,58 @@ func TestDispatchRejectsExcessiveWrapperDepthBeforeRPC(t *testing.T) {
s := New(Options{Logger: zaptest.NewLogger(t)}) s := New(Options{Logger: zaptest.NewLogger(t)})
var acks []int64 var acks []int64
err := s.dispatch(context.Background(), newConnState(), nil, 4, 0, &bin.Buffer{Buf: encoded}, &acks) msgID := proto.NewMessageIDGen(time.Now).New(proto.MessageFromClient)
err := s.dispatch(context.Background(), newConnState(), nil, msgID, 0, &bin.Buffer{Buf: encoded}, &acks)
if err == nil || !strings.Contains(err.Error(), "wrapper depth") { if err == nil || !strings.Contains(err.Error(), "wrapper depth") {
t.Fatalf("deep wrapper err = %v, want wrapper depth rejection", err) t.Fatalf("deep wrapper err = %v, want wrapper depth rejection", err)
} }
if got := s.frameBudget.usedBytes(); got != 0 {
t.Fatalf("deep wrapper rejection leaked frame budget: %d", got)
}
}
func TestNestedWrapperTailFailuresReleaseEveryBudget(t *testing.T) {
ping := encodeClientMessageBodyForTest(t, &mt.PingRequest{PingID: 5})
var gzipWithTail bin.Buffer
if err := (&proto.GZIP{Data: ping}).Encode(&gzipWithTail); err != nil {
t.Fatalf("encode inner gzip: %v", err)
}
gzipWithTail.PutInt(0x11223344)
gzipTailWrapped := encodeClientMessageBodyForTest(t, &proto.GZIP{Data: gzipWithTail.Copy()})
var containerWithTail bin.Buffer
if err := (&proto.MessageContainer{}).Encode(&containerWithTail); err != nil {
t.Fatalf("encode inner container: %v", err)
}
containerWithTail.PutInt(0x55667788)
containerTailWrapped := encodeClientMessageBodyForTest(t, &proto.GZIP{Data: containerWithTail.Copy()})
tests := []struct {
name string
body []byte
seqNo int32
}{
{name: "gzip tail after outer expansion", body: gzipTailWrapped, seqNo: 1},
{name: "container tail after outer expansion", body: containerTailWrapped, seqNo: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := New(Options{Logger: zaptest.NewLogger(t)})
msgID := proto.NewMessageIDGen(time.Now).New(proto.MessageFromClient)
plan, err := s.preflightInbound(newConnState(), msgID, tt.seqNo, tt.body)
if plan != nil {
plan.close()
t.Fatal("invalid nested wrapper unexpectedly produced a plan")
}
if err == nil || !strings.Contains(err.Error(), "trailing") {
t.Fatalf("nested wrapper err = %v, want trailing-byte rejection", err)
}
if got := s.frameBudget.usedBytes(); got != 0 {
t.Fatalf("nested wrapper failure leaked frame budget: %d", got)
}
})
}
} }
func TestOversizedConnectionBuffersAreReleasedAfterFrame(t *testing.T) { func TestOversizedConnectionBuffersAreReleasedAfterFrame(t *testing.T) {

View file

@ -0,0 +1,339 @@
package mtprotoedge
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/gotd/td/bin"
"github.com/gotd/td/transport"
)
const (
physicalTransportClosedBit = uint64(1) << 63
physicalTransportGenerationMax = physicalTransportClosedBit - 1
)
// physicalTransportOwner separates the lifetime of one physical socket from
// the logical Conn generations that successively use it. state is either an
// open generation in [1, physicalTransportGenerationMax], or that generation
// with physicalTransportClosedBit set.
//
// Transfer and owner-close race through one atomic state transition. This is
// the critical property: when transfer wins, a stale generation can no longer
// close the socket; when close wins, no later generation can be published.
type physicalTransportOwner struct {
raw transport.Conn
// writeMu orders a completed send before generation transfer. CloseAny and
// owner-close deliberately do not wait for it: raw.Close must be able to
// interrupt a transport implementation blocked inside Send.
writeMu sync.Mutex
state atomic.Uint64
// binding identifies the logical Conn currently attached to the open
// generation. Physical close fences that exact Conn before touching raw;
// generation matching prevents a stale close from retiring a replacement.
bindingMu sync.Mutex
boundGeneration uint64
boundLogicalConn *Conn
closeDone chan struct{}
closeErr error
}
// physicalTransportLease is the capability owned by one logical Conn
// generation. It implements transport.Conn so exchange/protocol-error paths
// can use the initial lease from the moment serveConn starts.
type physicalTransportLease struct {
owner *physicalTransportOwner
generation uint64
}
var _ transport.Conn = (*physicalTransportLease)(nil)
func newPhysicalTransportOwner(raw transport.Conn) (*physicalTransportOwner, *physicalTransportLease) {
owner := &physicalTransportOwner{
raw: raw,
closeDone: make(chan struct{}),
}
owner.state.Store(1)
return owner, &physicalTransportLease{owner: owner, generation: 1}
}
// Transfer atomically hands the physical transport to the next logical Conn
// generation. Holding writeMu ensures every send admitted by the old lease has
// returned before the generation changes. CloseAny may still interrupt such a
// send and make the CAS fail, which is the required shutdown ordering.
func (l *physicalTransportLease) Transfer() (*physicalTransportLease, bool) {
if l == nil || l.owner == nil || l.generation == 0 {
return nil, false
}
owner := l.owner
owner.writeMu.Lock()
defer owner.writeMu.Unlock()
if l.generation >= physicalTransportGenerationMax {
return nil, false
}
if !owner.state.CompareAndSwap(l.generation, l.generation+1) {
return nil, false
}
owner.bindingMu.Lock()
if owner.boundGeneration == l.generation {
owner.boundGeneration = l.generation + 1
owner.boundLogicalConn = nil
}
owner.bindingMu.Unlock()
return &physicalTransportLease{owner: owner, generation: l.generation + 1}, true
}
// IsCurrentOpen reports whether this lease is still the unique open owner.
// Callers use it at protocol publication barriers after a required write.
func (l *physicalTransportLease) IsCurrentOpen() bool {
return l != nil && l.owner != nil && l.generation != 0 &&
l.owner.state.Load() == l.generation
}
// bindLogicalConn publishes the logical Conn for this exact generation. If
// physical close already won, the new Conn is terminally fenced immediately
// and can never pass SessionManager publication checks.
func (l *physicalTransportLease) bindLogicalConn(c *Conn) bool {
if l == nil || l.owner == nil || c == nil {
if c != nil {
c.beginTerminalShutdown()
}
return false
}
owner := l.owner
owner.bindingMu.Lock()
open := owner.state.Load() == l.generation
if open {
owner.boundGeneration = l.generation
owner.boundLogicalConn = c
}
owner.bindingMu.Unlock()
if !open {
c.beginTerminalShutdown()
}
return open
}
// Send admits a write only while this lease is the current open generation.
// The lock also serializes quick ACK/protocol writes with the outbound actor.
func (l *physicalTransportLease) Send(ctx context.Context, b *bin.Buffer) error {
return l.withCurrentWriter(func(raw transport.Conn) error {
return raw.Send(ctx, b)
})
}
// SendDeadline preserves the allocation-free fast path of compatTransportConn.
func (l *physicalTransportLease) SendDeadline(deadline time.Time, b *bin.Buffer) error {
return l.withCurrentWriter(func(raw transport.Conn) error {
if writer, ok := raw.(deadlineOutboundWriter); ok {
return writer.SendDeadline(deadline, b)
}
ctx := context.Background()
cancel := func() {}
if !deadline.IsZero() {
ctx, cancel = context.WithDeadline(ctx, deadline)
}
defer cancel()
return raw.Send(ctx, b)
})
}
func (l *physicalTransportLease) withCurrentWriter(send func(transport.Conn) error) error {
if l == nil || l.owner == nil || l.owner.raw == nil {
return ErrConnClosed
}
owner := l.owner
owner.writeMu.Lock()
defer owner.writeMu.Unlock()
if owner.state.Load() != l.generation {
return ErrConnClosed
}
return send(owner.raw)
}
// Recv is owned by serveConn rather than a logical Conn generation. It is a
// direct proxy so the read loop remains valid after ownership transfers.
func (l *physicalTransportLease) Recv(ctx context.Context, b *bin.Buffer) error {
if l == nil || l.owner == nil || l.owner.raw == nil {
return ErrConnClosed
}
return l.owner.raw.Recv(ctx, b)
}
// RecvDeadline preserves serveConn's direct-deadline fast path.
func (l *physicalTransportLease) RecvDeadline(deadline time.Time, b *bin.Buffer) error {
if l == nil || l.owner == nil || l.owner.raw == nil {
return ErrConnClosed
}
if receiver, ok := l.owner.raw.(deadlineReceiver); ok {
return receiver.RecvDeadline(deadline, b)
}
ctx := context.Background()
cancel := func() {}
if !deadline.IsZero() {
ctx, cancel = context.WithDeadline(ctx, deadline)
}
defer cancel()
return l.owner.raw.Recv(ctx, b)
}
// Close closes the physical transport only if this lease still owns the
// current generation. A stale logical Conn therefore cannot close a socket
// already transferred to its replacement.
func (l *physicalTransportLease) Close() error {
if l == nil || l.owner == nil || l.generation == 0 {
return nil
}
owner := l.owner
for {
state := owner.state.Load()
if state&physicalTransportClosedBit != 0 {
return owner.waitClosed()
}
if state != l.generation {
return nil
}
if owner.state.CompareAndSwap(state, state|physicalTransportClosedBit) {
owner.fenceBoundGeneration(l.generation)
return owner.closeRaw()
}
}
}
// startCloseAlreadyFenced is the non-reentrant close capability for the logical
// Conn that has already published terminal/lifecycle gates itself. It
// synchronously publishes the physical closed bit (so Transfer cannot win), then
// runs the potentially pathological raw.Close outside the RPC worker/flight
// handoff. It neither calls fenceBoundGeneration nor waits for a CloseAny that
// already won, avoiding both lifecycle reentry and close cycles.
func (l *physicalTransportLease) startCloseAlreadyFenced() {
if l == nil || l.owner == nil || l.generation == 0 {
return
}
owner := l.owner
for {
state := owner.state.Load()
if state&physicalTransportClosedBit != 0 || state != l.generation {
return
}
if owner.state.CompareAndSwap(state, state|physicalTransportClosedBit) {
owner.bindingMu.Lock()
if owner.boundGeneration == l.generation {
owner.boundLogicalConn = nil
}
owner.bindingMu.Unlock()
go func() { _ = owner.closeRaw() }()
return
}
}
}
// CloseAny is the unconditional physical-socket capability retained by
// serveConn. It marks the owner closed before calling raw.Close and never waits
// for writeMu, so it can break a blocked Send or Recv.
func (o *physicalTransportOwner) CloseAny() error {
if o == nil {
return nil
}
for {
state := o.state.Load()
if state&physicalTransportClosedBit != 0 {
return o.waitClosed()
}
if o.state.CompareAndSwap(state, state|physicalTransportClosedBit) {
o.fenceBoundGeneration(state)
return o.closeRaw()
}
}
}
func (o *physicalTransportOwner) fenceBoundGeneration(generation uint64) {
o.bindingMu.Lock()
var c *Conn
if o.boundGeneration == generation {
c = o.boundLogicalConn
o.boundLogicalConn = nil
}
o.bindingMu.Unlock()
if c != nil {
c.beginTerminalShutdown()
}
}
func (o *physicalTransportOwner) closeRaw() error {
if o.raw != nil {
o.closeErr = o.raw.Close()
}
close(o.closeDone)
return o.closeErr
}
func (o *physicalTransportOwner) waitClosed() error {
<-o.closeDone
return o.closeErr
}
// Forward the optional compat-transport capabilities hidden by the lease.
// These keep frame-budget ownership and quick-ack semantics unchanged while
// serveConn operates on the initial lease instead of the raw transport.
func (l *physicalTransportLease) releaseInboundFrame() {
if l == nil || l.owner == nil {
return
}
if releaser, ok := l.owner.raw.(inboundFrameOwnershipReleaser); ok {
releaser.releaseInboundFrame()
}
}
func (l *physicalTransportLease) retainInboundFrameBytes(n int64) bool {
if l == nil || l.owner == nil {
return true
}
if retainer, ok := l.owner.raw.(inboundFrameBackingRetainer); ok {
return retainer.retainInboundFrameBytes(n)
}
return true
}
func (l *physicalTransportLease) ConsumeQuickAckRequested() bool {
if l == nil || l.owner == nil {
return false
}
if quick, ok := l.owner.raw.(quickAckTransport); ok {
return quick.ConsumeQuickAckRequested()
}
return false
}
func (l *physicalTransportLease) SendQuickAck(ctx context.Context, token uint32) error {
return l.withCurrentWriter(func(raw transport.Conn) error {
if quick, ok := raw.(quickAckTransport); ok {
return quick.SendQuickAck(ctx, token)
}
return nil
})
}
func (l *physicalTransportLease) SendQuickAckDeadline(deadline time.Time, token uint32) error {
return l.withCurrentWriter(func(raw transport.Conn) error {
if quick, ok := raw.(deadlineQuickAckTransport); ok {
return quick.SendQuickAckDeadline(deadline, token)
}
if quick, ok := raw.(quickAckTransport); ok {
ctx := context.Background()
cancel := func() {}
if !deadline.IsZero() {
ctx, cancel = context.WithDeadline(ctx, deadline)
}
defer cancel()
return quick.SendQuickAck(ctx, token)
}
return nil
})
}

View file

@ -0,0 +1,198 @@
package mtprotoedge
import (
"context"
"errors"
"io"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gotd/td/bin"
)
type ownershipTestTransport struct {
closeCalls atomic.Int32
sendCalls atomic.Int32
closed chan struct{}
closeOnce sync.Once
blockSend bool
started chan struct{}
startOnce sync.Once
}
func newOwnershipTestTransport(blockSend bool) *ownershipTestTransport {
return &ownershipTestTransport{
closed: make(chan struct{}),
blockSend: blockSend,
started: make(chan struct{}),
}
}
func (t *ownershipTestTransport) Send(context.Context, *bin.Buffer) error {
t.sendCalls.Add(1)
t.startOnce.Do(func() { close(t.started) })
if !t.blockSend {
select {
case <-t.closed:
return io.ErrClosedPipe
default:
return nil
}
}
<-t.closed
return io.ErrClosedPipe
}
func (*ownershipTestTransport) Recv(context.Context, *bin.Buffer) error { return io.EOF }
func (t *ownershipTestTransport) Close() error {
t.closeOnce.Do(func() {
t.closeCalls.Add(1)
close(t.closed)
})
return nil
}
func TestPhysicalTransportTransferFencesStaleLease(t *testing.T) {
raw := newOwnershipTestTransport(false)
owner, oldLease := newPhysicalTransportOwner(raw)
newLease, ok := oldLease.Transfer()
if !ok {
t.Fatal("transfer failed")
}
if err := oldLease.Send(context.Background(), &bin.Buffer{}); !errors.Is(err, ErrConnClosed) {
t.Fatalf("stale Send error = %v, want ErrConnClosed", err)
}
if err := oldLease.Close(); err != nil {
t.Fatalf("stale Close: %v", err)
}
if got := raw.closeCalls.Load(); got != 0 {
t.Fatalf("stale Close closed raw %d times", got)
}
if err := newLease.Send(context.Background(), &bin.Buffer{}); err != nil {
t.Fatalf("current Send: %v", err)
}
if err := owner.CloseAny(); err != nil {
t.Fatalf("owner CloseAny: %v", err)
}
if got := raw.closeCalls.Load(); got != 1 {
t.Fatalf("raw close calls = %d, want 1", got)
}
}
func TestPhysicalTransportCloseWinningPreventsTransfer(t *testing.T) {
raw := newOwnershipTestTransport(false)
owner, lease := newPhysicalTransportOwner(raw)
if err := owner.CloseAny(); err != nil {
t.Fatalf("CloseAny: %v", err)
}
if next, ok := lease.Transfer(); ok || next != nil {
t.Fatalf("transfer after close = (%p,%v), want nil,false", next, ok)
}
}
func TestPhysicalTransportCloseAnyInterruptsWriteAndDefeatsTransfer(t *testing.T) {
raw := newOwnershipTestTransport(true)
owner, lease := newPhysicalTransportOwner(raw)
sendDone := make(chan error, 1)
go func() { sendDone <- lease.Send(context.Background(), &bin.Buffer{}) }()
select {
case <-raw.started:
case <-time.After(time.Second):
t.Fatal("write did not block")
}
transferDone := make(chan bool, 1)
go func() {
_, ok := lease.Transfer()
transferDone <- ok
}()
select {
case ok := <-transferDone:
t.Fatalf("transfer returned before blocked write ended: %v", ok)
case <-time.After(20 * time.Millisecond):
}
if err := owner.CloseAny(); err != nil {
t.Fatalf("CloseAny: %v", err)
}
select {
case err := <-sendDone:
if !errors.Is(err, io.ErrClosedPipe) {
t.Fatalf("blocked Send error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("CloseAny did not interrupt write")
}
select {
case ok := <-transferDone:
if ok {
t.Fatal("transfer won after physical close")
}
case <-time.After(time.Second):
t.Fatal("transfer did not finish")
}
}
func TestPhysicalCloseFencesActivationPublication(t *testing.T) {
raw := newOwnershipTestTransport(false)
owner, lease := newPhysicalTransportOwner(raw)
s := New(Options{})
c := s.newConnWithLease(lease, newTestAuthKey(t), 73001, 1)
if !c.beginActivationClaim() {
t.Fatal("activation claim failed")
}
if err := owner.CloseAny(); err != nil {
t.Fatalf("CloseAny: %v", err)
}
if c.publishActivation() {
t.Fatal("closed physical transport published active Conn")
}
if !c.terminal.Load() || c.lifecycleState() != connLifecycleRetired {
t.Fatalf("closed Conn terminal=%v lifecycle=%v", c.terminal.Load(), c.lifecycleState())
}
c.Close()
}
func TestPhysicalCloseBitPreventsActivationClaimBeforeLogicalFence(t *testing.T) {
raw := newOwnershipTestTransport(false)
owner, lease := newPhysicalTransportOwner(raw)
s := New(Options{})
c := s.newConnWithLease(lease, newTestAuthKey(t), 73002, 1)
// Hold the binding lock so CloseAny can linearize the physical closed bit but
// cannot yet publish c.terminal. beginActivationClaim must inspect the lease
// itself and refuse this otherwise-dangerous window.
owner.bindingMu.Lock()
closeDone := make(chan error, 1)
go func() { closeDone <- owner.CloseAny() }()
deadline := time.Now().Add(time.Second)
for owner.state.Load()&physicalTransportClosedBit == 0 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if owner.state.Load()&physicalTransportClosedBit == 0 {
owner.bindingMu.Unlock()
t.Fatal("CloseAny did not publish closed bit")
}
if c.terminal.Load() {
owner.bindingMu.Unlock()
t.Fatal("logical fence escaped held binding lock")
}
if c.beginActivationClaim() {
owner.bindingMu.Unlock()
t.Fatal("closed physical generation entered activation claim")
}
owner.bindingMu.Unlock()
select {
case err := <-closeDone:
if err != nil {
t.Fatalf("CloseAny: %v", err)
}
case <-time.After(time.Second):
t.Fatal("CloseAny did not finish")
}
if !c.terminal.Load() || c.lifecycleState() != connLifecycleRetired {
t.Fatalf("logical fence terminal=%v lifecycle=%v", c.terminal.Load(), c.lifecycleState())
}
c.Close()
}

View file

@ -5,6 +5,7 @@ import (
"github.com/gotd/td/bin" "github.com/gotd/td/bin"
"github.com/gotd/td/clock" "github.com/gotd/td/clock"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
"strconv" "strconv"
"strings" "strings"
@ -36,7 +37,8 @@ func TestLinkedDiscussionGuestCanCommentWithoutMembership(t *testing.T) {
subscriber, _ := users.Create(ctx, domain.User{AccessHash: 302, Phone: "15550003002", FirstName: "Subscriber"}) subscriber, _ := users.Create(ctx, domain.User{AccessHash: 302, Phone: "15550003002", FirstName: "Subscriber"})
channelStore := memory.NewChannelStore() channelStore := memory.NewChannelStore()
channels := appchannels.NewService(channelStore, appchannels.WithBotProfileResolver(emptyDiscussionBotProfiles{})) channels := appchannels.NewService(channelStore, appchannels.WithBotProfileResolver(emptyDiscussionBotProfiles{}))
r := New(Config{}, Deps{Users: appusers.NewService(users), Channels: channels}, zaptest.NewLogger(t), clock.System) dialogs := appdialogs.NewService(memory.NewDialogStore(), channelStore)
r := New(Config{}, Deps{Users: appusers.NewService(users), Channels: channels, Dialogs: dialogs}, zaptest.NewLogger(t), clock.System)
broadcast, err := channels.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "Private channel", Broadcast: true, Date: 1700003001}) broadcast, err := channels.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "Private channel", Broadcast: true, Date: 1700003001})
if err != nil { if err != nil {
@ -78,14 +80,11 @@ func TestLinkedDiscussionGuestCanCommentWithoutMembership(t *testing.T) {
if _, err := r.onChannelsGetFullChannel(WithUserID(ctx, subscriber.ID), inputGroup); err != nil { if _, err := r.onChannelsGetFullChannel(WithUserID(ctx, subscriber.ID), inputGroup); err != nil {
t.Fatalf("get linked group full as guest: %v", err) t.Fatalf("get linked group full as guest: %v", err)
} }
participant, err := r.onChannelsGetParticipant(WithUserID(ctx, subscriber.ID), &tg.ChannelsGetParticipantRequest{ _, err = r.onChannelsGetParticipant(WithUserID(ctx, subscriber.ID), &tg.ChannelsGetParticipantRequest{
Channel: inputGroup, Participant: &tg.InputPeerSelf{}, Channel: inputGroup, Participant: &tg.InputPeerSelf{},
}) })
if err != nil { if !tgerr.Is(err, "USER_NOT_PARTICIPANT") {
t.Fatalf("get linked guest participant: %v", err) t.Fatalf("get linked guest self participant err = %v, want USER_NOT_PARTICIPANT", err)
}
if _, ok := participant.Participant.(*tg.ChannelParticipantLeft); !ok {
t.Fatalf("linked guest participant = %T, want channelParticipantLeft", participant.Participant)
} }
if _, err := r.onChannelsGetParticipants(WithUserID(ctx, subscriber.ID), &tg.ChannelsGetParticipantsRequest{ if _, err := r.onChannelsGetParticipants(WithUserID(ctx, subscriber.ID), &tg.ChannelsGetParticipantsRequest{
Channel: inputGroup, Filter: &tg.ChannelParticipantsRecent{}, Limit: 20, Channel: inputGroup, Filter: &tg.ChannelParticipantsRecent{}, Limit: 20,
@ -97,11 +96,68 @@ func TestLinkedDiscussionGuestCanCommentWithoutMembership(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatalf("get linked group bot participants as guest: %v", err) t.Fatalf("get linked group bot participants as guest: %v", err)
} }
if _, err := r.onMessagesGetReplies(WithUserID(ctx, subscriber.ID), &tg.MessagesGetRepliesRequest{ directReplies, err := r.onMessagesGetReplies(WithUserID(ctx, subscriber.ID), &tg.MessagesGetRepliesRequest{
Peer: &tg.InputPeerChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash}, MsgID: root.ID, Limit: 20, Peer: &tg.InputPeerChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash}, MsgID: root.ID, Limit: 20,
}); err != nil { })
if err != nil {
t.Fatalf("get linked group replies as guest: %v", err) t.Fatalf("get linked group replies as guest: %v", err)
} }
directPage, ok := directReplies.(*tg.MessagesChannelMessages)
if !ok || len(directPage.Chats) != 1 {
t.Fatalf("direct linked guest replies = %T %+v, want one channel chat", directReplies, directReplies)
}
directGroup, ok := directPage.Chats[0].(*tg.Channel)
if !ok || directGroup.ID != group.Channel.ID || directGroup.Min || !directGroup.Left {
t.Fatalf("direct linked guest replies chat = %T %+v, want full left group %d", directPage.Chats[0], directPage.Chats[0], group.Channel.ID)
}
viaBroadcastReplies, err := r.onMessagesGetReplies(WithUserID(ctx, subscriber.ID), &tg.MessagesGetRepliesRequest{
Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, MsgID: post.ID, Limit: 20,
})
if err != nil {
t.Fatalf("get linked replies through broadcast as guest: %v", err)
}
viaBroadcastPage, ok := viaBroadcastReplies.(*tg.MessagesChannelMessages)
if !ok || len(viaBroadcastPage.Chats) != 2 {
t.Fatalf("broadcast linked guest replies = %T %+v, want primary group plus source channel", viaBroadcastReplies, viaBroadcastReplies)
}
viaBroadcastGroup, ok := viaBroadcastPage.Chats[0].(*tg.Channel)
if !ok || viaBroadcastGroup.ID != group.Channel.ID || viaBroadcastGroup.Min || !viaBroadcastGroup.Left {
t.Fatalf("broadcast linked guest primary chat = %T %+v, want full left group %d", viaBroadcastPage.Chats[0], viaBroadcastPage.Chats[0], group.Channel.ID)
}
viaBroadcastSource, ok := viaBroadcastPage.Chats[1].(*tg.Channel)
if !ok || viaBroadcastSource.ID != broadcast.Channel.ID || !viaBroadcastSource.Min {
t.Fatalf("broadcast linked guest companion chat = %T %+v, want min source %d", viaBroadcastPage.Chats[1], viaBroadcastPage.Chats[1], broadcast.Channel.ID)
}
peerDialogsReq := &tg.MessagesGetPeerDialogsRequest{Peers: []tg.InputDialogPeerClass{
&tg.InputDialogPeer{Peer: &tg.InputPeerChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash}},
}}
var peerDialogsIn bin.Buffer
if err := peerDialogsReq.Encode(&peerDialogsIn); err != nil {
t.Fatalf("encode linked guest getPeerDialogs: %v", err)
}
peerDialogsEnc, err := r.Dispatch(WithUserID(ctx, subscriber.ID), [8]byte{}, 0, &peerDialogsIn)
if err != nil {
t.Fatalf("dispatch linked guest getPeerDialogs: %v", err)
}
peerDialogs, ok := peerDialogsEnc.(*tg.MessagesPeerDialogs)
if !ok || len(peerDialogs.Dialogs) != 1 || len(peerDialogs.Chats) != 1 || len(peerDialogs.Messages) == 0 {
t.Fatalf("linked guest peer dialogs = %T %+v, want one transient dialog/chat with top message", peerDialogsEnc, peerDialogsEnc)
}
dialog, ok := peerDialogs.Dialogs[0].(*tg.Dialog)
if !ok || dialog.TopMessage == 0 {
t.Fatalf("linked guest dialog = %T %+v, want non-zero top message", peerDialogs.Dialogs[0], peerDialogs.Dialogs[0])
}
guestDialogView, err := channels.GetChannel(ctx, subscriber.ID, group.Channel.ID)
if err != nil {
t.Fatalf("get linked guest view for dialog pts: %v", err)
}
if pts, ok := dialog.GetPts(); !ok || pts != guestDialogView.Channel.Pts {
t.Fatalf("linked guest dialog pts = %d (set=%v), want %d", pts, ok, guestDialogView.Channel.Pts)
}
chat, ok := peerDialogs.Chats[0].(*tg.Channel)
if !ok || !chat.Left || chat.ID != group.Channel.ID {
t.Fatalf("linked guest peer dialog chat = %T %+v, want left group %d", peerDialogs.Chats[0], peerDialogs.Chats[0], group.Channel.ID)
}
if changed, err := r.onMessagesReadDiscussion(WithUserID(ctx, subscriber.ID), &tg.MessagesReadDiscussionRequest{ if changed, err := r.onMessagesReadDiscussion(WithUserID(ctx, subscriber.ID), &tg.MessagesReadDiscussionRequest{
Peer: &tg.InputPeerChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash}, MsgID: root.ID, ReadMaxID: group.Channel.TopMessageID, Peer: &tg.InputPeerChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash}, MsgID: root.ID, ReadMaxID: group.Channel.TopMessageID,
}); err != nil || changed { }); err != nil || changed {
@ -120,6 +176,112 @@ func TestLinkedDiscussionGuestCanCommentWithoutMembership(t *testing.T) {
} }
} }
func TestPublicChannelAndMegagroupPreviewStayReadableForNonMember(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
owner, _ := users.Create(ctx, domain.User{AccessHash: 401, Phone: "15550004001", FirstName: "Owner"})
viewer, _ := users.Create(ctx, domain.User{AccessHash: 402, Phone: "15550004002", FirstName: "Viewer"})
channels := appchannels.NewService(memory.NewChannelStore())
r := New(Config{}, Deps{Users: appusers.NewService(users), Channels: channels}, zaptest.NewLogger(t), clock.System)
tests := []struct {
name string
broadcast bool
username string
}{
{name: "broadcast", broadcast: true, username: "public_rpc_broadcast"},
{name: "megagroup", username: "public_rpc_megagroup"},
}
for i, test := range tests {
t.Run(test.name, func(t *testing.T) {
var created domain.CreateChannelResult
var err error
if test.broadcast {
created, err = channels.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: test.name, Broadcast: true, Date: 1700004000 + i})
} else {
created, err = channels.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{Title: test.name, Date: 1700004000 + i})
}
if err != nil {
t.Fatalf("create public peer: %v", err)
}
public, err := channels.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{
UserID: owner.ID, ChannelID: created.Channel.ID, Username: test.username,
})
if err != nil {
t.Fatalf("make public: %v", err)
}
if _, err := channels.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
ChannelID: public.ID, RandomID: int64(4000 + i), Message: "public history", Date: 1700004010 + i,
}); err != nil {
t.Fatalf("seed public history: %v", err)
}
inputChannel := &tg.InputChannel{ChannelID: public.ID, AccessHash: public.AccessHash}
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, viewer.ID), inputChannel)
if err != nil {
t.Fatalf("getFullChannel preview: %v", err)
}
var preview *tg.Channel
for _, chat := range full.Chats {
if channel, ok := chat.(*tg.Channel); ok && channel.ID == public.ID {
preview = channel
}
}
if preview == nil || !preview.Left {
t.Fatalf("full chats = %+v, want public peer projected left", full.Chats)
}
history := dispatchMessagesPayload(t, r, WithUserID(ctx, viewer.ID), &tg.MessagesGetHistoryRequest{
Peer: &tg.InputPeerChannel{ChannelID: public.ID, AccessHash: public.AccessHash}, Limit: 20,
})
messages, _, _ := searchMessagesPayload(t, history)
if len(messages) == 0 {
t.Fatal("public preview history is empty")
}
if _, err := r.onChannelsGetParticipants(WithUserID(ctx, viewer.ID), &tg.ChannelsGetParticipantsRequest{
Channel: inputChannel, Filter: &tg.ChannelParticipantsRecent{}, Limit: 20,
}); err != nil {
t.Fatalf("getParticipants preview: %v", err)
}
if _, err := r.onChannelsGetParticipant(WithUserID(ctx, viewer.ID), &tg.ChannelsGetParticipantRequest{
Channel: inputChannel, Participant: &tg.InputPeerSelf{},
}); !tgerr.Is(err, "USER_NOT_PARTICIPANT") {
t.Fatalf("self participant preview err = %v, want USER_NOT_PARTICIPANT", err)
}
if _, err := channels.GetChannel(ctx, viewer.ID, public.ID); err != nil {
t.Fatalf("public preview became forbidden after participant miss: %v", err)
}
if _, err := channels.JoinChannel(ctx, viewer.ID, public.ID, 1700004020+i); err != nil {
t.Fatalf("join public peer: %v", err)
}
if _, err := channels.LeaveChannel(ctx, viewer.ID, public.ID, 1700004030+i); err != nil {
t.Fatalf("leave public peer: %v", err)
}
if _, err := r.onChannelsGetParticipant(WithUserID(ctx, viewer.ID), &tg.ChannelsGetParticipantRequest{
Channel: inputChannel, Participant: &tg.InputPeerSelf{},
}); !tgerr.Is(err, "USER_NOT_PARTICIPANT") {
t.Fatalf("left self participant err = %v, want USER_NOT_PARTICIPANT", err)
}
if _, err := channels.GetHistory(ctx, viewer.ID, domain.ChannelHistoryFilter{ChannelID: public.ID, Limit: 20}); err != nil {
t.Fatalf("public history after leave: %v", err)
}
})
}
private, err := channels.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{Title: "private", Date: 1700004050})
if err != nil {
t.Fatalf("create private group: %v", err)
}
privateReq := &tg.MessagesGetHistoryRequest{
Peer: &tg.InputPeerChannel{ChannelID: private.Channel.ID, AccessHash: private.Channel.AccessHash}, Limit: 20,
}
var privateIn bin.Buffer
if err := privateReq.Encode(&privateIn); err != nil {
t.Fatalf("encode private history: %v", err)
}
if _, err := r.Dispatch(WithUserID(ctx, viewer.ID), [8]byte{}, 0, &privateIn); !tgerr.Is(err, "CHANNEL_PRIVATE") {
t.Fatalf("private non-member history err = %v, want CHANNEL_PRIVATE", err)
}
}
func (s *countingDiscussionReadChannels) ResolveDiscussionReadTarget(ctx context.Context, userID, sourceChannelID int64, sourceMessageID, readMaxID int) (domain.ChannelDiscussionReadTarget, error) { func (s *countingDiscussionReadChannels) ResolveDiscussionReadTarget(ctx context.Context, userID, sourceChannelID int64, sourceMessageID, readMaxID int) (domain.ChannelDiscussionReadTarget, error) {
s.resolveCalls++ s.resolveCalls++
return s.delegate.ResolveDiscussionReadTarget(ctx, userID, sourceChannelID, sourceMessageID, readMaxID) return s.delegate.ResolveDiscussionReadTarget(ctx, userID, sourceChannelID, sourceMessageID, readMaxID)

View file

@ -206,7 +206,7 @@ func (r *Router) onChannelsGetParticipant(ctx context.Context, req *tg.ChannelsG
} }
member, err := r.deps.Channels.GetParticipant(ctx, userID, channelID, peer.ID) member, err := r.deps.Channels.GetParticipant(ctx, userID, channelID, peer.ID)
if err != nil { if err != nil {
return nil, channelInvalidErr(err) return nil, channelParticipantErr(err)
} }
participant := tgChannelParticipant(userID, member) participant := tgChannelParticipant(userID, member)
users := r.tgUsersForIDs(ctx, userID, channelParticipantUserRefs(participant)) users := r.tgUsersForIDs(ctx, userID, channelParticipantUserRefs(participant))
@ -217,6 +217,13 @@ func (r *Router) onChannelsGetParticipant(ctx context.Context, req *tg.ChannelsG
}, nil }, nil
} }
func channelParticipantErr(err error) error {
if errors.Is(err, domain.ErrUserNotParticipant) {
return tgerr400("USER_NOT_PARTICIPANT")
}
return channelInvalidErr(err)
}
func channelParticipantUserRefs(participant tg.ChannelParticipantClass) []int64 { func channelParticipantUserRefs(participant tg.ChannelParticipantClass) []int64 {
ids := make([]int64, 0, 3) ids := make([]int64, 0, 3)
add := func(id int64) { add := func(id int64) {

View file

@ -22,8 +22,15 @@ func tgChannelChatsWithPrimarySelf(viewerUserID int64, primary domain.Channel, e
continue continue
} }
seen[ch.ID] = struct{}{} seen[ch.ID] = struct{}{}
if ch.ID != primary.ID {
// Companion channels have no matching viewer membership in
// ChannelHistory. Keep them minimal so they cannot overwrite the
// client's cached left/creator/admin/banned state.
out = append(out, tgChannelChatMin(viewerUserID, ch))
continue
}
var self *domain.ChannelMember var self *domain.ChannelMember
if ch.ID == primary.ID && primarySelf.ChannelID == ch.ID && primarySelf.UserID == viewerUserID { if primarySelf.ChannelID == ch.ID && primarySelf.UserID == viewerUserID {
self = &primarySelf self = &primarySelf
} }
out = append(out, tgChannelChat(viewerUserID, ch, self)) out = append(out, tgChannelChat(viewerUserID, ch, self))

View file

@ -0,0 +1,119 @@
package memory
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
func TestLinkedDiscussionGuestAuthorizationFailsClosedMemory(t *testing.T) {
ctx := context.Background()
channels := NewChannelStore()
const (
ownerID int64 = 9101
outsiderID int64 = 9102
)
privateGroup, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: ownerID,
Title: "unlinked private group",
Megagroup: true,
Date: 1700009100,
})
if err != nil {
t.Fatalf("create private group: %v", err)
}
if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: outsiderID,
ChannelID: privateGroup.Channel.ID,
RandomID: 91001,
Message: "must be rejected",
Date: 1700009101,
}); !errors.Is(err, domain.ErrChannelPrivate) {
t.Fatalf("unlinked outsider send err = %v, want ErrChannelPrivate", err)
}
if _, err := channels.ResolveDiscussionReadTarget(ctx, outsiderID, privateGroup.Channel.ID, privateGroup.Message.ID, privateGroup.Message.ID); !errors.Is(err, domain.ErrChannelPrivate) {
t.Fatalf("unlinked outsider discussion read err = %v, want ErrChannelPrivate", err)
}
if got := len(channels.messages[privateGroup.Channel.ID]); got != 1 {
t.Fatalf("unlinked outsider changed history length = %d, want create message only", got)
}
}
func TestLinkedDiscussionGuestRepliesKeepViewerProjectionMemory(t *testing.T) {
ctx := context.Background()
channels := NewChannelStore()
const (
ownerID int64 = 9201
subscriberID int64 = 9202
)
broadcast, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: ownerID,
Title: "linked source",
Broadcast: true,
Date: 1700009200,
})
if err != nil {
t.Fatalf("create broadcast: %v", err)
}
group, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: ownerID,
Title: "linked discussion",
Megagroup: true,
Date: 1700009201,
})
if err != nil {
t.Fatalf("create group: %v", err)
}
if _, err := channels.SetDiscussionGroup(ctx, ownerID, broadcast.Channel.ID, group.Channel.ID); err != nil {
t.Fatalf("set discussion group: %v", err)
}
if _, err := channels.InviteToChannel(ctx, broadcast.Channel.ID, ownerID, []int64{subscriberID}, 1700009202); err != nil {
t.Fatalf("invite broadcast subscriber: %v", err)
}
post, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: ownerID, ChannelID: broadcast.Channel.ID, RandomID: 92001,
Message: "post", Date: 1700009203,
})
if err != nil || post.Discussion == nil {
t.Fatalf("send linked post = %+v err %v", post, err)
}
assertGuest := func(name string, history domain.ChannelHistory, err error) {
t.Helper()
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if history.Channel.ID != group.Channel.ID || history.Self.ChannelID != group.Channel.ID || history.Self.UserID != subscriberID || !history.Self.Guest || history.Self.Status != domain.ChannelMemberLeft {
t.Fatalf("%s = channel %+v self %+v, want linked left guest", name, history.Channel, history.Self)
}
}
direct, err := channels.ListChannelReplies(ctx, subscriberID, domain.ChannelRepliesFilter{
ChannelID: group.Channel.ID, RootMessageID: post.Discussion.Message.ID, Limit: 20,
})
assertGuest("direct replies", direct, err)
viaBroadcast, err := channels.ListChannelReplies(ctx, subscriberID, domain.ChannelRepliesFilter{
ChannelID: broadcast.Channel.ID, RootMessageID: post.Message.ID, Limit: 20,
})
assertGuest("broadcast replies", viaBroadcast, err)
if _, err := channels.EditChannelBanned(ctx, domain.EditChannelBannedRequest{
UserID: ownerID, ChannelID: group.Channel.ID,
Participant: domain.Peer{Type: domain.PeerTypeUser, ID: subscriberID},
BannedRights: domain.ChannelBannedRights{ViewMessages: true, UntilDate: 2147483647},
Date: 1700009204,
}); err != nil {
t.Fatalf("ban linked subscriber from target: %v", err)
}
if _, err := channels.ListChannelReplies(ctx, subscriberID, domain.ChannelRepliesFilter{
ChannelID: group.Channel.ID, RootMessageID: post.Discussion.Message.ID, Limit: 20,
}); !errors.Is(err, domain.ErrChannelUserBanned) {
t.Fatalf("target-banned direct replies err = %v, want ErrChannelUserBanned", err)
}
if _, err := channels.ListChannelReplies(ctx, subscriberID, domain.ChannelRepliesFilter{
ChannelID: broadcast.Channel.ID, RootMessageID: post.Message.ID, Limit: 20,
}); !errors.Is(err, domain.ErrChannelUserBanned) {
t.Fatalf("target-banned broadcast replies err = %v, want ErrChannelUserBanned", err)
}
}

View file

@ -12,7 +12,7 @@ import (
func (s *ChannelStore) GetParticipants(_ context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { func (s *ChannelStore) GetParticipants(_ context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
channel, viewer, err := s.channelAndMemberOrLinkedGuestLocked(viewerUserID, channelID) channel, viewer, _, err := s.channelForViewerLocked(viewerUserID, channelID)
if err != nil { if err != nil {
return domain.ChannelParticipantList{}, err return domain.ChannelParticipantList{}, err
} }
@ -75,17 +75,16 @@ func (s *ChannelStore) GetParticipants(_ context.Context, viewerUserID, channelI
func (s *ChannelStore) GetParticipant(_ context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) { func (s *ChannelStore) GetParticipant(_ context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
channel, viewer, err := s.channelAndMemberOrLinkedGuestLocked(viewerUserID, channelID) _, viewer, _, err := s.channelForViewerLocked(viewerUserID, channelID)
if err != nil { if err != nil {
return domain.ChannelMember{}, err return domain.ChannelMember{}, err
} }
if viewerUserID == participantUserID && viewer.Guest { if viewerUserID == participantUserID && viewer.Guest {
return viewer, nil return domain.ChannelMember{}, domain.ErrUserNotParticipant
} }
_ = channel
member, ok := s.members[channelID][participantUserID] member, ok := s.members[channelID][participantUserID]
if !ok { if !ok || (participantUserID == viewerUserID && member.Status == domain.ChannelMemberLeft) {
return domain.ChannelMember{}, domain.ErrChannelPrivate return domain.ChannelMember{}, domain.ErrUserNotParticipant
} }
return member, nil return member, nil
} }
@ -868,7 +867,7 @@ func (s *ChannelStore) FilterActiveChannelMemberIDs(_ context.Context, channelID
func (s *ChannelStore) ListActiveChannelMembers(_ context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error) { func (s *ChannelStore) ListActiveChannelMembers(_ context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
channel, viewer, err := s.channelAndMemberOrLinkedGuestLocked(viewerUserID, channelID) channel, viewer, _, err := s.channelForViewerLocked(viewerUserID, channelID)
if err != nil { if err != nil {
return domain.Channel{}, domain.ChannelMember{}, nil, err return domain.Channel{}, domain.ChannelMember{}, nil, err
} }

View file

@ -443,7 +443,16 @@ func (s *ChannelStore) ResolveDiscussionReadTarget(_ context.Context, viewerUser
if targetChannelID != sourceChannelID { if targetChannelID != sourceChannelID {
_, targetMember, err = s.channelAndMemberLocked(viewerUserID, targetChannelID) _, targetMember, err = s.channelAndMemberLocked(viewerUserID, targetChannelID)
if errors.Is(err, domain.ErrChannelPrivate) { if errors.Is(err, domain.ErrChannelPrivate) {
targetMember, _, err = s.linkedDiscussionGuestLocked(viewerUserID, s.channels[targetChannelID]) guestMember, allowed, guestErr := s.linkedDiscussionGuestLocked(viewerUserID, s.channels[targetChannelID])
switch {
case guestErr != nil:
err = guestErr
case allowed:
targetMember = guestMember
err = nil
default:
err = domain.ErrChannelPrivate
}
} }
} }
if err != nil { if err != nil {

View file

@ -41,10 +41,18 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID)
if errors.Is(err, domain.ErrChannelPrivate) { if errors.Is(err, domain.ErrChannelPrivate) {
if candidate, ok := s.channels[req.ChannelID]; ok && !candidate.Deleted { if candidate, ok := s.channels[req.ChannelID]; ok && !candidate.Deleted {
var guest bool guestMember, guest, guestErr := s.linkedDiscussionGuestLocked(req.UserID, candidate)
member, guest, err = s.linkedDiscussionGuestLocked(req.UserID, candidate) switch {
if guest { case guestErr != nil:
err = guestErr
case guest:
channel = candidate channel = candidate
member = guestMember
err = nil
default:
// A clean "not a linked guest" result is not authorization.
// Preserve the original private-member error and fail closed.
err = domain.ErrChannelPrivate
} }
} }
} }

View file

@ -513,23 +513,19 @@ func (s *ChannelStore) ListChannelReplies(_ context.Context, viewerUserID int64,
extraChannels := []domain.Channel(nil) extraChannels := []domain.Channel(nil)
if source.Broadcast { if source.Broadcast {
if root.Discussion == nil || root.Discussion.ChannelID == 0 || root.Discussion.MessageID == 0 { if root.Discussion == nil || root.Discussion.ChannelID == 0 || root.Discussion.MessageID == 0 {
return domain.ChannelHistory{Channel: source, Count: 0}, nil return domain.ChannelHistory{Channel: source, Self: member, Count: 0}, nil
} }
linked, ok := s.channels[root.Discussion.ChannelID] linked, linkedMember, linkedErr := s.channelAndMemberOrLinkedGuestLocked(viewerUserID, root.Discussion.ChannelID)
if !ok || linked.Deleted { if linkedErr != nil {
return domain.ChannelHistory{Channel: source, Count: 0}, nil return domain.ChannelHistory{}, linkedErr
} }
targetChannel = linked targetChannel = linked
targetMember = linkedMember
rootID = root.Discussion.MessageID rootID = root.Discussion.MessageID
if linkedMember, ok := s.members[linked.ID][viewerUserID]; ok {
targetMember = linkedMember
} else {
targetMember = domain.ChannelMember{}
}
extraChannels = append(extraChannels, source) extraChannels = append(extraChannels, source)
} }
if targetRoot, ok := s.findMessageLocked(targetChannel.ID, rootID); !ok || targetRoot.Deleted { if targetRoot, ok := s.findMessageLocked(targetChannel.ID, rootID); !ok || targetRoot.Deleted {
return domain.ChannelHistory{Channel: targetChannel, Channels: extraChannels, Count: 0}, nil return domain.ChannelHistory{Channel: targetChannel, Self: targetMember, Channels: extraChannels, Count: 0}, nil
} }
limit := filter.Limit limit := filter.Limit
if limit <= 0 || limit > domain.MaxChannelRepliesLimit { if limit <= 0 || limit > domain.MaxChannelRepliesLimit {
@ -570,7 +566,7 @@ func (s *ChannelStore) ListChannelReplies(_ context.Context, viewerUserID int64,
topics = append(topics, cloneChannelForumTopic(topic)) topics = append(topics, cloneChannelForumTopic(topic))
} }
} }
return domain.ChannelHistory{Channel: targetChannel, Channels: extraChannels, Topics: topics, Messages: out, Count: len(base)}, nil return domain.ChannelHistory{Channel: targetChannel, Self: targetMember, Channels: extraChannels, Topics: topics, Messages: out, Count: len(base)}, nil
} }
func (s *ChannelStore) populateChannelMessageRepliesLocked(viewerUserID, channelID int64, messages []domain.ChannelMessage) { func (s *ChannelStore) populateChannelMessageRepliesLocked(viewerUserID, channelID int64, messages []domain.ChannelMessage) {

View file

@ -377,7 +377,20 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
if err != nil { if err != nil {
return nil, err return nil, err
} }
linkedGuests, err := s.listLinkedDiscussionGuests(ctx, s.db, viewerUserID, remaining)
if err != nil {
return nil, err
}
for _, channel := range channels { for _, channel := range channels {
if member, ok := linkedGuests[channel.ID]; ok {
views[channel.ID] = domain.ChannelView{
Channel: channel,
Self: member,
Dialog: previewChannelDialog(viewerUserID, channel, member),
SelfBoostsApplied: 0,
}
continue
}
if member, _, ok, err := s.monoforumAdminPreview(ctx, s.db, viewerUserID, channel); err != nil { if member, _, ok, err := s.monoforumAdminPreview(ctx, s.db, viewerUserID, channel); err != nil {
return nil, err return nil, err
} else if ok { } else if ok {

View file

@ -96,6 +96,72 @@ func (s *ChannelStore) getLinkedDiscussionGuest(ctx context.Context, db sqlcgen.
return guest, true, nil return guest, true, nil
} }
// listLinkedDiscussionGuests is the bounded batch equivalent of
// getLinkedDiscussionGuest. GetChannels uses it after loading the requested
// channel rows so messages.getPeerDialogs can materialize linked discussion
// histories without an N+1 query per requested peer.
//
// The target membership predicate deliberately excludes active, kicked,
// banned and view-messages-banned rows. Active members were projected by the
// primary GetChannels query; explicit target denial must always win over the
// source broadcast membership. Returned members are transient and are never
// persisted to channel_members/channel_dialogs.
func (s *ChannelStore) listLinkedDiscussionGuests(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, targetIDs []int64) (map[int64]domain.ChannelMember, error) {
out := make(map[int64]domain.ChannelMember)
if viewerUserID == 0 || len(targetIDs) == 0 {
return out, nil
}
rows, err := db.Query(ctx, `
SELECT target.id
FROM channels target
JOIN channels source
ON source.id = target.linked_chat_id
AND NOT source.deleted
AND source.broadcast
AND source.linked_chat_id = target.id
JOIN channel_members source_member
ON source_member.channel_id = source.id
AND source_member.user_id = $1
AND source_member.status = 'active'
AND NOT COALESCE((source_member.banned_rights->>'ViewMessages')::boolean, false)
LEFT JOIN channel_members target_member
ON target_member.channel_id = target.id
AND target_member.user_id = $1
WHERE target.id = ANY($2::bigint[])
AND NOT target.deleted
AND target.megagroup
AND NOT target.broadcast
AND (
target_member.user_id IS NULL
OR (
target_member.status NOT IN ('active', 'banned', 'kicked')
AND NOT COALESCE((target_member.banned_rights->>'ViewMessages')::boolean, false)
)
)
ORDER BY target.id`, viewerUserID, targetIDs)
if err != nil {
return nil, fmt.Errorf("list linked discussion guests: %w", err)
}
defer rows.Close()
for rows.Next() {
var channelID int64
if err := rows.Scan(&channelID); err != nil {
return nil, fmt.Errorf("scan linked discussion guest: %w", err)
}
out[channelID] = domain.ChannelMember{
ChannelID: channelID,
UserID: viewerUserID,
Status: domain.ChannelMemberLeft,
Role: domain.ChannelRoleMember,
Guest: true,
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate linked discussion guests: %w", err)
}
return out, nil
}
func (s *ChannelStore) getPublicPreviewMember(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, ch domain.Channel) (domain.ChannelMember, error) { func (s *ChannelStore) getPublicPreviewMember(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, ch domain.Channel) (domain.ChannelMember, error) {
member, err := s.getChannelMember(ctx, db, ch.ID, viewerUserID) member, err := s.getChannelMember(ctx, db, ch.ID, viewerUserID)
if err != nil { if err != nil {

View file

@ -2,6 +2,7 @@ package postgres
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
@ -12,7 +13,7 @@ import (
) )
func (s *ChannelStore) GetParticipants(ctx context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { func (s *ChannelStore) GetParticipants(ctx context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
channel, viewer, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, channelID) channel, viewer, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID)
if err != nil { if err != nil {
return domain.ChannelParticipantList{}, err return domain.ChannelParticipantList{}, err
} }
@ -170,14 +171,21 @@ WHERE channel_id = $1
} }
func (s *ChannelStore) GetParticipant(ctx context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) { func (s *ChannelStore) GetParticipant(ctx context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) {
_, viewer, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, channelID) _, viewer, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID)
if err != nil { if err != nil {
return domain.ChannelMember{}, err return domain.ChannelMember{}, err
} }
if viewerUserID == participantUserID && viewer.Guest { if viewerUserID == participantUserID && viewer.Guest {
return viewer, nil return domain.ChannelMember{}, domain.ErrUserNotParticipant
} }
return s.getChannelMember(ctx, s.db, channelID, participantUserID) member, err := s.getChannelMember(ctx, s.db, channelID, participantUserID)
if errors.Is(err, domain.ErrChannelPrivate) {
return domain.ChannelMember{}, domain.ErrUserNotParticipant
}
if err == nil && participantUserID == viewerUserID && member.Status == domain.ChannelMemberLeft {
return domain.ChannelMember{}, domain.ErrUserNotParticipant
}
return member, err
} }
func (s *ChannelStore) ListActiveChannelMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) { func (s *ChannelStore) ListActiveChannelMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) {
@ -204,7 +212,7 @@ func (s *ChannelStore) ListActiveChannelMemberIDs(ctx context.Context, viewerUse
} }
func (s *ChannelStore) ListActiveChannelMembers(ctx context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error) { func (s *ChannelStore) ListActiveChannelMembers(ctx context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error) {
channel, viewer, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, channelID) channel, viewer, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID)
if err != nil { if err != nil {
return domain.Channel{}, domain.ChannelMember{}, nil, err return domain.Channel{}, domain.ChannelMember{}, nil, err
} }
@ -237,7 +245,7 @@ LIMIT $2`, channelID, limit)
} }
func (s *ChannelStore) ListActiveChannelBotMembers(ctx context.Context, viewerUserID, channelID int64, offset, limit int) (domain.ChannelParticipantList, error) { func (s *ChannelStore) ListActiveChannelBotMembers(ctx context.Context, viewerUserID, channelID int64, offset, limit int) (domain.ChannelParticipantList, error) {
channel, viewer, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, channelID) channel, viewer, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID)
if err != nil { if err != nil {
return domain.ChannelParticipantList{}, err return domain.ChannelParticipantList{}, err
} }

View file

@ -66,10 +66,18 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID)
if errors.Is(err, domain.ErrChannelPrivate) { if errors.Is(err, domain.ErrChannelPrivate) {
if candidate, candidateErr := s.channelByID(ctx, tx, req.ChannelID); candidateErr == nil { if candidate, candidateErr := s.channelByID(ctx, tx, req.ChannelID); candidateErr == nil {
var guest bool guestMember, guest, guestErr := s.getLinkedDiscussionGuest(ctx, tx, req.UserID, candidate)
member, guest, err = s.getLinkedDiscussionGuest(ctx, tx, req.UserID, candidate) switch {
if guest { case guestErr != nil:
err = guestErr
case guest:
channel = candidate channel = candidate
member = guestMember
err = nil
default:
// A clean "not a linked guest" result is not authorization.
// Preserve the original private-member error and fail closed.
err = domain.ErrChannelPrivate
} }
} else { } else {
err = candidateErr err = candidateErr

View file

@ -0,0 +1,263 @@
package postgres
import (
"context"
"errors"
"testing"
appdialogs "telesrv/internal/app/dialogs"
"telesrv/internal/domain"
)
func TestPublicChannelAndMegagroupPreviewPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 941, Phone: "+1941" + suffix + "01", FirstName: "PreviewOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
viewer, err := users.Create(ctx, domain.User{AccessHash: 942, Phone: "+1942" + suffix + "02", FirstName: "PreviewViewer"})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
channels := NewChannelStore(pool)
var channelIDs []int64
t.Cleanup(func() {
if len(channelIDs) != 0 {
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs)
}
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, viewer.ID})
})
for i, tc := range []struct {
name string
broadcast bool
}{
{name: "broadcast", broadcast: true},
{name: "megagroup"},
} {
t.Run(tc.name, func(t *testing.T) {
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Public Preview " + tc.name + " " + suffix,
Broadcast: tc.broadcast,
Megagroup: !tc.broadcast,
Date: 1700009400 + i,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelIDs = append(channelIDs, created.Channel.ID)
public, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{
UserID: owner.ID, ChannelID: created.Channel.ID, Username: "pub" + tc.name + suffix,
})
if err != nil {
t.Fatalf("make public: %v", err)
}
sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: public.ID, RandomID: int64(94100 + i), Message: "public history", Date: 1700009410 + i,
})
if err != nil {
t.Fatalf("send public message: %v", err)
}
history, err := channels.ListChannelHistory(ctx, viewer.ID, domain.ChannelHistoryFilter{ChannelID: public.ID, Limit: 20})
if err != nil {
t.Fatalf("public preview history: %v", err)
}
found := false
for _, message := range history.Messages {
if message.ID == sent.Message.ID {
found = true
}
}
if !found || history.Self.Status != domain.ChannelMemberLeft {
t.Fatalf("preview history = %+v self=%+v", history.Messages, history.Self)
}
if _, err := channels.GetParticipants(ctx, viewer.ID, public.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsRecent}, 0, 20); err != nil {
t.Fatalf("public preview participants: %v", err)
}
if _, err := channels.GetParticipant(ctx, viewer.ID, public.ID, viewer.ID); !errors.Is(err, domain.ErrUserNotParticipant) {
t.Fatalf("public preview self participant err = %v, want ErrUserNotParticipant", err)
}
var memberExists bool
if err := pool.QueryRow(ctx, `SELECT EXISTS (
SELECT 1 FROM channel_members WHERE channel_id = $1 AND user_id = $2
)`, public.ID, viewer.ID).Scan(&memberExists); err != nil {
t.Fatalf("check preview member row: %v", err)
}
if memberExists {
t.Fatal("public preview persisted a channel member row")
}
if _, err := channels.JoinChannel(ctx, public.ID, viewer.ID, 1700009420+i); err != nil {
t.Fatalf("join public peer: %v", err)
}
if _, err := channels.LeaveChannel(ctx, public.ID, viewer.ID, 1700009430+i); err != nil {
t.Fatalf("leave public peer: %v", err)
}
if _, err := channels.GetParticipant(ctx, viewer.ID, public.ID, viewer.ID); !errors.Is(err, domain.ErrUserNotParticipant) {
t.Fatalf("left self participant err = %v, want ErrUserNotParticipant", err)
}
if _, err := channels.ListChannelHistory(ctx, viewer.ID, domain.ChannelHistoryFilter{ChannelID: public.ID, Limit: 20}); err != nil {
t.Fatalf("public history after leave: %v", err)
}
})
}
private, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "Private Preview " + suffix, Megagroup: true, Date: 1700009450,
})
if err != nil {
t.Fatalf("create private group: %v", err)
}
channelIDs = append(channelIDs, private.Channel.ID)
if _, err := channels.ListChannelHistory(ctx, viewer.ID, domain.ChannelHistoryFilter{ChannelID: private.Channel.ID, Limit: 20}); !errors.Is(err, domain.ErrChannelPrivate) {
t.Fatalf("private preview history err = %v, want ErrChannelPrivate", err)
}
}
func TestLinkedDiscussionGuestPeerDialogProjectionPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 951, Phone: "+1951" + suffix + "01", FirstName: "DiscussionOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
subscriber, err := users.Create(ctx, domain.User{AccessHash: 952, Phone: "+1952" + suffix + "02", FirstName: "DiscussionSubscriber"})
if err != nil {
t.Fatalf("create subscriber: %v", err)
}
outsider, err := users.Create(ctx, domain.User{AccessHash: 953, Phone: "+1953" + suffix + "03", FirstName: "DiscussionOutsider"})
if err != nil {
t.Fatalf("create outsider: %v", err)
}
channels := NewChannelStore(pool,
WithChannelRowCache(NewChannelRowCache(32)),
WithChannelMemberCache(NewChannelMemberCache(64)))
var channelIDs []int64
t.Cleanup(func() {
if len(channelIDs) != 0 {
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs)
}
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, subscriber.ID, outsider.ID})
})
broadcast, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "Peer Dialog Source " + suffix, Broadcast: true, Date: 1700009500,
})
if err != nil {
t.Fatalf("create broadcast: %v", err)
}
channelIDs = append(channelIDs, broadcast.Channel.ID)
group, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "Peer Dialog Group " + suffix, Megagroup: true, Date: 1700009501,
})
if err != nil {
t.Fatalf("create discussion group: %v", err)
}
channelIDs = append(channelIDs, group.Channel.ID)
if _, err := channels.SetDiscussionGroup(ctx, owner.ID, broadcast.Channel.ID, group.Channel.ID); err != nil {
t.Fatalf("set discussion group: %v", err)
}
if _, err := channels.InviteToChannel(ctx, broadcast.Channel.ID, owner.ID, []int64{subscriber.ID}, 1700009502); err != nil {
t.Fatalf("invite broadcast subscriber: %v", err)
}
post, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: broadcast.Channel.ID, RandomID: 95101, Message: "peer dialog root", Date: 1700009503,
})
if err != nil || post.Discussion == nil {
t.Fatalf("send linked post = %+v err %v", post, err)
}
views, err := channels.GetChannels(ctx, subscriber.ID, []int64{group.Channel.ID})
if err != nil || len(views) != 1 {
t.Fatalf("batch linked guest views = %+v err %v, want one", views, err)
}
view := views[0]
if !view.Self.Guest || view.Self.Status != domain.ChannelMemberLeft || view.Dialog.TopMessageID == 0 || view.Channel.Pts == 0 {
t.Fatalf("batch linked guest view = %+v dialog=%+v channel=%+v", view.Self, view.Dialog, view.Channel)
}
dialogs := appdialogs.NewService(nil, channels)
peerDialogs, err := dialogs.GetPeerDialogs(ctx, subscriber.ID, []domain.Peer{{Type: domain.PeerTypeChannel, ID: group.Channel.ID}})
if err != nil {
t.Fatalf("get linked guest peer dialogs: %v", err)
}
if len(peerDialogs.Dialogs) != 1 || len(peerDialogs.Channels) != 1 || len(peerDialogs.ChannelMessages) == 0 {
t.Fatalf("linked guest peer dialogs = %+v, want transient dialog/channel/top message", peerDialogs)
}
if peerDialogs.Dialogs[0].TopMessage == 0 || peerDialogs.Dialogs[0].Pts != view.Channel.Pts {
t.Fatalf("linked guest dialog = %+v, want top message and pts %d", peerDialogs.Dialogs[0], view.Channel.Pts)
}
directReplies, err := channels.ListChannelReplies(ctx, subscriber.ID, domain.ChannelRepliesFilter{
ChannelID: group.Channel.ID, RootMessageID: post.Discussion.Message.ID, Limit: 20,
})
if err != nil || !directReplies.Self.Guest || directReplies.Self.Status != domain.ChannelMemberLeft || directReplies.Channel.ID != group.Channel.ID {
t.Fatalf("direct linked guest replies = %+v err %v, want guest self for group", directReplies, err)
}
viaBroadcastReplies, err := channels.ListChannelReplies(ctx, subscriber.ID, domain.ChannelRepliesFilter{
ChannelID: broadcast.Channel.ID, RootMessageID: post.Message.ID, Limit: 20,
})
if err != nil || !viaBroadcastReplies.Self.Guest || viaBroadcastReplies.Self.Status != domain.ChannelMemberLeft || viaBroadcastReplies.Channel.ID != group.Channel.ID {
t.Fatalf("broadcast linked guest replies = %+v err %v, want guest self for target group", viaBroadcastReplies, err)
}
outsiderViews, err := channels.GetChannels(ctx, outsider.ID, []int64{group.Channel.ID})
if err != nil || len(outsiderViews) != 0 {
t.Fatalf("private discussion outsider views = %+v err %v, want empty", outsiderViews, err)
}
outsiderDialogs, err := dialogs.GetPeerDialogs(ctx, outsider.ID, []domain.Peer{{Type: domain.PeerTypeChannel, ID: group.Channel.ID}})
if err != nil || len(outsiderDialogs.Dialogs) != 0 {
t.Fatalf("private discussion outsider dialogs = %+v err %v, want empty", outsiderDialogs, err)
}
if _, err := channels.InviteToChannel(ctx, broadcast.Channel.ID, owner.ID, []int64{outsider.ID}, 1700009504); err != nil {
t.Fatalf("invite second broadcast subscriber: %v", err)
}
if _, err := channels.EditChannelBanned(ctx, domain.EditChannelBannedRequest{
UserID: owner.ID,
ChannelID: group.Channel.ID,
Participant: domain.Peer{Type: domain.PeerTypeUser, ID: outsider.ID},
BannedRights: domain.ChannelBannedRights{
ViewMessages: true,
UntilDate: 2147483647,
},
Date: 1700009505,
}); err != nil {
t.Fatalf("ban linked subscriber from target group: %v", err)
}
bannedViews, err := channels.GetChannels(ctx, outsider.ID, []int64{group.Channel.ID})
if err != nil || len(bannedViews) != 1 || !bannedViews[0].Forbidden || bannedViews[0].Self.Guest {
t.Fatalf("target-banned linked subscriber views = %+v err %v, want forbidden non-guest", bannedViews, err)
}
bannedDialogs, err := dialogs.GetPeerDialogs(ctx, outsider.ID, []domain.Peer{{Type: domain.PeerTypeChannel, ID: group.Channel.ID}})
if !errors.Is(err, domain.ErrChannelUserBanned) || len(bannedDialogs.Dialogs) != 0 {
t.Fatalf("target-banned linked subscriber dialogs = %+v err %v, want ErrChannelUserBanned without dialog", bannedDialogs, err)
}
if _, err := channels.ListChannelReplies(ctx, outsider.ID, domain.ChannelRepliesFilter{
ChannelID: group.Channel.ID, RootMessageID: post.Discussion.Message.ID, Limit: 20,
}); !errors.Is(err, domain.ErrChannelUserBanned) {
t.Fatalf("target-banned direct replies err = %v, want ErrChannelUserBanned", err)
}
if _, err := channels.ListChannelReplies(ctx, outsider.ID, domain.ChannelRepliesFilter{
ChannelID: broadcast.Channel.ID, RootMessageID: post.Message.ID, Limit: 20,
}); !errors.Is(err, domain.ErrChannelUserBanned) {
t.Fatalf("target-banned broadcast replies err = %v, want ErrChannelUserBanned", err)
}
var memberExists, dialogExists bool
if err := pool.QueryRow(ctx, `SELECT EXISTS (
SELECT 1 FROM channel_members WHERE channel_id = $1 AND user_id = $2
)`, group.Channel.ID, subscriber.ID).Scan(&memberExists); err != nil {
t.Fatalf("check transient guest member: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT EXISTS (
SELECT 1 FROM channel_dialogs WHERE channel_id = $1 AND user_id = $2
)`, group.Channel.ID, subscriber.ID).Scan(&dialogExists); err != nil {
t.Fatalf("check transient guest dialog: %v", err)
}
if memberExists || dialogExists {
t.Fatalf("transient guest persisted state: member=%v dialog=%v", memberExists, dialogExists)
}
}

View file

@ -595,28 +595,27 @@ func (s *ChannelStore) ListChannelReplies(ctx context.Context, viewerUserID int6
return domain.ChannelHistory{}, domain.ErrMessageIDInvalid return domain.ChannelHistory{}, domain.ErrMessageIDInvalid
} }
target := source target := source
availableMinID := member.AvailableMinID targetMember := member
availableMinID := targetMember.AvailableMinID
extraChannels := []domain.Channel(nil) extraChannels := []domain.Channel(nil)
rootID := root.ID rootID := root.ID
if source.Broadcast { if source.Broadcast {
if root.Discussion == nil || root.Discussion.ChannelID == 0 || root.Discussion.MessageID == 0 { if root.Discussion == nil || root.Discussion.ChannelID == 0 || root.Discussion.MessageID == 0 {
return domain.ChannelHistory{Channel: source}, nil return domain.ChannelHistory{Channel: source, Self: member}, nil
} }
linked, err := getChannelByID(ctx, s.db, root.Discussion.ChannelID) linked, linkedMember, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, root.Discussion.ChannelID)
if err != nil { if err != nil {
return domain.ChannelHistory{Channel: source}, nil return domain.ChannelHistory{}, err
} }
target = linked target = linked
targetMember = linkedMember
rootID = root.Discussion.MessageID rootID = root.Discussion.MessageID
availableMinID = 0 availableMinID = targetMember.AvailableMinID
if linkedMember, err := s.getChannelMember(ctx, s.db, linked.ID, viewerUserID); err == nil && validateChannelMemberVisible(linkedMember) == nil {
availableMinID = linkedMember.AvailableMinID
}
extraChannels = append(extraChannels, source) extraChannels = append(extraChannels, source)
} }
targetRoot, err := s.getChannelMessage(ctx, s.db, target.ID, rootID) targetRoot, err := s.getChannelMessage(ctx, s.db, target.ID, rootID)
if err != nil || targetRoot.Deleted || targetRoot.ID <= availableMinID { if err != nil || targetRoot.Deleted || targetRoot.ID <= availableMinID {
return domain.ChannelHistory{Channel: target, Channels: extraChannels}, nil return domain.ChannelHistory{Channel: target, Self: targetMember, Channels: extraChannels}, nil
} }
limit := filter.Limit limit := filter.Limit
if limit <= 0 || limit > domain.MaxChannelRepliesLimit { if limit <= 0 || limit > domain.MaxChannelRepliesLimit {
@ -646,7 +645,7 @@ func (s *ChannelStore) ListChannelReplies(ctx context.Context, viewerUserID int6
return domain.ChannelHistory{}, err return domain.ChannelHistory{}, err
} }
} }
return domain.ChannelHistory{Channel: target, Channels: extraChannels, Topics: topics, Messages: messages, Count: count}, nil return domain.ChannelHistory{Channel: target, Self: targetMember, Channels: extraChannels, Topics: topics, Messages: messages, Count: count}, nil
} }
func (s *ChannelStore) getForumTopic(ctx context.Context, db sqlcgen.DBTX, channelID int64, topicID int) (domain.ChannelForumTopic, error) { func (s *ChannelStore) getForumTopic(ctx context.Context, db sqlcgen.DBTX, channelID int64, topicID int) (domain.ChannelForumTopic, error) {