Ctrl K
ComunidadGuías…

internal/mtprotoedge/session_manager.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package mtprotoedge

import (
	"context"
	"errors"
	"fmt"
	"sort"
	"sync"
	"sync/atomic"
	"time"

	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"

	"github.com/iamxvbaba/td/proto"
	"github.com/iamxvbaba/td/tg"
	"github.com/iamxvbaba/td/tlprofile"
)

// ErrSessionNotFound means the target session currently has no active connection.
var ErrSessionNotFound = errors.New("session not found")

var (
	ErrSessionActivationSuperseded = errors.New("session activation superseded")
	ErrSessionActivationFence      = errors.New("session activation could not fence previous writer")
)

const (
	maxPendingPushesPerSession = 32
	// maxFlushAttempts / flushRetryBackoff: the bound on backoff retries after
	// c.Send fails while draining staged pushes (outbound congestion 5s timeout or
	// transient error). When the connection is truly dead serveConn Unregisters and
	// cleans up state, cutting losses early; here only bounded retries are done for
	// "connection alive but outbound temporarily congested". If retries are
	// exhausted and it still fails, the flag is set active and getDifference
	// fallback is accepted—avoiding an idle client (sending only pings, not
	// triggering a flag-set retry) being stuck inactive forever and silently losing
	// its stream.
	maxFlushAttempts  = 5
	flushRetryBackoff = 2 * time.Second
	// pendingPushMaxAge: when a session, after registration, takes too long to call
	// updates.getState (receivesUpdates stays false), this is the longest its staged
	// proactive pushes are retained. Beyond it the whole batch is dropped and no
	// more is hoarded—a normal TDesktop establishes the sync baseline with getState
	// within seconds after login; long-term not-ready is usually an aberrant/adversarial connection.
	//
	// Invariant: only durable updates (writing user_update_events) enter pending.
	// Transient updates (typing/presence, not writing the durable log) go through
	// PushToUserTransient* and are skipped directly when not ready, without
	// enqueuing, so when this queue is dropped by aging/overflow/exhausted retries
	// what is dropped is always durable entries—getDifference fills them in via
	// user_update_events, so dropping loses no data.
	pendingPushMaxAge          = 60 * time.Second
	defaultPendingPushMaxBytes = int64(256 << 20)
	// maxSessionsPerAuthKey: the cap on sessions allowed online simultaneously for a
	// single raw auth_key. telesrv is single-DC; all connections of one client (main
	// connection + concurrent downloads/uploads) share the same auth_key and each
	// uses an independent session_id, so this cap must exceed a real client's
	// per-device concurrent connection peak, otherwise it would wrongly kill active
	// downloads/the main connection:
	//   - TDesktop: kMaxMediaDcCount=0x10, at most 16 downloads + 16 uploads + 1 main ≈ 33 per DC;
	//   - DrKLO: DOWNLOAD_CONNECTIONS_COUNT=2 + UPLOAD_CONNECTIONS_COUNT=4 + main/push ≈ 10.
	// Adding reconnect churn (old sessions lingering within readTimeout) the peak is
	// about ~70, so 256 is set (~3.5x headroom). It only guards against the
	// pathology of "a single auth_key accumulating a huge number of connections"
	// (which would make CloseSessionsForRawAuthKey/pushToUser traversal degrade to
	// O(N)); over-limit eviction only drops connections of that same device's
	// credentials and does not harm other accounts.
	maxSessionsPerAuthKey = 256
	// maxChannelIndexPerSession: the cap on the number of channels a single session
	// registers in the channel routing indexes (interest / membership). membership
	// comes from real membership (a large account may have many) and interest is
	// directly controlled by the client; a loose upper bound is set for both to
	// prevent memory amplification, truncating and logging when exceeded.
	maxChannelIndexPerSession = 8192
	// Official clients short-poll at most ten opened channels per session. Keep the
	// server-side passive subscription index at the same hard bound.
	maxChannelSubscriptionsPerSession = 10
	defaultChannelSubscriptionTTL     = 75 * time.Second
	maxChannelSubscriptionTTL         = 2 * time.Minute
	// A claim is normally released by its rpc_result delivery callback or the
	// pending-update flush it starts. This lease only recovers the exceptional
	// path where result encoding is replaced before the callback can be attached.
	updatesActivationClaimTTL = time.Minute
)

// forceCloseBatchTimeout is one deadline for a whole revoke/replace/eviction batch. Conn.Close
// already bounds its inbound-RPC wait, but calling ForceClose serially would multiply that bound
// by the number of sessions. The batch helper starts every close concurrently and waits at most
// this one shared interval.
const forceCloseBatchTimeout = rpcCloseWaitTimeout

// maxForceCloseParallelism caps control-plane close goroutines even if a corrupted/runtime index
// hands a revoke path far more sessions than maxSessionsPerAuthKey. Every Conn's producer/RPC gate
// is closed synchronously before these workers start, so a stuck transport.Close cannot admit more
// memory while the bounded workers continue draining physical sockets in the background.
const maxForceCloseParallelism = 64

type queuedPush struct {
	t           proto.MessageType
	updates     *layerUpdatesFanout
	reservation *pendingPushReservation
	at          time.Time
}

type pendingPushReservation struct {
	budget *outboundTrackedBudget
	bytes  atomic.Int64
	refs   atomic.Int32

	mu       sync.Mutex
	profiles map[tlprofile.Profile]struct{}
}

func (r *pendingPushReservation) retain() {
	if r == nil {
		return
	}
	if refs := r.refs.Add(1); refs <= 1 {
		panic("mtprotoedge: retained released pending push reservation")
	}
}

func (r *pendingPushReservation) release() {
	if r == nil {
		return
	}
	refs := r.refs.Add(-1)
	if refs < 0 {
		panic("mtprotoedge: pending push reservation released more than retained")
	}
	if refs == 0 {
		r.budget.release(int(r.bytes.Load()))
	}
}

// reservePrepared accounts the profile-specific immutable body retained by the
// semantic pending fanout. Multiple queued sessions sharing this reservation
// and profile share both the bytes and this one budget charge.
func (r *pendingPushReservation) reservePrepared(profile tlprofile.Profile, bytes int) bool {
	if r == nil || bytes < 0 {
		return false
	}
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ok := r.profiles[profile]; ok {
		return true
	}
	if !r.budget.reserve(bytes) {
		return false
	}
	if r.profiles == nil {
		r.profiles = make(map[tlprofile.Profile]struct{})
	}
	r.profiles[profile] = struct{}{}
	r.bytes.Add(int64(bytes))
	return true
}

type sessionKey struct {
	authKeyID [8]byte
	sessionID int64
}

type channelSubscription struct {
	userID    int64
	expiresAt int64
}

// SessionLifecycleObserver receives active connection lifecycle events.
type SessionLifecycleObserver interface {
	SessionOffline(rawAuthKeyID [8]byte, sessionID, userID int64, lastForUser bool)
}

// SessionDestructionObserver is an optional explicit control-plane lifecycle.
// It is separate from SessionOffline because a physical disconnect must retain
// logical-session replay metadata, while destroy_session must invalidate it.
type SessionDestructionObserver interface {
	SessionDestroyed(rawAuthKeyID [8]byte, sessionID int64)
}

func notifySessionDestroyed(observer SessionLifecycleObserver, authKeyID [8]byte, sessionID int64) {
	if destroyed, ok := observer.(SessionDestructionObserver); ok {
		destroyed.SessionDestroyed(authKeyID, sessionID)
	}
}

// SessionManager is the active-connection registry, supporting lookup and
// proactive push by session / auth-key / user.
//
// It only manages in-process runtime state, holding sendable active connections;
// protocol-recoverable facts are carried by the auth key, client reconnection and
// the durable updates/difference chain. All methods are concurrency-safe.
type SessionManager struct {
	mu        sync.RWMutex
	bySession map[sessionKey]*Conn
	// logicalSessions owns MTProto resend state independently of physical Conn
	// generations. It is bounded by the Server-wide tracked-body budget and a
	// short offline retention window; ACK and destroy release bodies immediately.
	logicalSessions map[sessionKey]*logicalSession
	// 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
	byAuthKey              map[[8]byte]map[int64]*Conn // raw authKeyID → sessionID → Conn
	byBusinessAuthKey      map[[8]byte]map[sessionKey]*Conn
	byUser                 map[int64]map[sessionKey]*Conn
	byChannel              map[int64]map[sessionKey]int64 // channelID → session → userID, for temporary channel active-viewer pushes
	bySessionChannels      map[sessionKey]map[int64]struct{}
	bySubscribedChannel    map[int64]map[sessionKey]channelSubscription
	bySessionSubscriptions map[sessionKey]map[int64]int64
	byMemberChannel        map[int64]map[sessionKey]int64 // channelID → session → userID, for durable update pushes to online members
	bySessionMembers       map[sessionKey]map[int64]struct{}
	pending                map[sessionKey][]queuedPush // proactive pushes staged before updates-ready
	flushing               map[sessionKey]bool         // sessions whose staged pushes are draining on flag set; pushes keep entering pending until the drain completes, preserving order
	pendingBudget          *outboundTrackedBudget      // process-level cap on encoded bodies staged for not-ready sessions
	logicalSessionReleased func(sessionKey)
	updatesActivationSeq   uint64
	bootstrapProbeSeq      uint64

	lifecycle SessionLifecycleObserver
	log       *zap.Logger
}

// NewSessionManager creates an empty connection registry.
func NewSessionManager(log *zap.Logger) *SessionManager {
	if log == nil {
		log = zap.NewNop()
	}
	return &SessionManager{
		bySession:              make(map[sessionKey]*Conn),
		logicalSessions:        make(map[sessionKey]*logicalSession),
		claims:                 make(map[sessionKey]*Conn),
		claimsByAuth:           make(map[[8]byte]map[int64]*Conn),
		byAuthKey:              make(map[[8]byte]map[int64]*Conn),
		byBusinessAuthKey:      make(map[[8]byte]map[sessionKey]*Conn),
		byUser:                 make(map[int64]map[sessionKey]*Conn),
		byChannel:              make(map[int64]map[sessionKey]int64),
		bySessionChannels:      make(map[sessionKey]map[int64]struct{}),
		bySubscribedChannel:    make(map[int64]map[sessionKey]channelSubscription),
		bySessionSubscriptions: make(map[sessionKey]map[int64]int64),
		byMemberChannel:        make(map[int64]map[sessionKey]int64),
		bySessionMembers:       make(map[sessionKey]map[int64]struct{}),
		pending:                make(map[sessionKey][]queuedPush),
		flushing:               make(map[sessionKey]bool),
		pendingBudget:          newOutboundTrackedBudget(defaultPendingPushMaxBytes),
		log:                    log,
	}
}

// SetLifecycleObserver installs a best-effort active session lifecycle observer.
func (m *SessionManager) SetLifecycleObserver(observer SessionLifecycleObserver) {
	m.mu.Lock()
	m.lifecycle = observer
	m.mu.Unlock()
}

func (m *SessionManager) setLogicalSessionReleaseHook(hook func(sessionKey)) {
	m.mu.Lock()
	m.logicalSessionReleased = hook
	m.mu.Unlock()
}

// SeedInheritedLayerForRawAuthKey supplies an auth-key-wide default to every
// currently unknown active/provisional connection for rawAuthKeyID. Existing
// inherited or explicit state is left untouched; only ordered invokeWithLayer
// admission may correct a selected profile. The return value is the number of
// connections which transitioned from unknown to inherited.
func (m *SessionManager) SeedInheritedLayerForRawAuthKey(rawAuthKeyID [8]byte, layer int) int {
	return m.applyInheritedLayerForRawAuthKey(rawAuthKeyID, layer, false)
}

// RefreshInheritedLayerForRawAuthKey is the auth.bindTempAuthKey identity-
// normalization path. It replaces unknown and inherited raw-temp-key shadows
// with the resolved permanent-key default, while preserving explicit evidence.
// Ordinary auth-key default publication must use SeedInheritedLayerForRawAuthKey
// so it cannot rewrite live sessions which already selected an inherited value.
func (m *SessionManager) RefreshInheritedLayerForRawAuthKey(rawAuthKeyID [8]byte, layer int) int {
	return m.applyInheritedLayerForRawAuthKey(rawAuthKeyID, layer, true)
}

