Skip to main content

ferogram_mtsender/
pool.rs

1/*
2 * Copyright (c) 2026 Ankit Chaubey <ankitchaubey.dev@gmail.com>
3 * https://github.com/ankit-chaubey
4 *
5 * Project: ferogram
6 * Website: https://ferogram.dev
7 *
8 * Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
9 * https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
10 * <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your option.
11 * This file may not be copied, modified, or distributed except according
12 * to those terms.
13 */
14
15use crate::errors::InvocationError;
16use crate::sender::DcConnection;
17use crate::sender_task::{FrameEvent, RpcEnqueue, spawn_sender_task};
18use ferogram_connect::util::maybe_gz_pack;
19use ferogram_connect::{Socks5Config, TransportKind};
20use ferogram_session::DcEntry;
21use ferogram_tl_types::{RemoteCall, Serializable};
22use std::collections::HashMap;
23use std::sync::Arc;
24use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
25use tokio::sync::{mpsc, oneshot};
26
27// Max simultaneous connections per DC.
28const MAX_CONNS_PER_DC: usize = 3;
29
30/// One slot in the per-DC connection pool.
31///
32/// Each slot is backed by a background sender task (see
33/// [`crate::sender_task::spawn_sender_task`]), not a locked `DcConnection`.
34/// Enqueueing a request just posts it to the task's mpsc channel and waits
35/// on a oneshot for the result: no lock is held across the network round
36/// trip, so any number of callers can have requests in flight on the same
37/// slot at once. The task itself batches whatever is pending into as few
38/// frames as possible and matches replies back to callers by msg_id,
39/// regardless of the order responses arrive in.
40///
41/// `in_flight` still lets the pool pick the least-busy slot without needing
42/// to touch the connection itself.
43pub struct ConnSlot {
44    rpc_tx: mpsc::Sender<RpcEnqueue>,
45    pub in_flight: AtomicUsize,
46    /// Set to `false` by the drain task below once the connection's sender
47    /// task reports an error. Callers check this after a failed call to
48    /// decide whether to evict the slot and retry on a fresh one, instead of
49    /// matching on the specific `InvocationError` variant (the sender task
50    /// always reports connection failures as `Deserialize`, since it has no
51    /// way to know whether a given caller still cares about the original
52    /// `Io`/etc. error kind once `fail_all` has fanned it out to everyone
53    /// waiting on this connection).
54    alive: Arc<AtomicBool>,
55    /// Snapshot of the auth key / salt / time offset taken when the slot was
56    /// created. Used by `collect_keys` to persist session info. The auth key
57    /// never changes for a slot's lifetime; salt and time offset can drift a
58    /// little as the connection runs (FutureSalts rotation), but a stale
59    /// value here only costs one bad_server_salt round trip the next time
60    /// this DC is reconnected, since the sender task self-corrects from
61    /// server-supplied corrections either way.
62    auth_key: [u8; 256],
63    first_salt: i64,
64    time_offset: i32,
65}
66
67/// Pool of per-DC authenticated connections.
68/// Each DC holds up to MAX_CONNS_PER_DC slots. The pool lock is dropped
69/// before any network I/O so concurrent callers don't serialize on it.
70pub struct DcPool {
71    /// Per-DC connection slots; inner Vec holds slot Arcs.
72    pub conns: HashMap<i32, Vec<Arc<ConnSlot>>>,
73    addrs: HashMap<i32, String>,
74    #[allow(dead_code)]
75    home_dc_id: i32,
76    /// Proxy config forwarded to auto-reconnect.
77    socks5: Option<Socks5Config>,
78    /// Transport kind reused for secondary DC connections.
79    transport: TransportKind,
80    /// DCs that have already received `invokeWithLayer(initConnection(...))`.
81    init_done: std::collections::HashSet<i32>,
82}
83
84impl DcPool {
85    /// Build an empty pool for `home_dc_id`, seeded with addresses for every
86    /// DC in `dc_entries`. No connections are opened yet; slots get created
87    /// lazily on first use of each DC.
88    pub fn new(
89        home_dc_id: i32,
90        dc_entries: &[DcEntry],
91        socks5: Option<Socks5Config>,
92        transport: TransportKind,
93    ) -> Self {
94        let addrs = dc_entries
95            .iter()
96            .map(|e| (e.dc_id, e.addr.clone()))
97            .collect();
98        Self {
99            conns: HashMap::new(),
100            addrs,
101            home_dc_id,
102            socks5,
103            transport,
104            init_done: std::collections::HashSet::new(),
105        }
106    }
107
108    /// Returns true if at least one connection slot exists for `dc_id`.
109    pub fn has_connection(&self, dc_id: i32) -> bool {
110        self.conns.get(&dc_id).is_some_and(|v| !v.is_empty())
111    }
112
113    /// Graduate an already set-up `DcConnection` into a pipelined slot.
114    ///
115    /// The connection has already done its DH / PFS bind / initConnection as
116    /// a plain `DcConnection`. From here on its socket is owned by a single
117    /// background task; this function just spawns that task and wraps the
118    /// resulting handle in a `ConnSlot`.
119    fn spawn_slot(conn: DcConnection) -> Arc<ConnSlot> {
120        let auth_key = conn.auth_key_bytes();
121        let first_salt = conn.first_salt();
122        let time_offset = conn.time_offset();
123        let (stream, frame_kind, enc) = conn.into_parts();
124
125        let (handle, mut frame_rx) = spawn_sender_task(stream, enc, frame_kind, None);
126
127        // Pool slots don't support reconnect: on failure the pool just
128        // evicts the whole DC and a fresh slot is opened from scratch on the
129        // next call. Dropping reconnect_tx here means the sender task's
130        // error branch sees its reconnect channel closed and shuts itself
131        // down cleanly instead of waiting for a reconnect that will never
132        // come.
133        drop(handle.reconnect_tx);
134
135        let alive = Arc::new(AtomicBool::new(true));
136        let alive_for_drain = alive.clone();
137        tokio::spawn(async move {
138            while let Some(event) = frame_rx.recv().await {
139                if let FrameEvent::Error(e) = event {
140                    tracing::warn!("[ferogram::pool] worker connection dropped: {e}");
141                    alive_for_drain.store(false, Ordering::Release);
142                    break;
143                }
144                // FrameEvent::Update / Connected: pool connections don't
145                // dispatch updates, nothing to do.
146            }
147        });
148
149        Arc::new(ConnSlot {
150            rpc_tx: handle.rpc_tx,
151            in_flight: AtomicUsize::new(0),
152            alive,
153            auth_key,
154            first_salt,
155            time_offset,
156        })
157    }
158
159    /// Insert a pre-built, already initialized connection into the pool as a
160    /// new slot.
161    pub fn insert(&mut self, dc_id: i32, conn: DcConnection) {
162        let slot = Self::spawn_slot(conn);
163        self.conns.entry(dc_id).or_default().push(slot);
164        let total: usize = self.conns.values().map(|v| v.len()).sum();
165        crate::metrics_shim::gauge!("ferogram.connections_active").set(total as f64);
166    }
167
168    /// Returns the least-loaded slot for `dc_id`, creating one if needed.
169    /// Creates a new slot if all existing ones are busy and count < MAX_CONNS_PER_DC.
170    /// Drop the DcPool guard before locking the returned slot.
171    pub(crate) async fn get_or_create_slot(
172        &mut self,
173        dc_id: i32,
174        pfs: bool,
175        auth_key: Option<([u8; 256], i64, i32)>,
176    ) -> Result<Arc<ConnSlot>, InvocationError> {
177        let addr = self.addrs.get(&dc_id).cloned().ok_or_else(|| {
178            InvocationError::Deserialize(format!("dc_pool: no address for DC{dc_id}"))
179        })?;
180
181        // Ensure at least one slot exists.
182        if !self.conns.contains_key(&dc_id) || self.conns[&dc_id].is_empty() {
183            tracing::debug!("[ferogram::pool] opening first connection to DC{dc_id} at {addr}");
184            let conn = if let Some((key, salt, offset)) = auth_key {
185                DcConnection::connect_with_key(
186                    &addr,
187                    key,
188                    salt,
189                    offset,
190                    self.socks5.as_ref(),
191                    None,
192                    &self.transport,
193                    dc_id as i16,
194                    pfs,
195                )
196                .await?
197            } else {
198                DcConnection::connect_raw(
199                    &addr,
200                    self.socks5.as_ref(),
201                    None,
202                    &self.transport,
203                    dc_id as i16,
204                )
205                .await?
206            };
207            let slot = Self::spawn_slot(conn);
208            self.conns.entry(dc_id).or_default().push(slot);
209            self.init_done.remove(&dc_id);
210            let total: usize = self.conns.values().map(|v| v.len()).sum();
211            crate::metrics_shim::gauge!("ferogram.connections_active").set(total as f64);
212        }
213
214        let slots = self
215            .conns
216            .get(&dc_id)
217            .expect("dc_id must be registered before use");
218
219        // pick least-busy slot
220        let best = slots
221            .iter()
222            .min_by_key(|s| s.in_flight.load(Ordering::Relaxed))
223            .expect("slots vec is non-empty")
224            .clone();
225        let min_inflight = best.in_flight.load(Ordering::Relaxed);
226
227        // Spawn a new slot if: all are busy AND we have room for more.
228        //
229        // With pipelined slots this matters less than it used to (a single
230        // slot can now happily carry many in-flight requests at once), but
231        // it's still worth spreading load across a few real TCP connections
232        // for very heavy transfers.
233        if min_inflight > 0 && slots.len() < MAX_CONNS_PER_DC {
234            tracing::debug!(
235                "[ferogram::pool] DC{dc_id}: all {} slots busy (min_inflight={min_inflight}), opening extra connection",
236                slots.len()
237            );
238            let conn = if let Some((key, salt, offset)) = auth_key {
239                DcConnection::connect_with_key(
240                    &addr,
241                    key,
242                    salt,
243                    offset,
244                    self.socks5.as_ref(),
245                    None,
246                    &self.transport,
247                    dc_id as i16,
248                    pfs,
249                )
250                .await?
251            } else {
252                DcConnection::connect_raw(
253                    &addr,
254                    self.socks5.as_ref(),
255                    None,
256                    &self.transport,
257                    dc_id as i16,
258                )
259                .await?
260            };
261            let new_slot = Self::spawn_slot(conn);
262            let arc = new_slot.clone();
263            self.conns
264                .get_mut(&dc_id)
265                .expect("dc_id must be registered")
266                .push(new_slot);
267            let total: usize = self.conns.values().map(|v| v.len()).sum();
268            crate::metrics_shim::gauge!("ferogram.connections_active").set(total as f64);
269            return Ok(arc);
270        }
271
272        Ok(best)
273    }
274
275    /// Evict all slots for a DC (called on connection failure to force
276    /// reconnection on the next call).
277    pub fn evict(&mut self, dc_id: i32) {
278        self.conns.remove(&dc_id);
279        self.init_done.remove(&dc_id);
280        let total: usize = self.conns.values().map(|v| v.len()).sum();
281        crate::metrics_shim::gauge!("ferogram.connections_active").set(total as f64);
282        tracing::debug!("[ferogram::pool] evicted all connections for DC{dc_id}");
283    }
284
285    /// Enqueue `body` on `slot` and await the result.
286    ///
287    /// This is the only place that touches `rpc_tx`/the oneshot: no mutex,
288    /// no blocking for the duration of the round trip. Multiple callers can
289    /// call this against the same slot concurrently and their requests will
290    /// pipeline on the wire instead of queueing behind each other.
291    async fn send_via_slot(
292        slot: &Arc<ConnSlot>,
293        body: Vec<u8>,
294    ) -> Result<Vec<u8>, InvocationError> {
295        slot.in_flight.fetch_add(1, Ordering::Relaxed);
296        let (tx, rx) = oneshot::channel();
297        let send_result = slot.rpc_tx.send(RpcEnqueue { body, tx }).await;
298        let result = if send_result.is_err() {
299            slot.alive.store(false, Ordering::Release);
300            Err(InvocationError::Deserialize(
301                "worker sender task shut down".into(),
302            ))
303        } else {
304            match rx.await {
305                Ok(r) => r,
306                Err(_) => {
307                    slot.alive.store(false, Ordering::Release);
308                    Err(InvocationError::Deserialize(
309                        "worker rpc channel closed".into(),
310                    ))
311                }
312            }
313        };
314        slot.in_flight.fetch_sub(1, Ordering::Relaxed);
315        result
316    }
317
318    /// Invoke a raw RPC call on the given DC.
319    /// Pool lock is released before the network round-trip begins.
320    ///
321    /// On connection death or a `-404` (auth key gone), this evicts the
322    /// dead slot and returns the error as-is -- it does not reconnect and
323    /// resend itself. `DcPool` has no `api_id`/device info to build
324    /// `invokeWithLayer(initConnection(...))`, so it can't safely redo
325    /// setup on its own. The caller sees `!pool.has_connection(dc_id)`
326    /// after this returns and is expected to redo full setup -- cached
327    /// auth key, `InitConnection`, and (for foreign DCs)
328    /// `auth.importAuthorization` -- before retrying.
329    pub async fn invoke_on_dc<R: RemoteCall>(
330        &mut self,
331        dc_id: i32,
332        _dc_entries: &[DcEntry],
333        req: &R,
334    ) -> Result<Vec<u8>, InvocationError> {
335        let slot = self.get_or_create_slot(dc_id, false, None).await?;
336        let body = maybe_gz_pack(&req.to_bytes());
337        let result = Self::send_via_slot(&slot, body.clone()).await;
338
339        if let Err(ref e) = result {
340            let _kind = match e {
341                InvocationError::Rpc(_) => "rpc",
342                InvocationError::Io(_) => "io",
343                _ => "other",
344            };
345            crate::metrics_shim::counter!("ferogram.rpc_errors_total", "kind" => _kind)
346                .increment(1);
347        }
348
349        if let Err(InvocationError::Rpc(ref e)) = result
350            && e.code == -404
351        {
352            // Telegram dropped the auth key (e.g. AndroidTV killed the socket during sleep).
353            // Evict; the caller redoes DH + auth import + InitConnection and retries.
354            tracing::warn!(
355                "[ferogram::pool] DC{dc_id} returned -404 (auth key gone); evicting for caller to redo setup"
356            );
357            self.evict(dc_id);
358            return result;
359        }
360
361        if result.is_err() && !slot.alive.load(Ordering::Acquire) {
362            tracing::warn!(
363                "[ferogram::pool] DC{dc_id} connection died mid-request; evicting for caller to redo setup"
364            );
365            self.evict(dc_id);
366        }
367        result
368    }
369
370    /// Mark a DC as having completed initConnection.
371    pub fn mark_init_done(&mut self, dc_id: i32) {
372        self.init_done.insert(dc_id);
373    }
374
375    /// Returns true if this DC has already received initConnection this session.
376    pub fn is_init_done(&self, dc_id: i32) -> bool {
377        self.init_done.contains(&dc_id)
378    }
379
380    /// Like `invoke_on_dc` but accepts any `Serializable` type.
381    /// Same evict-and-propagate behavior as `invoke_on_dc` -- see its doc comment.
382    pub async fn invoke_on_dc_serializable<S: Serializable>(
383        &mut self,
384        dc_id: i32,
385        req: &S,
386    ) -> Result<Vec<u8>, InvocationError> {
387        let slot = self
388            .get_or_create_slot(dc_id, false, None)
389            .await
390            .map_err(|_| InvocationError::Deserialize(format!("no connection for DC{dc_id}")))?;
391        let body = maybe_gz_pack(&req.to_bytes());
392        let result = Self::send_via_slot(&slot, body.clone()).await;
393
394        if let Err(InvocationError::Rpc(ref e)) = result
395            && e.code == -404
396        {
397            tracing::warn!(
398                "[ferogram::pool] DC{dc_id} returned -404 (serializable path); evicting for caller to redo setup"
399            );
400            self.evict(dc_id);
401            return result;
402        }
403
404        if result.is_err() && !slot.alive.load(Ordering::Acquire) {
405            tracing::warn!(
406                "[ferogram::pool] DC{dc_id} connection died mid-request (serializable path); evicting for caller to redo setup"
407            );
408            self.evict(dc_id);
409        }
410        result
411    }
412
413    /// Update the address table (called after `initConnection`).
414    pub fn update_addrs(&mut self, entries: &[DcEntry]) {
415        for e in entries {
416            self.addrs.insert(e.dc_id, e.addr.clone());
417        }
418    }
419
420    /// Save the auth keys from pool connections back into the DC entry list.
421    /// Uses the first slot per DC (all slots share the same auth key).
422    pub fn collect_keys(&self, entries: &mut [DcEntry]) {
423        for e in entries.iter_mut() {
424            if let Some(slots) = self.conns.get(&e.dc_id)
425                && let Some(slot) = slots.first()
426            {
427                e.auth_key = Some(slot.auth_key);
428                e.first_salt = slot.first_salt;
429                e.time_offset = slot.time_offset;
430            }
431        }
432    }
433}
434
435/// Serialize a `msgs_ack#62d6b459 { msg_ids: Vector<long> }` TL body.
436///
437/// This is sent as a non-content-related encrypted frame (even seq_no)
438/// to acknowledge received server messages and prevent Telegram from
439/// closing the connection due to un-acked messages.
440pub(crate) fn build_msgs_ack_body(msg_ids: &[i64]) -> Vec<u8> {
441    let mut out = Vec::with_capacity(4 + 4 + 4 + msg_ids.len() * 8);
442    out.extend_from_slice(&0x62d6b459_u32.to_le_bytes()); // msgs_ack constructor
443    out.extend_from_slice(&0x1cb5c415_u32.to_le_bytes()); // Vector constructor
444    out.extend_from_slice(&(msg_ids.len() as u32).to_le_bytes());
445    for &id in msg_ids {
446        out.extend_from_slice(&id.to_le_bytes());
447    }
448    out
449}
450
451/// Serialize a `ping_delay_disconnect#f3427b8c { ping_id, disconnect_delay: 75 }` body.
452///
453/// Tells Telegram to close the connection after 75 seconds of silence.
454pub(crate) fn build_msgs_ack_ping_body(ping_id: i64) -> Vec<u8> {
455    // ping_delay_disconnect#f3427b8c ping_id:long disconnect_delay:int = Pong
456    let mut out = Vec::with_capacity(4 + 8 + 4);
457    out.extend_from_slice(&0xf3427b8c_u32.to_le_bytes()); // constructor
458    out.extend_from_slice(&ping_id.to_le_bytes());
459    out.extend_from_slice(&75_i32.to_le_bytes()); // disconnect_delay = 75 s
460    out
461}