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}