// ClearInheritedLayerForRawAuthKey removes a stale raw-key default after
// identity normalization obtains an authoritative unsupported/unknown
// permanent-key result. Only inherited state is cleared: explicit
// invokeWithLayer evidence belongs to the concrete logical session and remains
// authoritative until newer ordered evidence replaces it.
func (m *SessionManager) ClearInheritedLayerForRawAuthKey(rawAuthKeyID [8]byte) int {
	if m == nil || rawAuthKeyID == ([8]byte{}) {
		return 0
	}
	m.mu.RLock()
	conns := make([]*Conn, 0, len(m.byAuthKey[rawAuthKeyID])+len(m.claimsByAuth[rawAuthKeyID]))
	seen := make(map[*Conn]struct{}, cap(conns))
	for _, group := range []map[int64]*Conn{m.byAuthKey[rawAuthKeyID], m.claimsByAuth[rawAuthKeyID]} {
		for _, c := range group {
			if c == nil {
				continue
			}
			if _, duplicate := seen[c]; duplicate {
				continue
			}
			seen[c] = struct{}{}
			conns = append(conns, c)
		}
	}
	m.mu.RUnlock()

	cleared := 0
	for _, c := range conns {
		if c.isRetired() {
			continue
		}
		if changed, err := c.clearInheritedLayerProfileState(); err == nil && changed {
			cleared++
		}
	}
	return cleared
}

// SeedInheritedLayerForBusinessAuthKey supplies a canonical permanent-key
// default to every live raw physical key already normalized to that business
// identity. This covers multiple temporary/PFS keys for one authorization;
// explicit and previously-selected inherited session profiles remain stable.
func (m *SessionManager) SeedInheritedLayerForBusinessAuthKey(businessAuthKeyID [8]byte, layer int) int {
	if m == nil || businessAuthKeyID == ([8]byte{}) {
		return 0
	}
	profile, ok := tlprofile.ResolveProfile(layer)
	if !ok {
		return 0
	}
	m.mu.RLock()
	group := m.byBusinessAuthKey[businessAuthKeyID]
	conns := make([]*Conn, 0, len(group))
	seen := make(map[*Conn]struct{}, len(group))
	for _, c := range group {
		if c == nil {
			continue
		}
		if _, duplicate := seen[c]; duplicate {
			continue
		}
		seen[c] = struct{}{}
		conns = append(conns, c)
	}
	m.mu.RUnlock()

	seeded := 0
	for _, c := range conns {
		if c.isRetired() {
			continue
		}
		if current, resolved := c.BusinessAuthKeyID(); !resolved || current != businessAuthKeyID {
			continue
		}
		if changed, err := c.setLayerProfile(profile, LayerProfileInherited, false); err == nil && changed {
			seeded++
		}
	}
	return seeded
}

func (m *SessionManager) applyInheritedLayerForRawAuthKey(rawAuthKeyID [8]byte, layer int, refresh bool) int {
	if m == nil {
		return 0
	}
	profile, ok := tlprofile.ResolveProfile(layer)
	if !ok {
		return 0
	}
	m.mu.RLock()
	conns := make([]*Conn, 0, len(m.byAuthKey[rawAuthKeyID])+len(m.claimsByAuth[rawAuthKeyID]))
	seen := make(map[*Conn]struct{}, cap(conns))
	for _, group := range []map[int64]*Conn{m.byAuthKey[rawAuthKeyID], m.claimsByAuth[rawAuthKeyID]} {
		for _, c := range group {
			if c == nil {
				continue
			}
			if _, duplicate := seen[c]; duplicate {
				continue
			}
			seen[c] = struct{}{}
			conns = append(conns, c)
		}
	}
	m.mu.RUnlock()

	seeded := 0
	for _, c := range conns {
		if c.isRetired() {
			continue
		}
		var (
			changed bool
			err     error
		)
		if refresh {
			changed, err = c.refreshInheritedLayerProfile(profile)
		} else {
			changed, err = c.setLayerProfile(profile, LayerProfileInherited, false)
		}
		if err == nil && changed {
			seeded++
		}
	}
	return seeded
}

// ApplyOrderedLayerProfileForSession converges every physical generation
// currently active or claiming the same logical MTProto session. Per-Conn
// msg_id watermarks make broadcasts commutative: even if profile 300 reaches a
// Conn before a delayed profile 200 broadcast, 200 is inert and final state is
// the exact registry's maximum accepted evidence.
func (m *SessionManager) ApplyOrderedLayerProfileForSession(
	primary *Conn,
	rawAuthKeyID [8]byte,
	sessionID int64,
	profile tlprofile.Profile,
	msgID int64,
) (int, error) {
	if err := validateLayerProfile(profile); err != nil {
		return 0, err
	}
	return m.ApplyOrderedRawLayerForSession(primary, rawAuthKeyID, sessionID, int(profile), msgID)
}

// ApplyOrderedRawLayerForSession also carries future Layers unknown to this
// binary. Their raw watermark converges across physical generations while each
// Conn remains codec-unknown until a newer supported selector is admitted.
func (m *SessionManager) ApplyOrderedRawLayerForSession(
	primary *Conn,
	rawAuthKeyID [8]byte,
	sessionID int64,
	layer int,
	msgID int64,
) (int, error) {
	if msgID <= 0 {
		return 0, fmt.Errorf("invalid ordered session layer msg_id %d", msgID)
	}
	if layer <= 0 {
		return 0, fmt.Errorf("invalid ordered session layer %d", layer)
	}
	conns := make([]*Conn, 0, 3)
	seen := make(map[*Conn]struct{}, 3)
	if primary != nil {
		seen[primary] = struct{}{}
		conns = append(conns, primary)
	}
	if m != nil {
		key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}
		m.mu.RLock()
		for _, c := range []*Conn{m.bySession[key], m.claims[key]} {
			if c == nil {
				continue
			}
			if _, duplicate := seen[c]; duplicate {
				continue
			}
			seen[c] = struct{}{}
			conns = append(conns, c)
		}
		m.mu.RUnlock()
	}

	applied := 0
	for _, c := range conns {
		if c == nil || c.isRetired() {
			continue
		}
		changed, err := c.freezeRawLayerProfileAt(layer, msgID)
		if err != nil {
			return applied, err
		}
		if changed {
			applied++
		}
	}
	return applied, nil
}

// ExplicitLayerEvidenceForAuthKey exposes live exact-session truth to
// auth.bindTempAuthKey. Router's bounded exact registry may expire while a Conn
// remains active; bind must not replace that explicit profile with a permanent
// key's inherited default merely because the execution-receipt TTL elapsed.
func (m *SessionManager) ExplicitLayerEvidenceForAuthKey(rawAuthKeyID [8]byte, sessionID int64) (layer int, msgID int64, ok bool) {
	if m == nil || rawAuthKeyID == ([8]byte{}) || sessionID == 0 {
		return 0, 0, false
	}
	key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}
	m.mu.RLock()
	conns := []*Conn{m.bySession[key], m.claims[key]}
	m.mu.RUnlock()
	seen := make(map[*Conn]struct{}, len(conns))
	for _, c := range conns {
		if c == nil || c.isRetired() {
			continue
		}
		if _, duplicate := seen[c]; duplicate {
			continue
		}
		seen[c] = struct{}{}
		state, evidenceMsgID := c.layerProfileEvidenceState()
		if c.isRetired() || state.Origin != LayerProfileExplicit {
			continue
		}
		profile, supported := tlprofile.ResolveProfile(int(state.Profile))
		if !supported || profile != state.Profile {
			continue
		}
		if !ok || evidenceMsgID > msgID {
			layer, msgID, ok = int(profile), evidenceMsgID, true
			continue
		}
		if evidenceMsgID == msgID && layer != int(profile) {
			// This state contradicts the per-session msg_id ordering invariant;
			// do not let bind choose either physical generation arbitrarily.
			return 0, 0, false
		}
	}
	return layer, msgID, ok
}

// SetClientLayerForAuthKey implements rpc.ClientLayerBinder without weakening
// ordered evidence. It is a legacy/readiness safety net: only an unknown exact
// Conn receives the value as inherited state. Explicit or already-selected
// inherited profiles are owned by the edge's msg_id-ordered path.
func (m *SessionManager) SetClientLayerForAuthKey(rawAuthKeyID [8]byte, sessionID int64, layer int) {
	if m == nil {
		return
	}
	profile, ok := tlprofile.ResolveProfile(layer)
	if !ok {
		return
	}
	key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}
	m.mu.RLock()
	conns := []*Conn{m.bySession[key], m.claims[key]}
	m.mu.RUnlock()
	seen := make(map[*Conn]struct{}, len(conns))
	for _, c := range conns {
		if c == nil || c.isRetired() {
			continue
		}
		if _, duplicate := seen[c]; duplicate {
			continue
		}
		seen[c] = struct{}{}
		_, _ = c.setLayerProfile(profile, LayerProfileInherited, false)
	}
}

// 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)
	retired := make([]*Conn, 0, 2)
	m.mu.Lock()
	if !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
		}
		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.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
	addConnIndex(m.byAuthKey, c.authKeyID, c.sessionID, c)
	if businessAuthKeyID, resolved := c.BusinessAuthKeyID(); resolved {
		addBusinessAuthKeyIndex(m.byBusinessAuthKey, businessAuthKeyID, key, c)
	}
	if uid := c.userID.Load(); uid != 0 {
		c.userIDResolved.Store(true)
		addUserIndex(m.byUser, uid, key, c)
	}
	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)),
	)
	return nil
}

// 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)
		}
		m.markLogicalSessionOfflineLocked(key, time.Now())
		owned = true
	}
	m.mu.Unlock()
	if owned {
		_ = closeConnBatch([]*Conn{c}, forceCloseBatchTimeout, false)
	}
}

// Unregister unregisters a connection (only if it is still the same object
// registered currently, to avoid wrongly deleting a new connection after
// reconnecting). The observer is also called back for logged-out connections
// (userID=0): the business layer uses it to clean up per-session cache entries,
// otherwise the metadata of a logged-out connection could only be evicted at the
// capacity cap.
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
	)
	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
		}
		observer = m.lifecycle
		m.log.Debug("Session unregistered",
			zap.String("auth_key_id", sessionKeyLog(c.authKeyID)),
			zap.Int64("session_id", c.sessionID),
			zap.Int("online", len(m.bySession)),
		)
	}
	m.markLogicalSessionOfflineLocked(key, time.Now())
	m.mu.Unlock()
	if observer != nil {
		observer.SessionOffline(c.authKeyID, c.sessionID, offlineUser, lastForUser)
	}
}

// DestroySessionForAuthKey precisely removes a session under a raw auth_key_id.
func (m *SessionManager) DestroySessionForAuthKey(authKeyID [8]byte, sessionID int64) bool {
	m.mu.Lock()
	observer := m.lifecycle
	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)
			outbound := m.destroyLogicalSessionLocked(key)
			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),
				)
			}
			if outbound != nil {
				m.releaseLogicalSession(key, outbound)
			}
			notifySessionDestroyed(observer, authKeyID, sessionID)
			return true
		}
		m.deletePendingLocked(key)
		outbound := m.destroyLogicalSessionLocked(key)
		m.mu.Unlock()
		if outbound != nil {
			m.releaseLogicalSession(key, outbound)
		}
		notifySessionDestroyed(observer, authKeyID, sessionID)
		return false
	}
	offlineUser := m.retireConnLocked(c, true)
	outbound := m.destroyLogicalSessionLocked(key)
	lastForUser := offlineUser != 0 && len(m.byUser[offlineUser]) == 0
	m.log.Debug("Session destroyed",
		zap.String("auth_key_id", sessionKeyLog(authKeyID)),
		zap.Int64("session_id", sessionID),
		zap.Int("online", len(m.bySession)),
	)
	m.mu.Unlock()
	if !forceCloseConnBatch([]*Conn{c}, forceCloseBatchTimeout) {
		m.log.Warn("Destroyed session close exceeded shared deadline",
			zap.String("auth_key_id", sessionKeyLog(authKeyID)),
			zap.Int64("session_id", sessionID),
		)
	}
	if outbound != nil {
		m.releaseLogicalSession(key, outbound)
	}
	if observer != nil && offlineUser != 0 {
		observer.SessionOffline(authKeyID, sessionID, offlineUser, lastForUser)
	}
	notifySessionDestroyed(observer, authKeyID, sessionID)
	return true
}

