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

@ -25,13 +25,31 @@ type outboundWriter interface {
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 {
transport transport.Conn
writer outboundWriter
cipher crypto.Cipher
msgID *proto.MessageIDGen
writeTimeout time.Duration
metrics Metrics
transport transport.Conn
// transportLease owns exactly one generation of the physical transport.
// It is nil only for directly constructed test/embedded Conns that retain
// the legacy raw-transport close fallback.
transportLease *physicalTransportLease
writer outboundWriter
cipher crypto.Cipher
msgID *proto.MessageIDGen
writeTimeout time.Duration
metrics Metrics
authKeyID [8]byte
// authKeyHex 是 authKeyID 的 hex 缓存:每条 RPC 的结构化日志都会带它,
@ -66,7 +84,11 @@ type Conn struct {
outboundScratchOnce sync.Once
// terminal 表示该 logical Conn 已停止接受新的出站操作。写失败时由
// 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
rpcScheduler *inboundRPCScheduler
@ -130,6 +152,63 @@ type Conn struct {
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不降级
func (c *Conn) ClientLayer() int {
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"
"io"
"math"
"sync"
"sync/atomic"
"time"
@ -19,6 +20,7 @@ import (
"github.com/gotd/td/crypto"
"github.com/gotd/td/mt"
"github.com/gotd/td/proto"
"github.com/gotd/td/proto/codec"
"github.com/gotd/td/tgerr"
"github.com/gotd/td/transport"
@ -30,11 +32,16 @@ import (
// connState 是单连接的 MTProto 运行态。
type connState struct {
sentCreated bool
seen map[int64]clientMsgRecord // 已处理的 client msg_id用于幂等和 msgs_state_req
order []int64
minSeen int64
maxSeen int64
// createdFloor is the smallest client msg_id covered by the latest
// new_session_created notification for this server-side session generation.
// It only moves down: official clients resend every request below first_msg_id,
// so advertising an outer container id while accepting smaller inner ids would
// 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 高水位,
// 供 validateSeq 的 O(1) 快路径使用(客户端正常发送严格递增)。二者只增不减、
// 不随 seen 淘汰回退——快路径只接受「全扫描也必然接受」的子集,其余回落全扫描。
@ -46,6 +53,9 @@ type clientMsgRecord struct {
state byte
seqNo int32
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 {
@ -76,11 +86,11 @@ const (
// MTProto service vectors operate on bounded connection tracking tables. Accepting more IDs
// only burns decode/CPU and cannot improve the result.
maxServiceMessageIDs = 4096
// A decoded container descriptor is 48 bytes on 64-bit Go today. Charge 64 bytes per entry
// before allocating the exact-size slice so allocator rounding and future field growth remain
// inside the process-wide inbound budget. Message bodies stay as zero-copy views of the already
// charged plaintext frame/gzip expansion.
containerDescriptorBudgetBytes = 64
// Charge each container entry for the decoded proto.Message view plus the staged connState,
// action and ACK descriptors retained by the single-pass inbound plan. Bodies remain zero-copy
// views of the already charged plaintext frame/gzip expansion; RPC copies have a separate batch
// admission budget.
containerDescriptorBudgetBytes = 192
msgStateUnknown byte = 1
msgStateNotReceived byte = 2
@ -97,11 +107,14 @@ const (
badMsgContainer = 64
)
var errActivationAuthKeyRejected = errors.New("activation auth key no longer exists")
// handleEncrypted 解密加密消息,按需注册连接,处理服务消息并分发明文 payload。
// 返回(可能新建/更新的)当前连接对象,供 serveConn 维护生命周期。
// fetchedKey 非 nil 表示本帧的 auth key 是刚从 AuthKeyStore 查出的(首帧/换 auth key/被销毁
// 后回落);为 nil 表示走快路径——serveConn 判定 current 仍持同一未销毁的 auth key直接复用
// current.key/current.salt 解密,既不回查 AuthKeyStore 也不重建 store.AuthKeyData。
// 后回落);为 nil 表示走连接缓存快路径——serveConn 判定 current 仍持同一未销毁的 auth key
// 直接复用 current.key/current.salt 解密。任何 provisional 在 claim 建立后、发 required
// control 前都会最终回查 AuthKeyStore使外部撤销与 activation 线性化。
// 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) {
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)
}
if frame.salt != serverSalt {
c := current
temp := false
if c == nil || c.sessionID != frame.sessionID || c.authKeyID != key.ID {
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。
// 首个加密消息(即使 salt 尚未修正)或 session 变化时创建并保留唯一的
// provisional Conn。同一物理 transport 换 session 必须先不可逆地 fence/drain
// 旧 writer再把物理 lease 原子转交给新 generation。为每个 bad_server_salt
// 临时创建 Conn 会在同一 socket 上启动多个 outbound actorAndroid 的启动重试
// 风暴随即变成并发写和重复结果放大。
if current == nil || current.sessionID != frame.sessionID || current.authKeyID != key.ID {
if current != nil {
cs.reset()
}
if current != nil {
current.beginTerminalShutdown()
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若等到
// 首条 RPC 的 Dispatch 返回后才刷新,重连老客户端在首条 RPC handler 执行期间
// 收到的 pending flush / 并发 push 会漏降级。进程内重连时 rpc 层留有
@ -153,68 +163,120 @@ func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *con
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
typeID, err := (&bin.Buffer{Buf: body}).PeekID()
if err != nil {
return current, fmt.Errorf("peek encrypted payload type id: %w", err)
}
if code := validateClientEnvelope(s.clock.Now(), frame.messageID, frame.seqNo, typeID); code != 0 {
s.log.Debug("Sending bad_msg_notification",
zap.Int64("msg_id", frame.messageID),
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)
plan, err := s.preflightInbound(cs, frame.messageID, frame.seqNo, body)
if err != nil {
var bad *dispatchBadMsgError
if errors.As(err, &bad) {
s.log.Debug("Sending bad_msg_notification",
zap.Int64("msg_id", bad.msgID),
zap.Int32("seq_no", bad.seqNo),
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
}
content := clientMessageNeedsAck(typeID)
if record, ok := cs.seenRecord(frame.messageID); ok {
s.log.Debug("Duplicate msg_id; replay cached result if available", zap.Int64("msg_id", frame.messageID))
if err := s.replayRPCResultByRequest(ctx, current, frame.messageID); err != nil {
moveCreatedFloor := cs.createdFloor == 0 || plan.logicalMin < cs.createdFloor
claimPending := false
if current.lifecycleState() == connLifecycleProvisional {
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
}
if !record.content {
return current, nil
claimPending = true
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 {
s.log.Debug("Sending bad_msg_notification",
zap.Int64("msg_id", frame.messageID),
if moveCreatedFloor {
s.log.Debug("Sending new_session_created",
zap.Int64("first_msg_id", plan.logicalMin),
zap.Int64("outer_msg_id", frame.messageID),
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)
}
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 {
if err := s.sendNewSessionCreated(ctx, current, plan.logicalMin); err != nil {
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.dispatch(ctx, cs, current, frame.messageID, frame.seqNo, &bin.Buffer{Buf: body}, &acks); err != nil {
if err := s.executeInboundPlan(ctx, cs, current, plan); err != nil {
return current, err
}
if len(acks) > 0 {
if err := s.sendAck(ctx, current, acks...); err != nil {
if err := plan.commitRPCBatch(); err != nil {
return current, err
}
if len(plan.ackIDs) > 0 {
if err := s.sendAck(ctx, current, plan.ackIDs...); err != nil {
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
}
@ -284,236 +346,37 @@ func clientQuickAckToken(key crypto.AuthKey, plaintext []byte) uint32 {
// dispatch 处理一条明文消息:解包 container/gzip处理服务消息其余转 RPC 路由。
// 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 {
expanded := 0
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()
plan, err := s.preflightInbound(cs, msgID, seqNo, b.Buf)
if err != nil {
return fmt.Errorf("peek type id: %w", err)
}
ackContent := func() {
if clientMessageNeedsAck(id) {
*acks = append(*acks, msgID)
}
var bad *dispatchBadMsgError
if errors.As(err, &bad) && c != nil {
return s.sendBadMsg(ctx, c, bad.msgID, bad.seqNo, bad.code)
}
return err
}
defer plan.close()
plan.commitState(cs)
*acks = append(*acks, plan.ackIDs...)
return s.executeInboundPlan(ctx, cs, c, plan)
}
switch id {
case proto.GZIPTypeID:
data, releaseExpansion, err := s.decodeGZIPWithGlobalBudget(b)
if err != nil {
return fmt.Errorf("decode gzip: %w", err)
}
defer releaseExpansion()
*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)
// dispatchBadMsgError carries a protocol-level rejection discovered during the
// side-effect-free wrapper/container preflight. The caller emits the single
// bad_msg_notification only after the whole container has been inspected.
type dispatchBadMsgError struct {
msgID int64
seqNo int32
code int
}
case proto.MessageContainerTypeID:
if budget.containerDepth != 0 {
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)
}
func (e *dispatchBadMsgError) Error() string {
return fmt.Sprintf("bad client message %d/%d: code %d", e.msgID, e.seqNo, e.code)
}
// decodeGZIPWithGlobalBudget reserves the maximum single-wrapper output before
// 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
// an unaccounted 10 MiB expansion before the shared RPC scheduler saw the body.
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 {
return nil, fmt.Errorf("unexpected gzip constructor %#x", binary.LittleEndian.Uint32(b.Buf[:4]))
}
payload, _, err := tlBytesView(b.Buf[4:], -1)
return payload, err
payload, consumed, err := tlBytesView(b.Buf[4:], -1)
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.
@ -604,9 +473,9 @@ func tlBytesView(raw []byte, maxPayload int) ([]byte, int, error) {
}
// 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;
// enqueueRPC takes its own budgeted copy before returning. Only the exact-size descriptor slice
// is new memory, and that allocation is reserved globally first.
// copies. Bodies stay as immutable views through the single-pass inbound plan; batch admission
// takes independent RPC copies before the backing frame/expansion is released. The descriptor
// 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) {
release := func() {}
if b == nil || len(b.Buf) < 8 {
@ -663,6 +532,10 @@ func (s *Server) decodeMessageContainerViews(b *bin.Buffer, count int) (proto.Me
}
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
}
@ -673,10 +546,13 @@ func msgsStateInfoView(b *bin.Buffer) (int64, []byte, error) {
if got := binary.LittleEndian.Uint32(b.Buf[:4]); got != mt.MsgsStateInfoTypeID {
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 {
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
}
@ -691,10 +567,13 @@ func msgsAllInfoView(b *bin.Buffer) (int, []byte, error) {
return 0, nil, io.ErrUnexpectedEOF
}
offset := 12 + count*8
info, _, err := tlBytesView(b.Buf[offset:], maxServiceMessageIDs)
info, consumed, err := tlBytesView(b.Buf[offset:], maxServiceMessageIDs)
if err != nil {
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
}
@ -750,15 +629,41 @@ func mergeStateInfo(primary, fallback []byte) []byte {
// (已 PeekID 过一次method 只解析一次并随任务透传,避免同一请求三处重复 PeekID/typeName。
func (s *Server) enqueueRPC(ctx context.Context, c *Conn, msgID int64, typeID uint32, request *bin.Buffer) error {
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",
zap.String("method", method),
zap.Int64("msg_id", msgID),
zap.String("auth_key_id", c.authKeyHex),
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对抗客户端不能用大量满尺寸请求在“判断队列满”
// 之前制造一轮无上限的临时 body 分配。reservation 在 commit/abort 间唯一持有预算。
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()
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() {
if !responseGate.tryTimeout() {
return
}
defer responseGate.finish()
// 原 task context 已到期,使用有界的新 context 回显明确的可重试超时;
// 500 保持 TDesktop 默认重试语义,错误名区分于容量型 FLOOD_WAIT。
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,
size: len(body),
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 {
// body 是预算成功后生成的独立副本,且每个任务只 run 一次,
// 无需再 append 拷贝;直接复用,省掉一份 inbound 在途内存。
@ -816,8 +750,7 @@ func (s *Server) enqueueRPC(ctx context.Context, c *Conn, msgID int64, typeID ui
}
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 {
@ -839,8 +772,18 @@ func (s *Server) handleInboundRPCAdmissionError(ctx context.Context, c *Conn, ms
// handleRPC 把明文 RPC 请求交给 RPC 路由,并将结果或错误包成 rpc_result 回发。
func (s *Server) handleRPC(ctx context.Context, c *Conn, msgID int64, method string, b *bin.Buffer, responseGate *rpcResponseGate) error {
if s.rpc == nil {
s.log.Warn("No RPC handler configured; dropping request", zap.String("method", method))
return nil
if err := ctx.Err(); err != 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)
@ -874,10 +817,48 @@ func (s *Server) handleRPC(ctx context.Context, c *Conn, msgID int64, method str
fields = dbtrace.AppendZapFields(fields, "", dbStats.Snapshot())
if ctxErr := ctx.Err(); ctxErr != nil {
// A canceled request context means neither a success nor an error can be delivered
// with this expired context. In particular, do not cache a late successful result and
// hand it to outbound: a past write deadline would correctly poison that transport and
// could prevent the scheduler's fresh-context RPC_TIMEOUT response from being sent.
// The old physical Conn may have been replaced after the business transaction
// committed. Never write with the expired context itself: a fenced generation
// publishes cache-only for its replacement, while a still-live generation uses
// 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))
if err != nil {
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...)
return context.DeadlineExceeded
}
defer responseGate.finish()
if err != nil {
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.
type rpcResponseGate struct {
state atomic.Uint32
done chan struct{}
once sync.Once
}
func newRPCResponseGate() *rpcResponseGate {
return &rpcResponseGate{done: make(chan struct{})}
}
func (g *rpcResponseGate) tryNormal() bool {
@ -931,14 +919,84 @@ func (g *rpcResponseGate) tryTimeout() bool {
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 并加密回发。
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)
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
}
// 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)
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
@ -986,13 +1044,14 @@ func (s *Server) replayRPCResultByRequest(ctx context.Context, c *Conn, reqMsgID
return nil
}
if resent, err := c.ResendByRequest(ctx, reqMsgID); err != nil {
c.fenceUndeliveredRPCResult()
return err
} else if resent {
s.log.Debug("Resent connection cached rpc_result for duplicate msg_id", zap.Int64("msg_id", reqMsgID))
return nil
}
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
}
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 被吞掉,错过的差分补拉
// Android 收到后才调 getDifference随之丢失。
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,
UniqueID: s.newServerSessionUID(),
ServerSalt: c.salt,
@ -1098,7 +1160,7 @@ func (s *Server) sendBadMsg(ctx context.Context, c *Conn, badMsgID int64, badSeq
// sendBadServerSalt 通知客户端修正 server_salterror_code 48
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,
BadMsgSeqno: int(badSeqno),
ErrorCode: 48,
@ -1138,25 +1200,6 @@ func validateClientEnvelope(now time.Time, msgID int64, seqNo int32, typeID uint
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 {
if msgID == 0 || proto.MessageID(msgID).Type() != proto.MessageFromClient {
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) {
cs.trackInbound(msgID, seqNo, content, false, state)
}
func (cs *connState) trackInbound(msgID int64, seqNo int32, content, service bool, state byte) {
cs.seen[msgID] = clientMsgRecord{
state: state,
seqNo: seqNo,
content: content,
service: service,
}
if content {
if msgID > cs.maxContentMsgID {

View file

@ -40,25 +40,72 @@ func TestEncryptedPingPong(t *testing.T) {
}
}
// TestDuplicateMsgIDIdempotent 验证 M4相同 msg_id 的重复 content 请求被幂等处理,
// server 重发已缓存的 rpc_result并重新 ack不重复执行业务。
// TestDuplicateMsgIDIdempotent 验证相同物理连接上的重复 content 请求只重新 ACK
// 原 owner 仍是唯一 rpc_result 发送者。若每次重复都重放完整结果Android 的
// bad_server_salt 全量重试会把一个启动批次放大成 N 轮孤儿结果并饿死新 request id。
func TestDuplicateMsgIDIdempotent(t *testing.T) {
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)
clientMsgID := proto.NewMessageIDGen(time.Now)
msgID := clientMsgID.New(proto.MessageFromClient)
sendEncrypted(t, conn, cipher, auth, msgID, &mt.RPCDropAnswerRequest{ReqMsgID: msgID - 4})
first := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID)
mustHave(t, first, proto.ResultTypeID, "first rpc_result")
sendEncrypted(t, conn, cipher, auth, msgID, &tg.HelpGetConfigRequest{})
collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{
proto.ResultTypeID: 1,
mt.MsgsAckTypeID: 1,
})
waitForAtomicCalls(t, &handler.calls, 1)
// 相同 msg_id —— 幂等:重发已有 rpc_result并重新 ack。
sendEncrypted(t, conn, cipher, auth, msgID, &mt.RPCDropAnswerRequest{ReqMsgID: msgID - 4})
second := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID)
mustHave(t, second, proto.ResultTypeID, "resent rpc_result")
mustHave(t, second, mt.MsgsAckTypeID, "second ack")
// 用一个小型重试风暴覆盖完成后的 duplicate 路径。TCP 仍存活时原结果已在同一
// 可靠字节流上;每个 duplicate 只需 ACK不应产生第二个 rpc_result。
const duplicateCount = 16
for i := 0; i < duplicateCount; i++ {
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 由连接层直接响应,
@ -268,6 +315,14 @@ func TestOldMessageInFreshContainerAccepted(t *testing.T) {
})
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")
var pong mt.Pong
if err := pong.Decode(buf); err != nil {
@ -382,8 +437,10 @@ func TestBadServerSalt(t *testing.T) {
wrongSalt := auth.ServerSalt + 1
sendEncryptedWithSalt(t, conn, cipher, auth, wrongSalt, reqMsgID, &mt.PingRequest{PingID: 1})
replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.BadServerSaltTypeID)
buf := mustHave(t, replies, mt.BadServerSaltTypeID, "bad_server_salt")
envelope, typeID, buf := readServerMessage(t, conn, cipher, auth.AuthKey)
if typeID != mt.BadServerSaltTypeID {
t.Fatalf("bad salt reply type = %#x, want %#x", typeID, mt.BadServerSaltTypeID)
}
var bad mt.BadServerSalt
if err := bad.Decode(buf); err != nil {
@ -398,6 +455,11 @@ func TestBadServerSalt(t *testing.T) {
if 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) {
@ -470,7 +532,7 @@ func TestBadMsgSeqTooHigh(t *testing.T) {
func TestSessionChangeResetsClientSeqState(t *testing.T) {
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)
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.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 {

View file

@ -190,12 +190,82 @@ func collectReplies(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key
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) {
t.Helper()
var buf bin.Buffer
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)
cancel()
if err != nil {
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
run func(context.Context) error
onTimeout func()
budget *inboundRPCGlobalReservation
ticket *inboundRPCTicket
// release drops request-scoped ownership which is independent from the body
// 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 (
@ -76,7 +81,7 @@ type inboundRPCScheduler struct {
type inboundRPCGlobalReservation struct {
scheduler *inboundRPCScheduler
size int64
once sync.Once
released atomic.Bool
}
// inboundRPCReservation 同时持有全局和单连接的“Copy 前”预算。commit/abort 只能成功一次;
@ -92,6 +97,32 @@ type inboundRPCReservation struct {
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 {
if workers <= 0 {
workers = 1
@ -196,17 +227,89 @@ func (s *inboundRPCScheduler) reserveGlobal(size int) (*inboundRPCGlobalReservat
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() {
if r == nil || r.scheduler == nil {
return
}
r.once.Do(func() {
s := r.scheduler
s.budgetMu.Lock()
s.tasks--
s.bytes -= r.size
s.budgetMu.Unlock()
})
if !r.released.CompareAndSwap(false, true) {
return
}
s := r.scheduler
s.budgetMu.Lock()
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) {
@ -363,6 +466,10 @@ func (c *Conn) reserveInboundRPC(ctx context.Context, method string, size int) (
return nil, ctx.Err()
default:
}
if c.terminal.Load() {
c.metrics.InboundRPCDropped(method, "scheduler_closed")
return nil, ErrConnClosed
}
if c.rpcScheduler == nil {
c.metrics.InboundRPCDropped(method, "scheduler_closed")
return nil, ErrConnClosed
@ -392,7 +499,7 @@ func (c *Conn) reserveInboundRPC(ctx context.Context, method string, size int) (
c.metrics.InboundRPCDropped(method, "context_done")
return nil, err
}
if c.rpcClosed {
if c.rpcClosed || c.terminal.Load() {
c.rpcMu.Unlock()
global.release()
c.metrics.InboundRPCDropped(method, "scheduler_closed")
@ -427,6 +534,102 @@ func (c *Conn) reserveInboundRPC(ctx context.Context, method string, size int) (
}, 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 的便捷入口。生产收包路径使用
// reserveInboundRPC -> Copy -> commit保证真正的 Copy 前预算。
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.rpcMu.Lock()
c.rpcReserved--
if c.rpcClosed {
if c.rpcClosed || c.terminal.Load() {
c.inflightRPCBytes.Add(-int64(r.size))
} else {
// 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) {
c.rpcMu.Lock()
defer c.rpcMu.Unlock()
@ -583,7 +923,7 @@ func (c *Conn) finishInboundRPC(task inboundRPC) {
if task.ticket != nil {
task.ticket.state.Store(inboundRPCTicketDone)
}
stopInboundRPCTask(task)
timeoutHandoff := stopInboundRPCTask(task)
var reschedule bool
c.rpcMu.Lock()
c.rpcRunning--
@ -594,11 +934,21 @@ func (c *Conn) finishInboundRPC(task inboundRPC) {
}
c.rpcMu.Unlock()
reservation := task.budget
release := task.release
// 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
// with a newly admitted body under the same byte accounting.
task = inboundRPC{}
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()
if reschedule {
c.rpcScheduler.schedule(c)
@ -648,7 +998,8 @@ func (c *Conn) expireInboundRPCTicket(ticket *inboundRPCTicket) {
if found {
method := task.method
reservation := task.budget
stopInboundRPCTask(task)
release := task.release
_ = stopInboundRPCTask(task)
// 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
// 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 {
ticket.onTimeout()
}
if release != nil {
release()
}
return
}
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
// completion or connection close cannot manufacture an RPC_TIMEOUT response.
// A deadline callback already in flight is harmless because enqueueRPC's response
// gate makes timeout and normal rpc_result mutually exclusive.
func stopInboundRPCTask(task inboundRPC) {
// If the runtime already started a deadline callback, the returned sync.Once
// wrapper is a mandatory handoff: callers invoke it before owner release so a
// 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 {
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 {
task.stopRoot()
@ -679,6 +1039,7 @@ func stopInboundRPCTask(task inboundRPC) {
if task.cancel != nil {
task.cancel()
}
return timeoutHandoff
}
func (c *Conn) closeInboundRPCScheduler() {
@ -722,9 +1083,16 @@ func (c *Conn) beginCloseInboundRPCScheduler() {
}
method := task.method
reservation := task.budget
stopInboundRPCTask(task)
release := task.release
timeoutHandoff := stopInboundRPCTask(task)
task = inboundRPC{}
reservation.release()
if timeoutHandoff != nil {
timeoutHandoff()
}
if release != nil {
release()
}
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 (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"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 {
c := &Conn{metrics: NopMetrics{}}
c.startInboundRPCScheduler(s, maxInflight, queueSize, timeout)

View file

@ -61,8 +61,8 @@ func TestMetricsHooks(t *testing.T) {
if got := m.inbound.Load(); got != 1 {
t.Errorf("InboundRPCStarted called %d times, want 1", got)
}
// new_session_created / ack 走 fire-and-forget异步可能在 client 收到 rpc_result 后
// 才被 outbound actor 处理轮询等其最终发送完成。M5 验证发送计数,不约束同步时序
// new_session_created 已经跨过 required-control 物理写屏障msgs_ack 仍是异步控制帧,
// 因而这里只轮询等待最终发送计数。具体 boundary→RPC 顺序由 session boundary 测试锁定
deadline := time.Now().Add(2 * time.Second)
for m.outbound.Load() < 3 && time.Now().Before(deadline) {
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)
}
}
// 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
defaultOutboundTrackedMaxBytes = int64(512 << 20) // 512 MiB / 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
maxTrackedAckedMsgIDs = 1024
@ -332,6 +337,9 @@ func (c *Conn) Close() {
// transport.Close call, so a timed-out batch close cannot keep accepting memory/work.
func (c *Conn) beginTerminalShutdown() {
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.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。
// 仅用于授权撤销 / destroy_auth_key 这类“必须让对端立即断线”的路径;普通生命周期仍由
// serveConn 统一关闭 transport避免正常 push/索引清理把长连接误伤成硬断。
@ -355,6 +380,13 @@ func (c *Conn) ForceClose() {
// closeTransport 只关闭物理 transport不等待 outbound actor。写失败路径运行在
// actor 自身 goroutine 中,若在这里调用 Close 会等待 outboundDone 而自锁。
func (c *Conn) closeTransport() {
if c == nil {
return
}
if c.transportLease != nil {
_ = c.transportLease.Close()
return
}
c.transportClose.Do(func() {
if c.transport != nil {
_ = c.transport.Close()
@ -372,6 +404,38 @@ func (c *Conn) failTransport() {
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
// actor调用方位于 fan-out 热路径,等待单个慢 socket 会把同一用户的健康设备和
// 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)
}
// 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 队列,不等待网络写完成。
// 用于 updates fanout队列拥塞时返回 ErrOutboundQueueFulldurable outbox/getDifference 负责兜底。
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:
return res.err
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()
case <-c.outboundStop:
select {
case res := <-op.done:
return res.err
default:
}
return ErrConnClosed
}
}
// 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——此时
// 连接多已严重拥塞,控制消息丢失由客户端重传 / 读写超时兜底。返回非 nil 仅表示连接已关闭。
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 {
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 {

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
}
// rpcResultCache 缓存已回发的 rpc_result按 auth_key+session+req_msg_id用于
// 跨连接重放重复请求。encodedOutboundMessage 构造后不可变push fan-out 与 pending
// resend 均依赖该契约),因此 Get/Put 直接共享指针,不做防御性拷贝。
// rpcResultCache 缓存已有交付证明的 rpc_result按 auth_key+session+req_msg_id
// 用于跨连接重放重复请求。Put 的调用方必须先证明结果已物理写出,或原 logical Conn
// 已不可逆 fenced绝不能发布“Conn 仍 current/open 但结果尚未上 wire”的完成态。
// encodedOutboundMessage 构造后不可变push fan-out 与 pending resend 均依赖该契约),
// 因此 Get/Put 直接共享指针,不做防御性拷贝。
type rpcResultCache struct {
shards [rpcResultCacheShards]rpcResultCacheShard
shards [rpcResultCacheShards]rpcResultCacheShard
flightLimit rpcResultFlightLimit
}
type rpcResultCacheShard struct {
@ -46,13 +49,25 @@ type rpcResultCacheShard struct {
bytes int
order *list.List
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 {
return newRPCResultCacheWithFlightLimit(now, rpcResultFlightDefaultMaxPending)
}
func newRPCResultCacheWithFlightLimit(now func() time.Time, maxPending int) *rpcResultCache {
if now == nil {
now = time.Now
}
if maxPending <= 0 {
maxPending = rpcResultFlightDefaultMaxPending
}
c := &rpcResultCache{}
c.flightLimit.max = int64(maxPending)
for i := range c.shards {
s := &c.shards[i]
s.now = now
@ -61,6 +76,7 @@ func newRPCResultCache(now func() time.Time) *rpcResultCache {
s.maxBytes = rpcResultCacheMaxBytes / rpcResultCacheShards
s.order = list.New()
s.byKey = make(map[rpcResultCacheKey]*list.Element)
s.pending = make(map[rpcResultCacheKey]*rpcResultFlight)
}
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}
s := c.shard(key)
size := len(encoded.body)
if s.maxBytes > 0 && size > s.maxBytes {
return
}
cacheable := s.maxBytes <= 0 || size <= s.maxBytes
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
s.expireLocked(now)
if elem, ok := s.byKey[key]; ok {
s.removeElement(elem)
if cacheable {
s.expireLocked(now)
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{
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()
// Resolve the independent in-flight entry only after the completed cache has
// been published. Waiters awakened by this close can therefore immediately
// observe either the shared encoded result or the completed Get entry.
c.completeRPCResultFlightLocked(s, key, encoded)
}
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) {
const dc = 2
handler := &canceledInternalRPC{
firstDone: make(chan struct{}),
firstStarted: make(chan struct{}),
firstDone: make(chan struct{}),
}
addr, pub, _ := startTestServer(t, Options{DC: dc, RPC: handler})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
@ -266,6 +267,11 @@ func TestCanceledRPCErrorIsNotCachedAcrossReconnect(t *testing.T) {
reqMsgID := clientMsgID.New(proto.MessageFromClient)
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()
select {
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 }
type canceledInternalRPC struct {
calls atomic.Int32
firstDone chan struct{}
calls atomic.Int32
firstStarted chan struct{}
firstDone chan struct{}
}
func (h *canceledInternalRPC) Dispatch(ctx context.Context, _ [8]byte, _ int64, _ *bin.Buffer) (bin.Encoder, error) {
if h.calls.Add(1) == 1 {
close(h.firstStarted)
<-ctx.Done()
close(h.firstDone)
return nil, tgerr.New(500, "INTERNAL_SERVER_ERROR")

View file

@ -172,7 +172,7 @@ func (o *Options) setDefaults() {
o.RPCGlobalWorkers = 256
}
if o.RPCGlobalMaxTasks <= 0 {
o.RPCGlobalMaxTasks = 8192
o.RPCGlobalMaxTasks = rpcResultFlightDefaultMaxPending
}
if o.RPCGlobalMaxBytes <= 0 {
o.RPCGlobalMaxBytes = 512 << 20
@ -295,7 +295,7 @@ func New(opts Options) *Server {
clock: opts.Clock,
rand: opts.Rand,
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),
}
}
@ -307,8 +307,33 @@ func (s *Server) Conns() *SessionManager {
// newConn 基于一次解密结果创建一个可发送的连接对象。
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{
transport: tc,
transportLease: lease,
writer: tc,
cipher: s.cipher,
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促使客户端重新握手。
//
// 连接建立 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.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
// not return that memory budget prematurely.
releaseInboundFrameOwnership(conn)
// 先同步关闭物理 socket解除可能阻塞在 writer.Send 的 outbound actor
// 再停止 logical Conn避免 Close 等 actor 时反过来等到 write deadline。
_ = conn.Close()
// Publish the terminal/RPC-cancel gates before index removal or lifecycle
// observers. Physical close then releases a writer already inside Send; the
// final Close only waits for the now-fenced actors to converge.
if current != nil {
current.beginTerminalShutdown()
}
_ = transportOwner.CloseAny()
if current != nil {
s.conns.Unregister(current)
current.Close()
@ -584,7 +614,7 @@ func (s *Server) serveConn(ctx context.Context, conn transport.Conn) (err error)
defer cancel()
go func() {
<-ctx.Done()
_ = conn.Close()
_ = transportOwner.CloseAny()
}()
cs := newConnState()
@ -601,7 +631,7 @@ func (s *Server) serveConn(ctx context.Context, conn transport.Conn) (err error)
// 建立 session 前current==nil握手 + 首个加密消息之前)用较短的 handshakeTimeout
// 快速回收静默的半开 / 异常连接;建立 session 后用 readTimeout客户端有 ping 心跳)。
timeout := s.readTimeout
if current == nil {
if current == nil || !current.isActive() {
timeout = s.handshakeTimeout
}
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 {
// 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()
if !admitted {
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——
// 这是 mtprotoedge 层最热的库访问点。密钥材料创建后不可变;销毁(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
if current == nil || current.authKeyID != authKeyID || current.keyDestroyed.Load() {
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)
}
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
}
// -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)
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 {
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 无法唯一定位连接。
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 (
maxPendingPushesPerSession = 32
// maxFlushAttempts / flushRetryBackoff排空暂存推送时 c.Send 失败(出站拥塞 5s 超时
@ -117,8 +122,13 @@ type SessionLifecycleObserver interface {
// 它管理运行态的在线连接,与持久化的 store.SessionStore 互补:后者记录 session 数据,
// 前者持有可发送的活跃连接。所有方法并发安全。
type SessionManager struct {
mu sync.RWMutex
bySession map[sessionKey]*Conn
mu sync.RWMutex
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 的唯一性检查
byAuthKey map[[8]byte]map[int64]*Conn // raw authKeyID → sessionID → Conn
byBusinessAuthKey map[[8]byte]map[sessionKey]*Conn
@ -142,6 +152,8 @@ func NewSessionManager(log *zap.Logger) *SessionManager {
}
return &SessionManager{
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),
byAuthKey: make(map[[8]byte]map[int64]*Conn),
byBusinessAuthKey: make(map[[8]byte]map[sessionKey]*Conn),
@ -164,32 +176,94 @@ func (m *SessionManager) SetLifecycleObserver(observer SessionLifecycleObserver)
m.mu.Unlock()
}
// Register 注册一个活跃连接。若同 raw auth_key_id + session_id 已存在(重连),旧连接被替换并移除索引。
func (m *SessionManager) Register(c *Conn) {
m.mu.Lock()
// BeginActivation atomically claims auth_key_id + session_id without publishing the
// new Conn. Under the manager lock it irreversibly fences every previous owner,
// 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)
var replaced *Conn
var evicted *Conn
if old, ok := m.bySession[key]; ok && old != c {
replaced = old
m.removeLocked(old, false)
} else if existing := m.byAuthKey[c.authKeyID]; len(existing) >= maxSessionsPerAuthKey {
// 同 raw auth_key 的 session 数达上限且本次是新 session驱逐建连最早的 session 让位,
// 防对抗客户端用海量 session_id 撑爆索引。驱逐对象与新连接同属一个设备凭据,
// 触顶基本是该凭据自身异常;选最旧而非 map 随机,避免误杀刚建立的活跃下载/主连接。
// 被驱逐连接的 serveConn 会在下一帧因 actor 已关而退出。O(cap) 扫描仅在触顶时发生。
for _, ec := range existing {
if evicted == nil || ec.createdAt.Before(evicted.createdAt) {
evicted = ec
}
retired := make([]*Conn, 0, 2)
m.mu.Lock()
if c.terminal.Load() || !c.isPhysicalTransportCurrentOpen() || c.lifecycleState() != connLifecycleClaiming {
c.beginTerminalShutdown()
m.mu.Unlock()
return ErrConnClosed
}
if oldClaim := m.claims[key]; oldClaim != nil && oldClaim != c {
m.retireClaimLocked(key, oldClaim, false)
retired = append(retired, oldClaim)
}
if old := m.bySession[key]; old != nil && old != c {
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)
m.log.Debug("Evicted oldest session for auth key at cap",
if isClaim {
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.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
addSessionIDIndex(m.bySessionID, c.sessionID, c.authKeyID, c)
addConnIndex(m.byAuthKey, c.authKeyID, c.sessionID, c)
@ -200,34 +274,88 @@ func (m *SessionManager) Register(c *Conn) {
c.userIDResolved.Store(true)
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.Int64("session_id", c.sessionID),
zap.Int("online", len(m.bySession)),
)
m.mu.Unlock()
return nil
}
// 同 identity 的新物理连接已经原子接管索引;立即关闭旧 transport不能只停
// actor 后让旧 FD/read goroutine 滞留到 read timeout。replacement 与 cap eviction
// 共用一个并发关闭批次,不能把每条 Conn 的 RPC 等待上界串行相加。
if replaced != nil || evicted != nil {
if !forceCloseConnBatch([]*Conn{replaced, evicted}, forceCloseBatchTimeout) {
m.log.Warn("Session replacement/eviction close exceeded shared deadline")
}
// AbortActivation removes a claim only when c still owns it, then terminally
// retires the provisional Conn. A superseded caller cannot delete the newer claim.
func (m *SessionManager) AbortActivation(c *Conn) {
if c == nil {
return
}
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 注销一个连接(仅当它仍是当前注册的同一对象,避免误删重连后的新连接)。
// 观察者对未登录连接userID=0也回调业务层据此清理按 session 维度的缓存条目,
// 否则未登录连接的元数据只能等容量上限驱逐。
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()
var (
observer SessionLifecycleObserver
offlineUser int64
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)
if offlineUser != 0 {
lastForUser = len(m.byUser[offlineUser]) == 0
@ -256,7 +384,7 @@ func (m *SessionManager) DestroySession(sessionID int64) bool {
m.mu.Unlock()
return false
}
offlineUser := m.removeLocked(c, true)
offlineUser := m.retireConnLocked(c, true)
lastForUser := offlineUser != 0 && len(m.byUser[offlineUser]) == 0
observer := m.lifecycle
m.log.Debug("Session destroyed",
@ -283,11 +411,22 @@ func (m *SessionManager) DestroySessionForAuthKey(authKeyID [8]byte, sessionID i
key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
c, ok := m.bySession[key]
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.mu.Unlock()
return false
}
offlineUser := m.removeLocked(c, true)
offlineUser := m.retireConnLocked(c, true)
lastForUser := offlineUser != 0 && len(m.byUser[offlineUser]) == 0
observer := m.lifecycle
m.log.Debug("Session destroyed",
@ -505,10 +644,17 @@ func (m *SessionManager) CloseSessionsForBusinessAuthKey(authKeyID [8]byte) int
if !connUsesBusinessAuthKey(c, authKeyID) {
continue
}
uid := m.removeLocked(c, true)
uid := m.retireConnLocked(c, true)
conns = append(conns, c)
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
if len(conns) > 0 {
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)
}
// CloseSessionsForRawAuthKeyExcept 强制断开指定 raw auth_key 的活跃连接,可排除
// 一个 sessiondestroy_auth_key 的发起连接:响应要先送达,它的密钥已删,下一帧
// 自然失效)。出站推送不回查密钥库,必须主动断开底层 transport 才能让销毁立即生效
// CloseSessionsForRawAuthKeyExcept 强制断开指定 raw auth_key 的活跃连接,可
// session ID 排除一个 session。该接口供业务层授权撤销使用wire-level
// destroy_auth_key 必须改用精确 Conn 排除,避免同 session replacement 被误放过
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 {
key sessionKey
userID int64
@ -544,14 +706,22 @@ func (m *SessionManager) CloseSessionsForRawAuthKeyExcept(authKeyID [8]byte, exc
var conns []*Conn
var events []offlineEvent
for sessionID, c := range m.byAuthKey[authKeyID] {
if sessionID == exceptSessionID {
if skip != nil && skip(sessionID, c) {
continue
}
key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
uid := m.removeLocked(c, true)
uid := m.retireConnLocked(c, true)
conns = append(conns, c)
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
m.mu.Unlock()
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
// removed so Register's replacement/eviction slots cannot close the same Conn twice.
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 {
return true
}
@ -640,7 +818,7 @@ func forceCloseConnBatch(conns []*Conn, timeout time.Duration) bool {
if remaining <= 0 {
return false
}
if c.rpcScheduler != nil && !c.waitInboundShutdown(remaining) {
if waitInbound && c.rpcScheduler != nil && !c.waitInboundShutdown(remaining) {
return false
}
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 {
key := connSessionKey(c)
if m.bySession[key] != c {
return 0
}
delete(m.bySession, key)
removeSessionIDIndex(m.bySessionID, c.sessionID, c.authKeyID)
removeConnIndex(m.byAuthKey, c.authKeyID, c.sessionID)
@ -1650,6 +1831,75 @@ func (m *SessionManager) removeLocked(c *Conn, dropPending bool) int64 {
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 {
out := make(map[sessionKey]*Conn, len(m.byBusinessAuthKey[authKeyID])+len(m.byAuthKey[authKeyID]))
for key, c := range m.byBusinessAuthKey[authKeyID] {

View file

@ -2,8 +2,12 @@ package mtprotoedge
import (
"context"
"encoding/binary"
"errors"
"io"
"strings"
"testing"
"time"
"github.com/gotd/td/bin"
"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) {
encoded := bin.Buffer{}
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) {
state := mt.MsgsStateInfo{ReqMsgID: 7, Info: make([]byte, maxServiceMessageIDs)}
var encodedState bin.Buffer
@ -142,10 +554,58 @@ func TestDispatchRejectsExcessiveWrapperDepthBeforeRPC(t *testing.T) {
s := New(Options{Logger: zaptest.NewLogger(t)})
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") {
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) {

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()
}