// BindUserForAuthKey caches the authorized user for the given raw auth_key_id + session_id.
func (m *SessionManager) BindUserForAuthKey(authKeyID [8]byte, sessionID, userID int64) {
	m.mu.Lock()
	defer m.mu.Unlock()
	key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
	c, ok := m.bySession[key]
	if !ok {
		return
	}
	m.bindUserLocked(c, key, userID)
}

func (m *SessionManager) bindUserLocked(c *Conn, key sessionKey, userID int64) {
	if old := c.userID.Swap(userID); old != 0 {
		removeUserIndex(m.byUser, old, key)
		if old != userID {
			m.clearSessionChannelIndexesLocked(c, key)
			c.membershipsSynced.Store(false)
			m.clearUpdatesActivationLocked(c)
			m.clearBootstrapProbeLocked(c)
			// An identity change discards staged pushes: they belong to the previous
			// account, and flushing them to the new account would be a cross-account
			// leak. It also cancels an in-progress drain (runFlush has an additional
			// owner check as a per-batch fallback).
			m.deletePendingLocked(key)
			delete(m.flushing, key)
		}
	}
	c.userIDResolved.Store(true)
	if userID != 0 {
		addUserIndex(m.byUser, userID, key, c)
	} else {
		m.clearSessionChannelIndexesLocked(c, key)
		c.membershipsSynced.Store(false)
		m.clearUpdatesActivationLocked(c)
		m.clearBootstrapProbeLocked(c)
		m.deletePendingLocked(key)
		delete(m.flushing, key)
	}
}

// UserIDResolvedForAuthKey returns the user_id cache state for the given raw auth_key_id + session_id.
func (m *SessionManager) UserIDResolvedForAuthKey(authKeyID [8]byte, sessionID int64) (int64, bool) {
	m.mu.RLock()
	c, ok := m.bySession[sessionKey{authKeyID: authKeyID, sessionID: sessionID}]
	m.mu.RUnlock()
	if !ok {
		return 0, false
	}
	return c.UserIDResolved()
}

// BindAuthKeyForSession caches the business auth_key_id for the given raw auth_key_id + session_id.
func (m *SessionManager) BindAuthKeyForSession(rawAuthKeyID [8]byte, sessionID int64, authKeyID [8]byte) {
	m.mu.Lock()
	defer m.mu.Unlock()
	key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}
	c, ok := m.bySession[key]
	if !ok {
		return
	}
	m.bindAuthKeyLocked(c, key, authKeyID)
}

// BindAuthKeyForRawAuthKey binds all active sessions of the same raw temporary key
// to the canonical permanent key. Android/TDesktop concurrently create multiple
// sessions on one temp key; the bind happens on only one of them, and the other
// sessions must not keep treating the raw temp as the business key.
func (m *SessionManager) BindAuthKeyForRawAuthKey(rawAuthKeyID [8]byte, authKeyID [8]byte) int {
	m.mu.Lock()
	defer m.mu.Unlock()
	bound := 0
	for sessionID, c := range m.byAuthKey[rawAuthKeyID] {
		if c == nil {
			continue
		}
		key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}
		if m.bySession[key] != c {
			continue
		}
		m.bindAuthKeyLocked(c, key, authKeyID)
		bound++
	}
	return bound
}

func (m *SessionManager) bindAuthKeyLocked(c *Conn, key sessionKey, authKeyID [8]byte) {
	oldAuthKeyID, resolved := c.BusinessAuthKeyID()
	changed := !resolved || oldAuthKeyID != authKeyID
	oldUserID := c.userID.Load()
	if resolved {
		removeBusinessAuthKeyIndex(m.byBusinessAuthKey, oldAuthKeyID, key)
	}
	c.SetBusinessAuthKeyID(authKeyID)
	m.bindLogicalSessionAuthKeyLocked(key, authKeyID)
	addBusinessAuthKeyIndex(m.byBusinessAuthKey, authKeyID, key, c)
	if changed {
		if oldUserID != 0 {
			removeUserIndex(m.byUser, oldUserID, key)
		}
		m.clearSessionChannelIndexesLocked(c, key)
		c.membershipsSynced.Store(false)
		m.clearUpdatesActivationLocked(c)
		m.clearBootstrapProbeLocked(c)
		m.deletePendingLocked(key)
		delete(m.flushing, key)
		c.userID.Store(0)
		c.userIDResolved.Store(false)
	}
}

// AuthKeyIDForSession returns the cached business auth_key_id for the given raw auth_key_id + session_id.
func (m *SessionManager) AuthKeyIDForSession(rawAuthKeyID [8]byte, sessionID int64) ([8]byte, bool) {
	m.mu.RLock()
	c, ok := m.bySession[sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}]
	m.mu.RUnlock()
	if !ok {
		return [8]byte{}, false
	}
	return c.BusinessAuthKeyID()
}

// AuthKeyExpiresAtForSession returns the handshake protocol expiry of the raw key; 0 means permanent.
func (m *SessionManager) AuthKeyExpiresAtForSession(rawAuthKeyID [8]byte, sessionID int64) (int, bool) {
	m.mu.RLock()
	c, ok := m.bySession[sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}]
	m.mu.RUnlock()
	if !ok {
		return 0, false
	}
	return c.AuthKeyExpiresAt(), true
}

// CloseSessionsForBusinessAuthKey forcibly disconnects all active connections of
// the given business auth_key, used for authorization revocation (a kicked
// device): outbound pushes are encrypted with the key held by the connection and
// do not recheck the key store, so without disconnecting, a revoked device would
// keep receiving pushes until it disconnects naturally; also, a perm-key
// connection's authorization cache is only rechecked on disconnect and reconnect.
// Here the underlying transport must be closed so the WebSocket/TCP peer sees the
// disconnect immediately, rather than only removing it from the online index.
func (m *SessionManager) CloseSessionsForBusinessAuthKey(authKeyID [8]byte) int {
	type offlineEvent struct {
		key    sessionKey
		userID int64
		last   bool
	}
	m.mu.Lock()
	var conns []*Conn
	var events []offlineEvent
	var logicalRelease []*logicalSession
	for key, c := range m.businessAuthKeyCandidatesLocked(authKeyID) {
		if !connUsesBusinessAuthKey(c, authKeyID) {
			continue
		}
		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)
	}
	for key, logical := range m.logicalSessions {
		if logical == nil || (key.authKeyID != authKeyID &&
			(!logical.businessAuthResolved || logical.businessAuthKeyID != authKeyID)) {
			continue
		}
		delete(m.logicalSessions, key)
		logicalRelease = append(logicalRelease, logical)
	}
	observer := m.lifecycle
	if len(conns) > 0 {
		m.log.Debug("Force close sessions for revoked auth key",
			zap.String("auth_key_id", sessionKeyLog(authKeyID)),
			zap.Int("closed", len(conns)),
		)
	}
	m.mu.Unlock()
	if !forceCloseConnBatch(conns, forceCloseBatchTimeout) {
		m.log.Warn("Revoked auth-key session close exceeded shared deadline",
			zap.String("auth_key_id", sessionKeyLog(authKeyID)),
			zap.Int("sessions", len(conns)),
		)
	}
	for _, logical := range logicalRelease {
		m.releaseLogicalSession(logical.key, logical.outbound)
	}
	if observer != nil {
		for _, e := range events {
			observer.SessionOffline(e.key.authKeyID, e.key.sessionID, e.userID, e.last)
		}
	}
	return len(conns)
}

// CloseSessionsForRawAuthKeyExcept forcibly disconnects the active connections of
// the given raw auth_key, optionally excluding one session by session ID. This
// interface is used by the business layer for authorization revocation; wire-level
// destroy_auth_key must instead use exact Conn exclusion, to avoid a same-session
// replacement being wrongly let through.
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
		last   bool
	}
	m.mu.Lock()
	var conns []*Conn
	var events []offlineEvent
	for sessionID, c := range m.byAuthKey[authKeyID] {
		if skip != nil && skip(sessionID, c) {
			continue
		}
		key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
		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) {
		m.log.Warn("Raw auth-key session close exceeded shared deadline",
			zap.String("auth_key_id", sessionKeyLog(authKeyID)),
			zap.Int("sessions", len(conns)),
		)
	}
	if observer != nil {
		for _, e := range events {
			observer.SessionOffline(e.key.authKeyID, e.key.sessionID, e.userID, e.last)
		}
	}
	return len(conns)
}

// forceCloseConnBatch closes every producer/RPC gate first, then closes physical transports with a
// bounded worker set. Physical close and actor/RPC convergence share one batch deadline; the wait is
// never multiplied by the number of sessions. Workers may finish physical closes after the caller's
// 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
	}
	unique := make([]*Conn, 0, len(conns))
	seen := make(map[*Conn]struct{}, len(conns))
	for _, c := range conns {
		if c == nil {
			continue
		}
		if _, ok := seen[c]; ok {
			continue
		}
		seen[c] = struct{}{}
		unique = append(unique, c)
	}
	if len(unique) == 0 {
		return true
	}

	// This phase is non-blocking and must precede transport.Close: it is the safety boundary if
	// an implementation of transport.Conn.Close itself blocks past the batch deadline.
	for _, c := range unique {
		c.beginTerminalShutdown()
	}

	workers := min(len(unique), maxForceCloseParallelism)
	jobs := make(chan *Conn, len(unique))
	for _, c := range unique {
		jobs <- c
	}
	close(jobs)
	var closeWG sync.WaitGroup
	closeWG.Add(workers)
	for range workers {
		go func() {
			defer closeWG.Done()
			for c := range jobs {
				c.closeTransport()
			}
		}()
	}
	physicalDone := make(chan struct{})
	go func() {
		closeWG.Wait()
		close(physicalDone)
	}()

	if timeout <= 0 {
		return false
	}
	deadline := time.Now().Add(timeout)
	timer := time.NewTimer(time.Until(deadline))
	defer timer.Stop()
	select {
	case <-physicalDone:
	case <-timer.C:
		return false
	}

	// All physical close calls returned. Wait for memory-owning actor/RPC work using the same
	// deadline; the first genuinely stuck Conn consumes the remaining allowance, not a fresh 5s.
	for _, c := range unique {
		remaining := time.Until(deadline)
		if remaining <= 0 {
			return false
		}
		if waitInbound && c.rpcScheduler != nil && !c.waitInboundShutdown(remaining) {
			return false
		}
		if c.outboundDone == nil {
			continue
		}
		remaining = time.Until(deadline)
		if remaining <= 0 {
			return false
		}
		wait := time.NewTimer(remaining)
		select {
		case <-c.outboundDone:
			wait.Stop()
		case <-wait.C:
			return false
		}
	}
	return true
}

// UnbindAuthKey clears the logged-in user cache of all active connections under a business auth_key.
func (m *SessionManager) UnbindAuthKey(authKeyID [8]byte) int {
	m.mu.Lock()
	defer m.mu.Unlock()
	count := 0
	for key, c := range m.businessAuthKeyCandidatesLocked(authKeyID) {
		if !connUsesBusinessAuthKey(c, authKeyID) {
			continue
		}
		if old := c.userID.Swap(0); old != 0 {
			removeUserIndex(m.byUser, old, key)
		}
		m.clearSessionChannelIndexesLocked(c, key)
		c.membershipsSynced.Store(false)
		m.clearUpdatesActivationLocked(c)
		m.clearBootstrapProbeLocked(c)
		// After unbinding, staged pushes belong to the logged-out account and must not
		// be flushed out when the next login sets the flag.
		m.deletePendingLocked(key)
		delete(m.flushing, key)
		c.userIDResolved.Store(true)
		count++
	}
	return count
}

// setReceivesUpdatesLocked is the common core of setting/resetting the flag; the
// caller must hold m.mu.
// When setting the flag and there is staged data, receivesUpdates is not set
// immediately: instead flushing is marked and the userID owning that staged batch
// is returned, to be drained by runFlush and set atomically afterwards. Meanwhile
// newly arriving pushes keep entering pending, preserving the relative order of
// staged and live pushes (otherwise a live direct send could land before staged
// entries with earlier pts). The returned owner lets runFlush detect an identity
// switch during the drain (logout/account switch) and discard the remaining staged
// data of the old account without sending it to the new account.
func (m *SessionManager) setReceivesUpdatesLocked(c *Conn, key sessionKey, receives bool) (int64, bool) {
	if !receives {
		c.receivesUpdates.Store(false)
		m.clearSessionChannelIndexesLocked(c, key)
		c.membershipsSynced.Store(false)
		m.clearUpdatesActivationLocked(c)
		// Cancel an in-progress drain activation: runFlush rechecks this flag before
		// setting, and if the flag has been deleted it abandons the set, avoiding
		// flipping a switch just set to false back to true.
		delete(m.flushing, key)
		return 0, false
	}
	if _, ok := c.LayerProfile(); !ok {
		// A successful wire-invariant bootstrap RPC is not evidence that this
		// physical session can decode proactive updates. Keep durable updates
		// pending until generated exact admission freezes a real profile; do not
		// start a flush which would fail layer binding and retire a healthy socket.
		c.receivesUpdates.Store(false)
		m.clearSessionChannelIndexesLocked(c, key)
		c.membershipsSynced.Store(false)
		m.clearUpdatesActivationLocked(c)
		delete(m.flushing, key)
		return 0, false
	}
	if c.receivesUpdates.Load() || m.flushing[key] {
		// Already ready, or a drain goroutine is already running (it will take the
		// newly staged pushes itself and set the flag when it completes).
		if c.receivesUpdates.Load() {
			m.clearUpdatesActivationLocked(c)
		}
		return 0, false
	}
	if len(m.pending[key]) == 0 {
		c.receivesUpdates.Store(true)
		return 0, false
	}
	m.flushing[key] = true
	return c.userID.Load(), true
}

// runFlush sends the staged pushes directly to the connection in order, setting
// receivesUpdates only after draining (including those added during the drain).
// The direct send uses c.Send to bypass the ready check—at this point it is
// necessarily not ready, and going through PushToSessionForAuthKey would stage it
// again into an infinite loop. Three kinds of termination:
//   - identity switch (logout/account switch causing c.userID != owner): discard the remaining staged and requeued data without sending to the new account;
//   - send failure: requeue the remainder and back off and retry; if attempts are exhausted, set active and fall back to getDifference, so an idle client does not stay inactive forever;
//   - drain complete: atomically set receivesUpdates.
func (m *SessionManager) runFlush(c *Conn, key sessionKey, owner int64, attempt int) {
	for {
		m.mu.Lock()
		if cur, ok := m.bySession[key]; !ok || cur != c || !m.flushing[key] {
			// The connection has changed generation (removeLocked has already cleared
			// flushing) or the activation was canceled (SetReceivesUpdates(false)).
			m.mu.Unlock()
			return
		}
		if c.userID.Load() != owner {
			// A logout/account switch happened during the drain: the remaining staged
			// data belongs to the old account, must be discarded and must not be sent to the new account.
			m.deletePendingLocked(key)
			delete(m.flushing, key)
			m.clearUpdatesActivationLocked(c)
			m.mu.Unlock()
			return
		}
		batch := m.takePendingLocked(key, true)
		if len(batch) == 0 {
			c.receivesUpdates.Store(true)
			delete(m.flushing, key)
			m.clearUpdatesActivationLocked(c)
			m.mu.Unlock()
			return
		}
		m.mu.Unlock()

		for i, item := range batch {
			// Recheck identity before each send: after a logout/account switch the
			// remaining entries of the batch must not keep being sent to a connection
			// that has changed hands.
			if c.userID.Load() != owner {
				m.mu.Lock()
				m.deletePendingLocked(key)
				delete(m.flushing, key)
				m.clearUpdatesActivationLocked(c)
				m.mu.Unlock()
				releaseQueuedPushes(batch[i:])
				return
			}
			ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
			// Pending entries are durable account updates. Shared body-budget pressure is not
			// evidence that this socket is corrupt, so use the non-terminal enqueue path; after
			// bounded retries, getDifference is the authoritative recovery path.
			encoded, err := item.updates.prepareForConn(ctx, c)
			if err == nil {
				if encoded == nil || encoded.layer == nil {
					err = errors.New("pending exact updates lost layer binding")
				} else if !item.reservation.reservePrepared(encoded.layer.profile, len(encoded.body)) {
					item.updates.discardPrepared(encoded.layer.profile, encoded)
					err = ErrOutboundTrackedBudget
				}
			}
			if err == nil {
				err = c.SendBestEffortEncoded(ctx, item.t, encoded, 5*time.Second)
			}
			cancel()
			if err == nil {
				item.release()
				continue
			}
			if isOutboundStaleLayerEpoch(err) {
				// This durable online accelerator was prepared before a client layer
				// correction. Drop only the stale item; difference remains authoritative.
				item.release()
				continue
			}
			if isOutboundLayerProfileError(err) {
				// updates-ready without an exact profile, or a mismatched final
				// body, violates the physical-connection layer invariant. Never
				// guess canonical bytes; retire this writer and let durable
				// difference recover after a correctly negotiated reconnect.
				c.dropSlowConsumer()
				releaseQueuedPushes(batch[i:])
				return
			}
			m.mu.Lock()
			if cur, ok := m.bySession[key]; !ok || cur != c || !m.flushing[key] || c.userID.Load() != owner {
				// Connection generation change/cancellation/hand-over: the remaining
				// batch does not belong to the current connection's current account, discard it.
				if c.userID.Load() != owner {
					m.deletePendingLocked(key)
					delete(m.flushing, key)
					m.clearUpdatesActivationLocked(c)
				}
				m.mu.Unlock()
				releaseQueuedPushes(batch[i:])
				return
			}
			rest := append(append([]queuedPush(nil), batch[i:]...), m.pending[key]...)
			if len(rest) > maxPendingPushesPerSession {
				// Consistent with queueLocked's overflow policy: drop the oldest and
				// keep the newest, concentrating the pts gap at the very front, so the
				// flush's first entry triggers the client's gap detection and the
				// recovery path is shortest.
				dropped := len(rest) - maxPendingPushesPerSession
				releaseQueuedPushes(rest[:dropped])
				rest = rest[dropped:]
			}
			m.pending[key] = rest
			if attempt+1 >= maxFlushAttempts {
				// Retries exhausted: set active to avoid an idle client losing its stream
				// forever; the durable updates among the remaining staged data are filled
				// in by getDifference triggered by the client's later pts gap.
				c.receivesUpdates.Store(true)
				m.deletePendingLocked(key)
				delete(m.flushing, key)
				m.clearUpdatesActivationLocked(c)
				m.mu.Unlock()
				m.log.Debug("Flush gave up after retries; activated with getDifference fallback",
					zap.String("auth_key_id", sessionKeyLog(key.authKeyID)),
					zap.Int64("session_id", key.sessionID),
					zap.Int("requeued", len(rest)),
				)
				return
			}
			m.mu.Unlock()
			m.log.Debug("Flush pending push failed; backoff retry",
				zap.String("auth_key_id", sessionKeyLog(key.authKeyID)),
				zap.Int64("session_id", key.sessionID),
				zap.Int("attempt", attempt+1),
				zap.Int("requeued", len(rest)),
				zap.Error(err),
			)
			time.AfterFunc(flushRetryBackoff*time.Duration(attempt+1), func() {
				m.runFlush(c, key, owner, attempt+1)
			})
			return
		}
		// This batch is done; loop back to re-take staged pushes added during the drain.
	}
}

// ReceivesUpdatesForAuthKey reports whether the connection for the given raw
// auth_key_id + session_id is fully ready: it both receives proactive updates and
// has successfully established the channel membership push route. Returns false
// when there is no active connection. Returning false lets the RPC-driven flag set
// short-circuit through and the next RPC retry the membership sync—otherwise a
// session whose sync failed would silently miss supergroup pushes in the state of
// "flagged ready but byMemberChannel missing".
func (m *SessionManager) ReceivesUpdatesForAuthKey(authKeyID [8]byte, sessionID int64) bool {
	m.mu.RLock()
	c, ok := m.bySession[sessionKey{authKeyID: authKeyID, sessionID: sessionID}]
	m.mu.RUnlock()
	if !ok {
		return false
	}
	_, hasProfile := c.LayerProfile()
	return hasProfile && c.receivesUpdates.Load() && c.membershipsSynced.Load()
}

// BeginSessionUpdatesActivation claims the readiness transition for the
// current physical connection. Ordinary startup RPCs race here before they
// register delivery hooks, so at most one of them can enqueue the expensive
// channel-membership synchronization. Cursor commits remain request-owned.
func (m *SessionManager) BeginSessionUpdatesActivation(authKeyID [8]byte, sessionID int64) (uint64, bool) {
	if m == nil {
		return 0, false
	}
	key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
	m.mu.Lock()
	defer m.mu.Unlock()
	c := m.bySession[key]
	if c == nil || c.isRetired() {
		return 0, false
	}
	if _, hasProfile := c.LayerProfile(); hasProfile && c.receivesUpdates.Load() && c.membershipsSynced.Load() {
		return 0, false
	}
	now := time.Now()
	if c.now != nil {
		now = c.now()
	}
	if c.updatesActivationToken != 0 {
		// A pending FIFO flush owns the activation until it reaches a terminal
		// outcome. Never lease-steal while that ordered delivery is in progress.
		if m.flushing[key] || now.Sub(c.updatesActivationAt) < updatesActivationClaimTTL {
			return 0, false
		}
	}
	m.updatesActivationSeq++
	if m.updatesActivationSeq == 0 {
		m.updatesActivationSeq++
	}
	c.updatesActivationToken = m.updatesActivationSeq
	c.updatesActivationAt = now
	return c.updatesActivationToken, true
}

// EndSessionUpdatesActivation releases only the token owned by the caller and
// only on the same current physical Conn. If SetReceivesUpdates started an
// ordered pending flush, that flush retains and releases the claim itself.
func (m *SessionManager) EndSessionUpdatesActivation(authKeyID [8]byte, sessionID int64, token uint64) {
	if m == nil || token == 0 {
		return
	}
	key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
	m.mu.Lock()
	defer m.mu.Unlock()
	c := m.bySession[key]
	if c == nil || c.updatesActivationToken != token || m.flushing[key] {
		return
	}
	m.clearUpdatesActivationLocked(c)
}

// BeginSessionBootstrapProbe claims the first durable bootstrap-job lookup for
// the current physical connection generation. Unlike updates activation, this
// is completed only by a delivered getState/getDifference baseline.
func (m *SessionManager) BeginSessionBootstrapProbe(authKeyID [8]byte, sessionID int64) (uint64, bool) {
	if m == nil {
		return 0, false
	}
	key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
	m.mu.Lock()
	defer m.mu.Unlock()
	c := m.bySession[key]
	if c == nil || c.isRetired() || c.bootstrapProbed || c.bootstrapProbeToken != 0 {
		return 0, false
	}
	m.bootstrapProbeSeq++
	if m.bootstrapProbeSeq == 0 {
		m.bootstrapProbeSeq++
	}
	c.bootstrapProbeToken = m.bootstrapProbeSeq
	return c.bootstrapProbeToken, true
}

// EndSessionBootstrapProbe completes or releases only the token on the same
// current Conn. A delayed callback from a replaced connection cannot mutate the
// replacement's one-shot state.
func (m *SessionManager) EndSessionBootstrapProbe(authKeyID [8]byte, sessionID int64, token uint64, success bool) {
	if m == nil || token == 0 {
		return
	}
	key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
	m.mu.Lock()
	defer m.mu.Unlock()
	c := m.bySession[key]
	if c == nil || c.bootstrapProbeToken != token {
		return
	}
	c.bootstrapProbeToken = 0
	if success {
		c.bootstrapProbed = true
	}
}

func (m *SessionManager) clearUpdatesActivationLocked(c *Conn) {
	if c == nil {
		return
	}
	c.updatesActivationToken = 0
	c.updatesActivationAt = time.Time{}
}

func (m *SessionManager) clearBootstrapProbeLocked(c *Conn) {
	if c == nil {
		return
	}
	c.bootstrapProbeToken = 0
	c.bootstrapProbed = false
}

// SetReceivesUpdatesForAuthKey marks whether the given raw auth_key_id + session_id receives proactive updates.
func (m *SessionManager) SetReceivesUpdatesForAuthKey(authKeyID [8]byte, sessionID int64, receives bool) {
	m.mu.Lock()
	key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
	c, ok := m.bySession[key]
	if !ok {
		m.mu.Unlock()
		return
	}
	owner, start := m.setReceivesUpdatesLocked(c, key, receives)
	m.mu.Unlock()

	if start {
		go m.runFlush(c, key, owner, 0)
	}
}

// PushToSessionForAuthKey pushes a message to the given raw auth_key_id + session_id.
func (m *SessionManager) PushToSessionForAuthKey(ctx context.Context, authKeyID [8]byte, sessionID int64, t proto.MessageType, msg tg.UpdatesClass) error {
	m.mu.RLock()
	key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
	c, ok := m.bySession[key]
	if !ok {
		m.mu.RUnlock()
		return ErrSessionNotFound
	}
	ready := c.receivesUpdates.Load()
	m.mu.RUnlock()
	if ready {
		updates, err := newLayerUpdatesFanoutContext(ctx, msg)
		if err != nil {
			return err
		}
		encoded, err := updates.prepareForConn(ctx, c)
		if err != nil {
			return err
		}
		return c.SendEncoded(ctx, t, encoded)
	}
	return m.queueOrSendPrepared(ctx, key, t, msg)
}

func (m *SessionManager) queueOrSendPrepared(ctx context.Context, key sessionKey, t proto.MessageType, msg tg.UpdatesClass) error {
	getUpdates := onceLayerUpdatesFanout(ctx, msg)
	updates, reservation, err := m.preparePendingPush(getUpdates)
	if err != nil {
		return err
	}
	defer reservation.release()

	m.mu.Lock()
	c, ok := m.bySession[key]
	if !ok {
		m.mu.Unlock()
		return ErrSessionNotFound
	}
	if !c.receivesUpdates.Load() {
		_ = m.queuePreparedLocked(key, t, updates, reservation)
		m.mu.Unlock()
		return nil
	}
	m.mu.Unlock()
	encoded, err := updates.prepareForConn(ctx, c)
	if err != nil {
		return err
	}
	return c.SendEncoded(ctx, t, encoded)
}

// PushToSessionForAuthKeyImmediate immediately pushes a message to the given raw
// auth_key_id + session_id.
//
// It does not wait for the session to become updates-ready and does not write the
// pending queue. It is only used for pre-login handshake signals (such as
// updateLoginToken): such messages are themselves the trigger for the client to
// continue completing login, and going through the ordinary durable update queue
// would stall at the stage where the client has not yet called updates.getState.
func (m *SessionManager) PushToSessionForAuthKeyImmediate(ctx context.Context, authKeyID [8]byte, sessionID int64, t proto.MessageType, msg tg.UpdatesClass) error {
	m.mu.RLock()
	key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
	c, ok := m.bySession[key]
	m.mu.RUnlock()
	if !ok {
		return ErrSessionNotFound
	}
	updates, err := newLayerUpdatesFanoutContext(ctx, msg)
	if err != nil {
		return err
	}
	encoded, err := updates.prepareForConn(ctx, c)
	if err != nil {
		return err
	}
	return c.SendBestEffortEncoded(ctx, t, encoded, 2*time.Second)
}

// PushToUserExceptAuthKeySession pushes to all active connections of a user, skipping the given raw auth_key + session.
func (m *SessionManager) PushToUserExceptAuthKeySession(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg tg.UpdatesClass) (int, error) {
	return m.pushToUser(ctx, userID, &excludeAuthKeyID, excludeSessionID, t, msg)
}

// PushToUserAuthKey delivers msg specifically to the ready connection bound to the
// concrete device businessAuthKeyID and belonging to userID (the anchor for
// device-level secret-chat delivery). The index goes through byBusinessAuthKey
// (via businessAuthKeyCandidatesLocked, compatible with temp-key/PFS connections),
// not byAuthKey (the raw index would miss temp-key devices). Not-ready connections
// are skipped and do not enter pending—secret-chat messages are durable in the qts
// queue, and offline devices catch up via getDifference (online push is just an
// accelerator). The c.userID recheck prevents cross-account leaks.
func (m *SessionManager) PushToUserAuthKey(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass) (int, error) {
	// Secret-chat qts is the durable source of truth, so online delivery is an accelerator just
	// like account pts fan-out.  Do not synchronously wait for every PFS/raw connection's socket.
	return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, 0, t, msg, 2*time.Second)
}

// PushToUserAuthKeyTransient is the transient (typing) best-effort version of PushToUserAuthKey.
func (m *SessionManager) PushToUserAuthKeyTransient(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
	return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, 0, t, msg, timeout)
}

func (m *SessionManager) PushToUserAuthKeyTransientCompatible(ctx context.Context, userID int64, businessAuthKeyID [8]byte, semantic tlprofile.SemanticID, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
	return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, semantic, t, msg, timeout)
}

// PushToUserExceptBusinessAuthKey delivers the update to the account's other
// devices, precisely excluding all raw/temp/PFS connections under the same
// permanent business auth key. The secret-chat accept uses it to make the device
// that lost the race converge to discarded, while ensuring the winning device's
// other connections do not wrongly delete the just-established secret chat.
func (m *SessionManager) PushToUserExceptBusinessAuthKey(ctx context.Context, userID int64, excludeBusinessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
	getUpdates := onceLayerUpdatesFanout(ctx, msg)
	return m.pushToUserWithSender(ctx, userID, nil, 0, &excludeBusinessAuthKeyID, 0, t, getUpdates, false, func(c *Conn) error {
		if c.outbound == nil || c.outboundControl == nil {
			return ErrConnClosed
		}
		updates, err := getUpdates()
		if err != nil {
			return err
		}
		encoded, err := updates.prepareForConn(ctx, c)
		if err != nil {
			return err
		}
		return c.SendBestEffortEncoded(ctx, t, encoded, timeout)
	})
}

func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, userID int64, businessAuthKeyID [8]byte, semantic tlprofile.SemanticID, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
	if ctx != nil && ctx.Err() != nil {
		return 0, ctx.Err()
	}
	sendCtx := context.Background()
	if ctx != nil {
		sendCtx = context.WithoutCancel(ctx)
	}
	var deadline time.Time
	if timeout > 0 {
		deadline = time.Now().Add(timeout)
	}
	if ctx != nil {
		if ctxDeadline, ok := ctx.Deadline(); ok && (deadline.IsZero() || ctxDeadline.Before(deadline)) {
			deadline = ctxDeadline
		}
	}
	if !deadline.IsZero() {
		var cancel context.CancelFunc
		sendCtx, cancel = context.WithDeadline(sendCtx, deadline)
		defer cancel()
	}
	getUpdates := onceLayerUpdatesFanout(sendCtx, msg)
	return m.pushToBusinessAuthKey(ctx, userID, businessAuthKeyID, semantic, func(c *Conn) error {
		if c.outbound == nil || c.outboundControl == nil {
			return ErrConnClosed
		}
		updates, err := getUpdates()
		if err != nil {
			return err
		}
		encoded, err := updates.prepareForConn(sendCtx, c)
		if err != nil {
			return err
		}
		remaining := timeout
		if !deadline.IsZero() {
			remaining = time.Until(deadline)
			if remaining < 0 {
				remaining = 0
			}
		}
		return c.SendBestEffortEncoded(sendCtx, t, encoded, remaining)
	})
}

func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64, businessAuthKeyID [8]byte, semantic tlprofile.SemanticID, send func(*Conn) error) (int, error) {
	m.mu.Lock()
	candidates := m.businessAuthKeyCandidatesLocked(businessAuthKeyID)
	conns := make([]*Conn, 0, len(candidates))
	for _, c := range candidates {
		if c.userID.Load() != userID {
			continue
		}
		if !c.receivesUpdates.Load() {
			// Not ready: secret-chat messages are caught up via getDifference and typing
			// is dropped directly—neither enters pending.
			continue
		}
		if !sessionSupportsSemantic(c, semantic) {
			continue
		}
		conns = append(conns, c)
	}
	m.mu.Unlock()
	var firstErr error
	sent := 0
	for _, c := range conns {
		// Recheck identity before sending outside the lock, preventing a cross-account
		// leak from a concurrent rebind after collection.
		if c.userID.Load() != userID {
			continue
		}
		if err := send(c); err != nil {
			if isOutboundStaleLayerEpoch(err) {
				// A concurrent correction invalidated this prepared push, not the
				// connection. Durable qts/difference remains the source of truth.
				continue
			}
			if isOutboundLayerProfileError(err) {
				c.dropSlowConsumer()
				continue
			}
			if errors.Is(err, ErrOutboundTrackedBudget) {
				// Shared process pressure is not evidence that this particular socket is
				// slow. Skip this online accelerator; durable qts/difference is the truth.
				continue
			}
			if errors.Is(err, ErrOutboundQueueFull) {
				c.dropSlowConsumer()
				continue
			}
			if errors.Is(err, ErrConnClosed) {
				continue
			}
			if firstErr == nil {
				firstErr = err
			}
			continue
		}
		sent++
	}
	return sent, firstErr
}

func (m *SessionManager) pushToUser(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg tg.UpdatesClass) (int, error) {
	getUpdates := onceLayerUpdatesFanout(ctx, msg)
	return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, nil, 0, t, getUpdates, true, func(c *Conn) error {
		if c.outbound == nil || c.outboundControl == nil {
			return ErrConnClosed
		}
		updates, err := getUpdates()
		if err != nil {
			return err
		}
		encoded, err := updates.prepareForConn(ctx, c)
		if err != nil {
			return err
		}
		return c.SendEncoded(ctx, t, encoded)
	})
}

// PushToUserTransientExceptAuthKeySession pushes transient (short-lived, not
// writing the durable log) updates such as typing / presence. The key difference
// from an ordinary push: when a session is not ready (receivesUpdates=false) that
// connection is skipped directly and does not enter pending—transient data cannot
// be caught up by getDifference, and once ready is rebuilt from the getState
// snapshot / the next state change; hoarding stale transient data is both
// meaningless and would be wrongly treated as a "durable fallback" discard by
// pending's aging/overflow/exhausted retries. It uses a best-effort send and does
// not block the caller.
func (m *SessionManager) PushToUserTransientExceptAuthKeySession(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
	getUpdates := onceLayerUpdatesFanout(ctx, msg)
	return m.pushToUserWithSender(ctx, userID, &excludeAuthKeyID, excludeSessionID, nil, 0, t, getUpdates, false, func(c *Conn) error {
		if c.outbound == nil || c.outboundControl == nil {
			return ErrConnClosed
		}
		updates, err := getUpdates()
		if err != nil {
			return err
		}
		encoded, err := updates.prepareForConn(ctx, c)
		if err != nil {
			return err
		}
		return c.SendBestEffortEncoded(ctx, t, encoded, timeout)
	})
}

func (m *SessionManager) PushToUserTransientCompatible(ctx context.Context, userID int64, semantic tlprofile.SemanticID, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
	getUpdates := onceLayerUpdatesFanout(ctx, msg)
	return m.pushToUserWithSender(ctx, userID, nil, 0, nil, semantic, t, getUpdates, false, func(c *Conn) error {
		if c.outbound == nil || c.outboundControl == nil {
			return ErrConnClosed
		}
		updates, err := getUpdates()
		if err != nil {
			return err
		}
		encoded, err := updates.prepareForConn(ctx, c)
		if err != nil {
			return err
		}
		return c.SendBestEffortEncoded(ctx, t, encoded, timeout)
	})
}

func (m *SessionManager) PushToUserExceptAuthKeySessionBestEffort(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
	return m.pushToUserBestEffort(ctx, userID, &excludeAuthKeyID, excludeSessionID, t, msg, timeout)
}

func (m *SessionManager) pushToUserBestEffort(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
	if ctx != nil && ctx.Err() != nil {
		return 0, ctx.Err()
	}
	sendCtx := context.Background()
	if ctx != nil {
		sendCtx = context.WithoutCancel(ctx)
	}
	// timeout is the wait budget for the whole fan-out, not one per session. Healthy
	// connections always take SendBestEffortEncoded's non-blocking fast path first;
	// after the budget is exhausted remaining=0, the fast path is still attempted,
	// but no more serial waiting is done for subsequent slow connections.
	var deadline time.Time
	if timeout > 0 {
		deadline = time.Now().Add(timeout)
	}
	if ctx != nil {
		if ctxDeadline, ok := ctx.Deadline(); ok && (deadline.IsZero() || ctxDeadline.Before(deadline)) {
			deadline = ctxDeadline
		}
	}
	if !deadline.IsZero() {
		var cancel context.CancelFunc
		sendCtx, cancel = context.WithDeadline(sendCtx, deadline)
		defer cancel()
	}
	getUpdates := onceLayerUpdatesFanout(sendCtx, msg)
	return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, nil, 0, t, getUpdates, true, func(c *Conn) error {
		if c.outbound == nil || c.outboundControl == nil {
			return ErrConnClosed
		}
		updates, err := getUpdates()
		if err != nil {
			return err
		}
		encoded, err := updates.prepareForConn(sendCtx, c)
		if err != nil {
			return err
		}
		remaining := timeout
		if !deadline.IsZero() {
			remaining = time.Until(deadline)
			if remaining < 0 {
				remaining = 0
			}
		}
		return c.SendBestEffortEncoded(sendCtx, t, encoded, remaining)
	})
}

func newLayerUpdatesFanoutContext(ctx context.Context, msg tg.UpdatesClass) (*layerUpdatesFanout, error) {
	var updates *layerUpdatesFanout
	err := withOutboundEncodeSlot(ctx, nil, func() error {
		var err error
		updates, err = newLayerUpdatesFanout(msg)
		return err
	})
	return updates, err
}

func onceLayerUpdatesFanout(ctx context.Context, msg tg.UpdatesClass) func() (*layerUpdatesFanout, error) {
	var (
		once    sync.Once
		updates *layerUpdatesFanout
		err     error
	)
	return func() (*layerUpdatesFanout, error) {
		once.Do(func() {
			updates, err = newLayerUpdatesFanoutContext(ctx, msg)
		})
		return updates, err
	}
}

func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, excludeBusinessAuthKeyID *[8]byte, semantic tlprofile.SemanticID, t proto.MessageType, getUpdates func() (*layerUpdatesFanout, error), queueWhenNotReady bool, send func(*Conn) error) (int, error) {
	// push fan-out is one of the connection layer's hottest paths: debug log field
	// construction (including auth_key hex formatting) is evaluated even when debug
	// is off, so check the level once and log on demand.
	debug := m.log.Core().Enabled(zapcore.DebugLevel)
	// Fast path: in steady state all target connections are ready (or transient
	// directly skips the not-ready ones), and collecting connections is read-only
	// and does not write, so a shared read lock suffices throughout, avoiding taking
	// an exclusive write lock to serialize the whole registry on every push. Only
	// when a durable push hits a not-ready connection (needing to write pending)
	// does it fall back to a write lock and rescan.
	m.mu.RLock()
	total := len(m.byUser[userID])
	conns := make([]*Conn, 0, total)
	queued := 0
	dropped := 0
	excluded := 0
	skipped := 0
	needQueue := false
	for _, c := range m.byUser[userID] {
		if shouldExcludeSession(c, excludeAuthKeyID, excludeSessionID) || shouldExcludeBusinessAuthKey(c, excludeBusinessAuthKeyID) {
			excluded++
			continue
		}
		if !sessionSupportsSemantic(c, semantic) {
			skipped++
			continue
		}
		if !c.receivesUpdates.Load() {
			if !queueWhenNotReady {
				// transient (typing/presence): dropped if not ready, not entering pending.
				// These updates do not write the durable log and cannot be caught up by
				// getDifference; once ready they are rebuilt from the getState
				// snapshot/next state change.
				skipped++
				continue
			}
			needQueue = true
			break
		}
		conns = append(conns, c)
	}
	m.mu.RUnlock()
	if needQueue {
		// TL encoding and the process-wide pending-byte reservation may be expensive or
		// briefly block on the global encode gate. Do both before taking SessionManager.mu,
		// then share the immutable body across every not-ready session found by the re-scan.
		pendingUpdates, pendingReservation, pendingErr := m.preparePendingPush(getUpdates)
		// Full rescan under the write lock (state may change between releasing the read
		// lock and here; the rescan result is authoritative).
		conns = conns[:0]
		queued, dropped, excluded, skipped = 0, 0, 0, 0
		m.mu.Lock()
		total = len(m.byUser[userID])
		for key, c := range m.byUser[userID] {
			if shouldExcludeSession(c, excludeAuthKeyID, excludeSessionID) || shouldExcludeBusinessAuthKey(c, excludeBusinessAuthKeyID) {
				excluded++
				continue
			}
			if !sessionSupportsSemantic(c, semantic) {
				skipped++
				continue
			}
			if !c.receivesUpdates.Load() {
				if !queueWhenNotReady {
					skipped++
					continue
				}
				if pendingErr == nil && m.queuePreparedLocked(key, t, pendingUpdates, pendingReservation) {
					queued++
					if debug {
						m.log.Debug("Push queued (session not updates-ready)",
							zap.Int64("user_id", userID),
							zap.String("auth_key_id", sessionKeyLog(key.authKeyID)),
							zap.Int64("session_id", key.sessionID),
						)
					}
				} else {
					dropped++
					if debug {
						m.log.Debug("Push dropped (stale pending; durable log covers)",
							zap.Int64("user_id", userID),
							zap.String("auth_key_id", sessionKeyLog(key.authKeyID)),
							zap.Int64("session_id", key.sessionID),
						)
					}
				}
				continue
			}
			conns = append(conns, c)
		}
		m.mu.Unlock()
		if pendingReservation != nil {
			pendingReservation.release() // drop producer ref; queued entries own the body now.
		}
		if pendingErr != nil && debug {
			m.log.Debug("Drop pending pushes outside byte budget",
				zap.Int64("user_id", userID),
				zap.Error(pendingErr),
			)
		}
	}

	var firstErr error
	sent := 0
	for _, c := range conns {
		// Recheck identity before sending outside the lock: between collecting conns
		// and now, a connection may have been concurrently rebound (logout/account
		// switch, bindUserLocked's c.userID.Swap). Without the recheck, an update
		// belonging to userID would be delivered to a connection that has changed
		// hands, constituting a cross-account leak. Consistent with the same defense
		// in AddUserChannelMembership.
		if c.userID.Load() != userID {
			continue
		}
		if shouldExcludeBusinessAuthKey(c, excludeBusinessAuthKeyID) {
			continue
		}
		if err := send(c); err != nil {
			if isOutboundStaleLayerEpoch(err) {
				// Do not classify profile correction as slow-consumer evidence.
				dropped++
				continue
			}
			if isOutboundLayerProfileError(err) {
				c.dropSlowConsumer()
				dropped++
				continue
			}
			if errors.Is(err, ErrOutboundTrackedBudget) {
				// Do not turn pressure owned by other sockets into a reconnect storm on
				// healthy recipients. The durable event remains recoverable by difference.
				dropped++
				continue
			}
			// For durable/best-effort fan-out, a full queue means this socket has become
			// a slow consumer. Remove it immediately and treat it as offline: its error
			// must not cause a wholesale retry of outbox rows already delivered to
			// healthy sessions. This session's durable gap is recovered by getDifference.
			if errors.Is(err, ErrOutboundQueueFull) {
				c.dropSlowConsumer()
				if debug {
					m.log.Debug("Drop slow outbound consumer",
						zap.Int64("user_id", userID),
						zap.String("auth_key_id", sessionKeyLog(c.authKeyID)),
						zap.Int64("session_id", c.sessionID),
					)
				}
				continue
			}
			if errors.Is(err, ErrConnClosed) {
				continue
			}
			if firstErr == nil {
				firstErr = err
			}
			if debug {
				m.log.Debug("Push to conn failed",
					zap.Int64("user_id", userID),
					zap.String("auth_key_id", sessionKeyLog(c.authKeyID)),
					zap.Int64("session_id", c.sessionID),
					zap.Error(err),
				)
			}
			continue
		}
		sent++
		if debug {
			m.log.Debug("Push to conn ok",
				zap.Int64("user_id", userID),
				zap.String("auth_key_id", sessionKeyLog(c.authKeyID)),
				zap.Int64("session_id", c.sessionID),
			)
		}
	}
	if debug {
		if total == 0 {
			m.log.Debug("Push to user: no active conns", zap.Int64("user_id", userID))
		} else if excluded > 0 || queued > 0 || dropped > 0 || skipped > 0 || sent < len(conns) {
			m.log.Debug("Push to user summary",
				zap.Int64("user_id", userID),
				zap.Int("conns", total),
				zap.Int("sent", sent),
				zap.Int("queued", queued),
				zap.Int("dropped", dropped),
				zap.Int("skipped_transient", skipped),
				zap.Int("excluded", excluded),
			)
		}
	}
	return sent + queued, firstErr
}

// ActiveRawAuthKeyIDs returns a deduplicated snapshot of the raw auth_key_ids
// actually used by current physical connections. maintenance uses it to protect
// long-lived connections that "have built a key but not yet logged in" from being
// deleted by the orphan GC; business/temp→perm keys cannot substitute for this, or
// active temp connections could still be wrongly deleted.
func (m *SessionManager) ActiveRawAuthKeyIDs() [][8]byte {
	m.mu.RLock()
	defer m.mu.RUnlock()
	seen := make(map[[8]byte]struct{}, len(m.bySession))
	out := make([][8]byte, 0, len(m.byAuthKey))
	for key := range m.bySession {
		if _, ok := seen[key.authKeyID]; ok {
			continue
		}
		seen[key.authKeyID] = struct{}{}
		out = append(out, key.authKeyID)
	}
	return out
}

// IsUserOnline returns whether userID has at least one active connection.
func (m *SessionManager) IsUserOnline(userID int64) bool {
	if userID == 0 {
		return false
	}
	m.mu.RLock()
	defer m.mu.RUnlock()
	return len(m.byUser[userID]) > 0
}

// OnlineUserIDsForCandidates filters an explicit candidate set against the
// active user index. It avoids exporting or sorting the whole online map.
func (m *SessionManager) OnlineUserIDsForCandidates(candidateUserIDs []int64, limit int) []int64 {
	if len(candidateUserIDs) == 0 {
		return nil
	}
	m.mu.RLock()
	defer m.mu.RUnlock()
	out := make([]int64, 0, min(len(candidateUserIDs), positiveLimitOrLen(limit, len(candidateUserIDs))))
	seen := make(map[int64]struct{}, len(candidateUserIDs))
	for _, userID := range candidateUserIDs {
		if userID == 0 {
			continue
		}
		if _, ok := seen[userID]; ok {
			continue
		}
		seen[userID] = struct{}{}
		if len(m.byUser[userID]) == 0 {
			continue
		}
		out = append(out, userID)
		if limit > 0 && len(out) >= limit {
			break
		}
	}
	return out
}

// TrackChannelInterest replaces the channel viewer set for one live session.
// Realtime transient fan-out uses this as the current active-viewer candidate
// set; durable channel updates use the broader membership index instead.
func (m *SessionManager) TrackChannelInterest(rawAuthKeyID [8]byte, sessionID, userID int64, channelIDs []int64) {
	if userID == 0 {
		return
	}
	key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}
	m.mu.Lock()
	defer m.mu.Unlock()
	c, ok := m.bySession[key]
	if !ok || c.userID.Load() != userID {
		return
	}
	m.clearChannelInterestsLocked(key)
	if len(channelIDs) == 0 {
		return
	}
	m.trackChannelIndexLocked(m.byChannel, m.bySessionChannels, key, userID, channelIDs)
}

// ClearChannelInterest removes the active-viewer channel set for one live
// session while leaving its joined-channel membership index intact.
func (m *SessionManager) ClearChannelInterest(rawAuthKeyID [8]byte, sessionID, userID int64) {
	if userID == 0 {
		return
	}
	key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}
	m.mu.Lock()
	defer m.mu.Unlock()
	c, ok := m.bySession[key]
	if !ok || c.userID.Load() != userID {
		return
	}
	m.clearChannelInterestsLocked(key)
}

// OnlineChannelUserIDs returns users with active sessions that have recently
// proven current interest in channelID. The result is intentionally unsorted and bounded.
func (m *SessionManager) OnlineChannelUserIDs(channelID int64, limit int) []int64 {
	return m.onlineChannelUsers(m.byChannel, channelID, limit)
}

// RefreshChannelSubscription refreshes one public-channel passive-update
// subscription without replacing the other short-polled channels of the same
// session. The index is runtime-only and bounded to the official client limit.
func (m *SessionManager) RefreshChannelSubscription(rawAuthKeyID [8]byte, sessionID, userID, channelID int64, ttl time.Duration) {
	if userID == 0 || channelID == 0 {
		return
	}
	if ttl <= 0 {
		ttl = defaultChannelSubscriptionTTL
	} else if ttl > maxChannelSubscriptionTTL {
		ttl = maxChannelSubscriptionTTL
	}
	key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}
	now := time.Now().UnixNano()
	expiresAt := now + int64(ttl)
	m.mu.Lock()
	defer m.mu.Unlock()
	c, ok := m.bySession[key]
	if !ok || c.userID.Load() != userID {
		return
	}
	m.pruneSessionSubscriptionsLocked(key, now)
	channels := m.bySessionSubscriptions[key]
	if channels == nil {
		channels = make(map[int64]int64, 1)
		m.bySessionSubscriptions[key] = channels
	}
	if _, exists := channels[channelID]; !exists && len(channels) >= maxChannelSubscriptionsPerSession {
		m.log.Warn("Channel passive subscription ignored at per-session cap",
			zap.String("auth_key_id", sessionKeyLog(rawAuthKeyID)),
			zap.Int64("session_id", sessionID),
			zap.Int64("channel_id", channelID),
			zap.Int("cap", maxChannelSubscriptionsPerSession))
		return
	}
	channels[channelID] = expiresAt
	sessions := m.bySubscribedChannel[channelID]
	if sessions == nil {
		sessions = make(map[sessionKey]channelSubscription)
		m.bySubscribedChannel[channelID] = sessions
	}
	sessions[key] = channelSubscription{userID: userID, expiresAt: expiresAt}
}

// OnlineChannelSubscriberUserIDs returns users for which at least one live
// session still holds an unexpired short-poll subscription. The user is
// deduplicated because passive updates are subsequently pushed account-wide.
func (m *SessionManager) OnlineChannelSubscriberUserIDs(channelID int64, limit int) []int64 {
	return m.onlineChannelSubscriberUserIDsExcluding(channelID, nil, limit)
}

func (m *SessionManager) OnlineChannelSubscriberUserIDsExcluding(channelID int64, exclude map[int64]struct{}, limit int) []int64 {
	return m.onlineChannelSubscriberUserIDsExcluding(channelID, exclude, limit)
}

func (m *SessionManager) onlineChannelSubscriberUserIDsExcluding(channelID int64, exclude map[int64]struct{}, limit int) []int64 {
	if channelID == 0 {
		return nil
	}
	now := time.Now().UnixNano()
	m.mu.Lock()
	defer m.mu.Unlock()
	sessions := m.bySubscribedChannel[channelID]
	if len(sessions) == 0 {
		return nil
	}
	out := make([]int64, 0, positiveLimitOrLen(limit, len(sessions)))
	seen := make(map[int64]struct{}, positiveLimitOrLen(limit, len(sessions)))
	for key, subscription := range sessions {
		if subscription.expiresAt <= now {
			m.removeChannelSubscriptionLocked(key, channelID)
			continue
		}
		if subscription.userID == 0 {
			continue
		}
		if _, ok := exclude[subscription.userID]; ok {
			continue
		}
		c, ok := m.bySession[key]
		if !ok || c.userID.Load() != subscription.userID {
			m.removeChannelSubscriptionLocked(key, channelID)
			continue
		}
		if _, ok := seen[subscription.userID]; ok {
			continue
		}
		seen[subscription.userID] = struct{}{}
		out = append(out, subscription.userID)
		if limit > 0 && len(out) >= limit {
			break
		}
	}
	return out
}

// ChannelMembershipGeneration returns the membership index revision number of that
// session. The full-sync party must sample it *before* reading the persistent
// membership list and carry it back for comparison via
// SetSessionChannelMemberships; when the session is offline it returns 0 (a later
// Set also gives up because it cannot find the connection).
func (m *SessionManager) ChannelMembershipGeneration(rawAuthKeyID [8]byte, sessionID int64) int64 {
	m.mu.RLock()
	c, ok := m.bySession[sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}]
	m.mu.RUnlock()
	if !ok {
		return 0
	}
	return c.membershipGen.Load()
}

// SetSessionChannelMemberships replaces the joined-channel index for one
// updates-ready session. This index is broader than TrackChannelInterest and is
// used for durable channel updates such as new/edit/delete message.
//
// expectedGen is the revision number the caller sampled via
// ChannelMembershipGeneration before reading the persistent membership list. If the
// revision has changed on commit (an incremental revision from join/leave/kick or a
// wholesale clear happened inside the read window), a full replacement would
// overwrite the in-window increment—in that case it switches to a union merge
// (retaining incremental Adds; stale entries merged back are caught by the
// pre-fan-out PG active recheck) and keeps membershipsSynced=false, so the next RPC
// runs a fresh full sync to converge.
func (m *SessionManager) SetSessionChannelMemberships(rawAuthKeyID [8]byte, sessionID, userID int64, channelIDs []int64, expectedGen int64) {
	key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}
	m.mu.Lock()
	defer m.mu.Unlock()
	c, ok := m.bySession[key]
	if !ok {
		return
	}
	if userID == 0 || c.userID.Load() != userID {
		m.clearChannelMembershipsLocked(c, key)
		c.membershipsSynced.Store(false)
		return
	}
	if c.membershipGen.Load() != expectedGen {
		c.membershipsSynced.Store(false)
		m.trackChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key, userID, channelIDs)
		m.log.Debug("Channel membership sync raced with incremental updates; merged and kept unsynced",
			zap.String("auth_key_id", sessionKeyLog(rawAuthKeyID)),
			zap.Int64("session_id", sessionID),
		)
		return
	}
	m.clearChannelMembershipsLocked(c, key)
	c.membershipsSynced.Store(false)
	m.trackChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key, userID, channelIDs)
	c.membershipsSynced.Store(true)
}

// AddUserChannelMembership adds channelID to every live session for userID.
// It is called after successful join/invite approval paths.
func (m *SessionManager) AddUserChannelMembership(userID, channelID int64) {
	if userID == 0 || channelID == 0 {
		return
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	for key, c := range m.byUser[userID] {
		if c == nil || c.userID.Load() != userID {
			continue
		}
		c.membershipGen.Add(1)
		m.trackChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key, userID, []int64{channelID})
	}
}

// RemoveUserChannelMembership removes channelID from every live session for userID.
// It is called after leave/kick/ban/delete paths.
func (m *SessionManager) RemoveUserChannelMembership(userID, channelID int64) {
	if userID == 0 || channelID == 0 {
		return
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	for key, c := range m.byUser[userID] {
		if c != nil {
			c.membershipGen.Add(1)
		}
		m.removeChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key, channelID)
	}
}

// OnlineChannelMemberUserIDs returns users with active sessions that are indexed
// as joined members of channelID. The result is intentionally unsorted; callers
// still verify business membership before pushing.
func (m *SessionManager) OnlineChannelMemberUserIDs(channelID int64, limit int) []int64 {
	return m.onlineChannelUsers(m.byMemberChannel, channelID, limit)
}

// OnlineChannelMemberUserIDsExcluding returns the user ids of a channel's online
// members that are not in the exclude set, used for the UpdateChannelTooLong nudge
// when online members exceed the cap (P0-8): the full payload has already been
// delivered to the exclude set (in-cap members), and the remaining online members
// only get a cheap nudge prompting their getChannelDifference. A single RLock
// snapshot; the caller builds exclude from "recipients that already got the full
// payload", so the same user does not receive both the payload and the nudge—this
// naturally avoids boundary double-send/missed-send between two independent cap
// calls (design §8-D3/D32). limit prevents an unbounded nudge storm.
// No PG active recheck is done: byMemberChannel is already maintained on
// join/leave/kick; the nudge is cheap and idempotent, and extra nudges to members
// who just left are harmless (their getChannelDifference has its own access check).
func (m *SessionManager) OnlineChannelMemberUserIDsExcluding(channelID int64, exclude map[int64]struct{}, limit int) []int64 {
	if channelID == 0 {
		return nil
	}
	m.mu.RLock()
	defer m.mu.RUnlock()
	sessions := m.byMemberChannel[channelID]
	if len(sessions) == 0 {
		return nil
	}
	out := make([]int64, 0, positiveLimitOrLen(limit, len(sessions)))
	seen := make(map[int64]struct{}, len(sessions))
	for key, userID := range sessions {
		if userID == 0 {
			continue
		}
		if _, ok := exclude[userID]; ok {
			continue
		}
		if _, ok := m.bySession[key]; !ok {
			continue
		}
		if _, ok := seen[userID]; ok {
			continue
		}
		seen[userID] = struct{}{}
		out = append(out, userID)
		if limit > 0 && len(out) >= limit {
			break
		}
	}
	return out
}

// OnlineChannelIDsSnapshot returns every channel with at least one live joined-member session or
// unexpired passive subscriber in strictly ascending order. The global SessionManager lock is held
// only while copying map keys and pruning expired subscription entries;
// sorting and all recovery database work happen after unlock. The fixed saturation-recovery actor
// is the sole caller, so its exceptional-path temporary memory is one int64 slice (peak about 8*C
// bytes) rather than repeated O(C) scans under the connection/membership lock.
func (m *SessionManager) OnlineChannelIDsSnapshot() []int64 {
	m.mu.Lock()
	now := time.Now().UnixNano()
	seen := make(map[int64]struct{}, len(m.byMemberChannel)+len(m.bySubscribedChannel))
	out := make([]int64, 0, len(m.byMemberChannel)+len(m.bySubscribedChannel))
	for channelID, sessions := range m.byMemberChannel {
		if channelID <= 0 || len(sessions) == 0 {
			continue
		}
		live := false
		for key := range sessions {
			if _, ok := m.bySession[key]; ok {
				live = true
				break
			}
		}
		if !live {
			continue
		}
		seen[channelID] = struct{}{}
		out = append(out, channelID)
	}
	for channelID, sessions := range m.bySubscribedChannel {
		if channelID <= 0 || len(sessions) == 0 {
			continue
		}
		live := false
		for key, subscription := range sessions {
			if subscription.expiresAt <= now {
				m.removeChannelSubscriptionLocked(key, channelID)
				continue
			}
			c, ok := m.bySession[key]
			if !ok || c.userID.Load() != subscription.userID {
				m.removeChannelSubscriptionLocked(key, channelID)
				continue
			}
			live = true
		}
		if !live {
			continue
		}
		if _, exists := seen[channelID]; exists {
			continue
		}
		out = append(out, channelID)
	}
	m.mu.Unlock()
	sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
	return out
}

func (m *SessionManager) onlineChannelUsers(index map[int64]map[sessionKey]int64, channelID int64, limit int) []int64 {
	if channelID == 0 {
		return nil
	}
	m.mu.RLock()
	defer m.mu.RUnlock()
	sessions := index[channelID]
	if len(sessions) == 0 {
		return nil
	}
	out := make([]int64, 0, positiveLimitOrLen(limit, len(sessions)))
	seen := make(map[int64]struct{}, len(sessions))
	for key, userID := range sessions {
		if userID == 0 {
			continue
		}
		if _, ok := m.bySession[key]; !ok {
			continue
		}
		if _, ok := seen[userID]; ok {
			continue
		}
		seen[userID] = struct{}{}
		out = append(out, userID)
		if limit > 0 && len(out) >= limit {
			break
		}
	}
	return out
}

func (m *SessionManager) removeLocked(c *Conn, dropPending bool) int64 {
	key := connSessionKey(c)
	if m.bySession[key] != c {
		return 0
	}
	delete(m.bySession, key)
	removeConnIndex(m.byAuthKey, c.authKeyID, c.sessionID)
	if businessAuthKeyID, resolved := c.BusinessAuthKeyID(); resolved {
		removeBusinessAuthKeyIndex(m.byBusinessAuthKey, businessAuthKeyID, key)
	}
	uid := c.userID.Load()
	if uid != 0 {
		removeUserIndex(m.byUser, uid, key)
	}
	m.clearSessionChannelIndexesLocked(c, key)
	m.clearUpdatesActivationLocked(c)
	if dropPending {
		m.deletePendingLocked(key)
	}
	delete(m.flushing, key)
	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] {
		if cur := m.bySession[key]; cur == c {
			out[key] = c
		}
	}
	for sessionID, c := range m.byAuthKey[authKeyID] {
		key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
		if cur := m.bySession[key]; cur == c {
			out[key] = c
		}
	}
	return out
}

func (m *SessionManager) clearChannelInterestsLocked(key sessionKey) {
	m.clearChannelIndexLocked(m.byChannel, m.bySessionChannels, key)
}

func (m *SessionManager) clearChannelSubscriptionsLocked(key sessionKey) {
	channels := m.bySessionSubscriptions[key]
	if len(channels) == 0 {
		delete(m.bySessionSubscriptions, key)
		return
	}
	for channelID := range channels {
		sessions := m.bySubscribedChannel[channelID]
		delete(sessions, key)
		if len(sessions) == 0 {
			delete(m.bySubscribedChannel, channelID)
		}
	}
	delete(m.bySessionSubscriptions, key)
}

func (m *SessionManager) pruneSessionSubscriptionsLocked(key sessionKey, now int64) {
	channels := m.bySessionSubscriptions[key]
	for channelID, expiresAt := range channels {
		if expiresAt <= now {
			m.removeChannelSubscriptionLocked(key, channelID)
		}
	}
}

func (m *SessionManager) removeChannelSubscriptionLocked(key sessionKey, channelID int64) {
	channels := m.bySessionSubscriptions[key]
	delete(channels, channelID)
	if len(channels) == 0 {
		delete(m.bySessionSubscriptions, key)
	}
	sessions := m.bySubscribedChannel[channelID]
	delete(sessions, key)
	if len(sessions) == 0 {
		delete(m.bySubscribedChannel, channelID)
	}
}

func (m *SessionManager) clearSessionChannelIndexesLocked(c *Conn, key sessionKey) {
	m.clearChannelInterestsLocked(key)
	m.clearChannelSubscriptionsLocked(key)
	m.clearChannelMembershipsLocked(c, key)
}

// clearChannelMembershipsLocked wholesale clears a connection's membership index
// and increments its revision number, so an in-flight full sync
// (SetSessionChannelMemberships) can detect the clear and abandon a stale replacement.
func (m *SessionManager) clearChannelMembershipsLocked(c *Conn, key sessionKey) {
	c.membershipGen.Add(1)
	m.clearChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key)
}

func (m *SessionManager) trackChannelIndexLocked(index map[int64]map[sessionKey]int64, reverse map[sessionKey]map[int64]struct{}, key sessionKey, userID int64, channelIDs []int64) {
	channels := reverse[key]
	if channels == nil {
		channels = make(map[int64]struct{}, len(channelIDs))
		reverse[key] = channels
	}
	truncated := 0
	for _, channelID := range channelIDs {
		if channelID == 0 {
			continue
		}
		if _, exists := channels[channelID]; !exists && len(channels) >= maxChannelIndexPerSession {
			// Per-session cap reached: drop the excess channel registrations (this only
			// affects that channel's live/member push routing; durable updates are still
			// caught by getDifference/getChannelDifference).
			truncated++
			continue
		}
		channels[channelID] = struct{}{}
		sessions := index[channelID]
		if sessions == nil {
			sessions = make(map[sessionKey]int64)
			index[channelID] = sessions
		}
		sessions[key] = userID
	}
	if truncated > 0 {
		m.log.Warn("Channel index truncated for session at per-session cap",
			zap.String("auth_key_id", sessionKeyLog(key.authKeyID)),
			zap.Int64("session_id", key.sessionID),
			zap.Int("cap", maxChannelIndexPerSession),
			zap.Int("truncated", truncated),
		)
	}
}

func (m *SessionManager) clearChannelIndexLocked(index map[int64]map[sessionKey]int64, reverse map[sessionKey]map[int64]struct{}, key sessionKey) {
	channels := reverse[key]
	if len(channels) == 0 {
		delete(reverse, key)
		return
	}
	for channelID := range channels {
		sessions := index[channelID]
		delete(sessions, key)
		if len(sessions) == 0 {
			delete(index, channelID)
		}
	}
	delete(reverse, key)
}

func (m *SessionManager) removeChannelIndexLocked(index map[int64]map[sessionKey]int64, reverse map[sessionKey]map[int64]struct{}, key sessionKey, channelID int64) {
	channels := reverse[key]
	delete(channels, channelID)
	if len(channels) == 0 {
		delete(reverse, key)
	}
	sessions := index[channelID]
	delete(sessions, key)
	if len(sessions) == 0 {
		delete(index, channelID)
	}
}

func positiveLimitOrLen(limit, length int) int {
	if limit > 0 && limit < length {
		return limit
	}
	return length
}

func (m *SessionManager) takePendingLocked(key sessionKey, ready bool) []queuedPush {
	if !ready || len(m.pending[key]) == 0 {
		return nil
	}
	q := m.pending[key]
	delete(m.pending, key)
	// Filter out over-age entries on take: staging only bridges the "registered to
	// ready" window, so outdated updates (including transient ones) hoarded during a
	// long not-ready period should not be delivered as-is minutes later; durable
	// events are caught by user_update_events + getDifference, so dropping loses no data.
	now := time.Now()
	pending := make([]queuedPush, 0, len(q))
	dropped := 0
	for i := range q {
		item := q[i]
		q[i] = queuedPush{}
		if now.Sub(item.at) > pendingPushMaxAge {
			item.release()
			dropped++
			continue
		}
		pending = append(pending, item)
	}
	if dropped > 0 {
		m.log.Debug("Drop stale pending pushes on take",
			zap.String("auth_key_id", sessionKeyLog(key.authKeyID)),
			zap.Int64("session_id", key.sessionID),
			zap.Int("dropped", dropped),
		)
	}
	return pending
}

// preparePendingPush reserves the one frozen canonical semantic snapshot. Exact
// wire bytes are deliberately not retained here: they are prepared only when a
// target physical connection has a frozen profile, then cached once per profile
// by layerUpdatesFanout.
func (m *SessionManager) preparePendingPush(getUpdates func() (*layerUpdatesFanout, error)) (*layerUpdatesFanout, *pendingPushReservation, error) {
	updates, err := getUpdates()
	if err != nil {
		return nil, nil, err
	}
	if updates == nil {
		return nil, nil, errors.New("nil pending layer updates")
	}
	bytes := updates.canonicalSize()
	if bytes > maxOutboundBodyBytes {
		return nil, nil, fmt.Errorf("%w: body=%d limit=%d", ErrOutboundMessageTooLarge, bytes, maxOutboundBodyBytes)
	}
	if !m.pendingBudget.reserve(bytes) {
		return nil, nil, ErrOutboundTrackedBudget
	}
	reservation := &pendingPushReservation{budget: m.pendingBudget}
	reservation.bytes.Store(int64(bytes))
	reservation.refs.Store(1) // producer ownership; queue entries retain below.
	return updates, reservation, nil
}

// queuePreparedLocked stages a frozen proactive push and returns whether it was
// actually enqueued. The caller must hold the reservation's producer ref outside
// the lock and release it after all enqueues complete.
func (m *SessionManager) queuePreparedLocked(key sessionKey, t proto.MessageType, updates *layerUpdatesFanout, reservation *pendingPushReservation) bool {
	q := m.pending[key]
	// Expiry protection: when the earliest staged entry has exceeded
	// pendingPushMaxAge (the session has long been not ready), drop the whole batch
	// and stop hoarding this one, logging a trace. This avoids a connection that
	// "never calls getState after login" occupying pending memory long term.
	if len(q) > 0 && time.Since(q[0].at) > pendingPushMaxAge {
		m.log.Debug("Drop stale pending pushes (session not ready in time)",
			zap.String("auth_key_id", sessionKeyLog(key.authKeyID)),
			zap.Int64("session_id", key.sessionID),
			zap.Int("dropped", len(q)),
		)
		m.deletePendingLocked(key)
		return false
	}
	if updates == nil || reservation == nil {
		return false
	}
	reservation.retain()
	push := queuedPush{
		t:           t,
		updates:     updates,
		reservation: reservation,
		at:          time.Now(),
	}
	if len(q) >= maxPendingPushesPerSession {
		q[0].release()
		copy(q, q[1:])
		q[len(q)-1] = push
		m.pending[key] = q
		return true
	}
	m.pending[key] = append(q, push)
	return true
}

// RunPendingSweeper periodically reclaims long-lingering pending staged data:
// passive aging (queueLocked/takePendingLocked) only triggers on "a new push" or
// "taken when ready", and cannot reclaim the over-age pending of a connection that
// "has registered but never calls getState, happens to have no new push, and does
// not disconnect either (kept alive by pings)". This sweeper provides an active
// fallback, consistent with the pendingPushMaxAge threshold, dropping only
// over-age whole batches and not touching a session that is draining (flushing).
func (m *SessionManager) RunPendingSweeper(ctx context.Context, interval time.Duration) {
	if interval <= 0 {
		interval = time.Minute
	}
	ticker := time.NewTicker(interval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
		}
		m.sweepStalePending()
		m.sweepLogicalSessions(time.Now())
	}
}

func (m *SessionManager) sweepStalePending() {
	now := time.Now()
	m.mu.Lock()
	defer m.mu.Unlock()
	dropped := 0
	for key, q := range m.pending {
		if m.flushing[key] {
			// The drain goroutine owns this batch; reclamation is left to runFlush to avoid racing with it.
			continue
		}
		if len(q) == 0 || now.Sub(q[0].at) <= pendingPushMaxAge {
			continue
		}
		m.deletePendingLocked(key)
		dropped++
	}
	if dropped > 0 {
		m.log.Debug("Swept stale pending sessions", zap.Int("dropped_sessions", dropped))
	}
}

func (q *queuedPush) release() {
	if q == nil {
		return
	}
	reservation := q.reservation
	*q = queuedPush{}
	reservation.release()
}

func releaseQueuedPushes(q []queuedPush) {
	for i := range q {
		q[i].release()
	}
}

func (m *SessionManager) deletePendingLocked(key sessionKey) {
	q := m.pending[key]
	delete(m.pending, key)
	releaseQueuedPushes(q)
}

func addConnIndex[K comparable](idx map[K]map[int64]*Conn, key K, sessionID int64, c *Conn) {
	set := idx[key]
	if set == nil {
		set = make(map[int64]*Conn)
		idx[key] = set
	}
	set[sessionID] = c
}

func removeConnIndex[K comparable](idx map[K]map[int64]*Conn, key K, sessionID int64) {
	if set := idx[key]; set != nil {
		delete(set, sessionID)
		if len(set) == 0 {
			delete(idx, key)
		}
	}
}

func addBusinessAuthKeyIndex(idx map[[8]byte]map[sessionKey]*Conn, authKeyID [8]byte, key sessionKey, c *Conn) {
	set := idx[authKeyID]
	if set == nil {
		set = make(map[sessionKey]*Conn)
		idx[authKeyID] = set
	}
	set[key] = c
}

func removeBusinessAuthKeyIndex(idx map[[8]byte]map[sessionKey]*Conn, authKeyID [8]byte, key sessionKey) {
	if set := idx[authKeyID]; set != nil {
		delete(set, key)
		if len(set) == 0 {
			delete(idx, authKeyID)
		}
	}
}

func addUserIndex(idx map[int64]map[sessionKey]*Conn, userID int64, key sessionKey, c *Conn) {
	set := idx[userID]
	if set == nil {
		set = make(map[sessionKey]*Conn)
		idx[userID] = set
	}
	set[key] = c
}

func removeUserIndex(idx map[int64]map[sessionKey]*Conn, userID int64, key sessionKey) {
	if set := idx[userID]; set != nil {
		delete(set, key)
		if len(set) == 0 {
			delete(idx, userID)
		}
	}
}

func connSessionKey(c *Conn) sessionKey {
	return sessionKey{authKeyID: c.authKeyID, sessionID: c.sessionID}
}

func connUsesBusinessAuthKey(c *Conn, authKeyID [8]byte) bool {
	id, resolved := c.BusinessAuthKeyID()
	if resolved {
		return id == authKeyID
	}
	return c.authKeyID == authKeyID
}

func shouldExcludeSession(c *Conn, excludeAuthKeyID *[8]byte, excludeSessionID int64) bool {
	if excludeSessionID == 0 {
		return false
	}
	if c.sessionID != excludeSessionID {
		return false
	}
	if excludeAuthKeyID == nil || *excludeAuthKeyID == ([8]byte{}) {
		return true
	}
	return c.authKeyID == *excludeAuthKeyID
}

func shouldExcludeBusinessAuthKey(c *Conn, excludeBusinessAuthKeyID *[8]byte) bool {
	if c == nil || excludeBusinessAuthKeyID == nil || *excludeBusinessAuthKeyID == ([8]byte{}) {
		return false
	}
	return connUsesBusinessAuthKey(c, *excludeBusinessAuthKeyID)
}

func sessionSupportsSemantic(c *Conn, semantic tlprofile.SemanticID) bool {
	if semantic == 0 {
		return true
	}
	if c == nil {
		return false
	}
	state := c.LayerProfileState()
	if state.Origin == LayerProfileUnknown {
		return false
	}
	_, ok := tlprofile.WireID(state.Profile, semantic)
	return ok
}

func sessionKeyLog(id [8]byte) string {
	return fmt.Sprintf("%x", id)
}