Skip to main content

iscsi_client_rs/client/
pool_sessions.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2012-2025 Andrei Maltsev
3
4use std::{
5    sync::{Arc, Weak, atomic::AtomicU32},
6    time::Duration,
7};
8
9use anyhow::{Context, Result, ensure};
10use dashmap::DashMap;
11use once_cell::sync::OnceCell;
12use tokio_util::sync::CancellationToken;
13use tracing::{debug, info, warn};
14
15use crate::{
16    cfg::config::{AuthConfig, Config},
17    client::client::ClientConnection,
18    models::{data_fromat, logout::common::LogoutReason, nop::response::NopInResponse},
19    state_machine::{
20        common::StateMachineCtx, login::common::LoginCtx, logout_states::LogoutCtx,
21        nop_states::NopCtx,
22    },
23    utils::generate_isid,
24};
25
26/// Per-connection state within an iSCSI session
27///
28/// Represents a single TCP connection within an iSCSI session. A session may
29/// have multiple connections (Multi-Connection per Session - MC/S) for
30/// increased throughput.
31#[derive(Debug)]
32pub struct Connection {
33    /// Connection ID - unique identifier for this connection within the session
34    pub cid: u16,
35    /// Reference to the underlying client connection handling TCP communication
36    pub conn: Arc<ClientConnection>,
37    /// Next Expected StatSN (ACK). Bumped when we accept a reply from target.
38    /// Used to track the sequence of status responses from the target.
39    pub exp_stat_sn: Arc<AtomicU32>,
40}
41
42/// Per-session state identified by ISID+TSIH combination
43///
44/// Represents an iSCSI session which is a logical connection between an
45/// initiator and target. A session may have multiple TCP connections
46/// (Multi-Connection per Session - MC/S) for increased performance and
47/// redundancy.
48#[derive(Debug)]
49pub struct Session {
50    /// Target Session Identifying Handle - assigned by target during login
51    pub tsih: u16,
52    /// Initiator Session ID - 6 bytes identifying the session from initiator
53    /// side
54    pub isid: [u8; 6],
55    /// Name of the target this session is connected to
56    pub target_name: Arc<str>,
57    /// Map of connection ID to connection objects within this session
58    pub conns: DashMap<u16, Arc<Connection>>,
59
60    /// CmdSN generator for numbered commands (incremented on every
61    /// non-immediate command). Ensures proper command ordering.
62    cmd_sn: Arc<AtomicU32>,
63    /// ITT (Initiator Task Tag) generator - unique within a session.
64    /// Used to match requests with responses.
65    itt_gen: Arc<AtomicU32>,
66}
67
68/// Pool of iSCSI sessions and connections
69///
70/// Manages multiple iSCSI sessions and their associated connections. Provides
71/// centralized management, resource limits, and graceful shutdown capabilities.
72/// Acts as the main orchestrator for all iSCSI communication.
73pub struct Pool {
74    /// Map of TSIH to session objects - all active sessions
75    pub sessions: DashMap<u16, Arc<Session>>,
76    /// Maximum number of sessions allowed in this pool
77    max_sessions: u32,
78    /// Maximum number of connections per session
79    max_connections: u16,
80    /// Weak self-reference to avoid circular dependencies
81    self_weak: OnceCell<Weak<Pool>>,
82
83    /// Root cancellation token for the entire pool.
84    /// Child tokens are passed to connections so we can abort all I/O on full
85    /// shutdown.
86    cancel: CancellationToken,
87}
88
89const MAX_CONNECTION_RECOVERY_ATTEMPTS: usize = 3;
90
91impl Pool {
92    /// Create a pool with its own root cancellation token.
93    pub fn new(cfg: &Config) -> Self {
94        Self {
95            sessions: DashMap::with_capacity(cfg.runtime.max_sessions as usize),
96            max_sessions: cfg.runtime.max_sessions,
97            max_connections: cfg.login.limits.max_connections,
98            self_weak: OnceCell::new(),
99            cancel: CancellationToken::new(),
100        }
101    }
102
103    /// Optionally construct with an external root cancellation token.
104    pub fn with_cancel(cfg: &Config, cancel: CancellationToken) -> Self {
105        Self {
106            sessions: DashMap::with_capacity(cfg.runtime.max_sessions as usize),
107            max_sessions: cfg.runtime.max_sessions,
108            max_connections: cfg.login.limits.max_connections,
109            self_weak: OnceCell::new(),
110            cancel,
111        }
112    }
113
114    /// Expose the root token (e.g., if callers want to create siblings).
115    #[inline]
116    pub fn cancel_token(&self) -> CancellationToken {
117        self.cancel.clone()
118    }
119
120    /// Must be called once after creating Arc<Pool>.
121    pub fn attach_self(self: &Arc<Self>) {
122        let _ = self.self_weak.set(Arc::downgrade(self));
123    }
124
125    /// Login all sessions sequentially.
126    pub async fn login_sessions_from_cfg(&self, cfg: &Config) -> Result<Vec<u16>> {
127        ensure!(self.max_sessions > 0, "max_sessions must be > 0");
128
129        let target_name: Arc<str> = Arc::from(cfg.login.identity.target_name.clone());
130        let mut tsihs = Vec::with_capacity(self.max_sessions as usize);
131
132        for _ in 0..self.max_sessions {
133            let child = self.cancel.child_token();
134            let conn = ClientConnection::connect(cfg.clone(), child).await?;
135            let (isid, _) = generate_isid();
136
137            let tsih = self
138                .login_and_insert(target_name.clone(), isid, 0u16, conn)
139                .await?;
140
141            tsihs.push(tsih);
142        }
143
144        Ok(tsihs)
145    }
146
147    /// Login via a single TCP connection.
148    /// If TSIH is unknown (new session), target will assign a non-zero TSIH.
149    pub async fn login_and_insert(
150        &self,
151        target_name: Arc<str>,
152        isid: [u8; 6],
153        cid: u16,
154        conn: Arc<ClientConnection>,
155    ) -> Result<u16> {
156        self.login_one_and_insert_impl(
157            target_name,
158            isid,
159            /* tsih_hint */ 0,
160            cid,
161            conn,
162        )
163        .await
164    }
165
166    /// Add one more TCP connection into an existing session (known TSIH).
167    pub async fn add_connection_to_session(
168        &self,
169        tsih: u16,
170        cid: u16,
171        conn: Arc<ClientConnection>,
172    ) -> Result<()> {
173        // Read immutable bits upfront (don't hold DashMap guards across await)
174        let (target_name, isid) = {
175            let sess = self
176                .sessions
177                .get(&tsih)
178                .ok_or_else(|| anyhow::anyhow!("unknown TSIH={tsih}"))?;
179            (sess.target_name.clone(), sess.isid)
180        };
181        let _ = self
182            .login_one_and_insert_impl(target_name, isid, tsih, cid, conn)
183            .await?;
184        Ok(())
185    }
186
187    fn drop_connection_local(&self, tsih: u16, cid: u16) {
188        let should_remove_session = if let Some(sess) = self.sessions.get(&tsih) {
189            sess.conns.remove(&cid);
190            sess.conns.is_empty()
191        } else {
192            false
193        };
194
195        if should_remove_session {
196            self.sessions.remove(&tsih);
197        }
198    }
199
200    async fn recover_connection(
201        &self,
202        tsih: u16,
203        cid: u16,
204        expected: Arc<Connection>,
205    ) -> Result<()> {
206        let sess = self
207            .sessions
208            .get(&tsih)
209            .with_context(|| format!("unknown TSIH={tsih}"))?
210            .clone();
211
212        if let Some(current) = sess.conns.get(&cid).map(|entry| entry.clone())
213            && !Arc::ptr_eq(&current, &expected)
214            && !current.conn.is_poisoned()
215        {
216            return Ok(());
217        }
218
219        let target_name = sess.target_name.clone();
220        let isid = sess.isid;
221        let cfg = expected.conn.cfg.clone();
222        let mut removed = None;
223
224        if let Some(current) = sess.conns.get(&cid).map(|entry| entry.clone()) {
225            if Arc::ptr_eq(&current, &expected) || current.conn.is_poisoned() {
226                removed = sess.conns.remove(&cid).map(|(_, conn)| conn);
227            } else {
228                return Ok(());
229            }
230        }
231
232        let child = self.cancel.child_token();
233        let recovery = async {
234            let conn = ClientConnection::connect(cfg, child).await?;
235            let _ = self
236                .login_one_and_insert_impl(target_name, isid, tsih, cid, conn)
237                .await?;
238            Ok(())
239        }
240        .await;
241
242        if recovery.is_err()
243            && let Some(previous) = removed
244            && sess.conns.get(&cid).is_none()
245        {
246            sess.conns.insert(cid, previous);
247        }
248
249        recovery
250    }
251
252    async fn login_one_and_insert_impl(
253        &self,
254        target_name: Arc<str>,
255        isid: [u8; 6],
256        tsih_hint: u16,
257        cid: u16,
258        conn: Arc<ClientConnection>,
259    ) -> Result<u16> {
260        let mut l = LoginCtx::new(conn.clone(), isid, cid, tsih_hint);
261        match &conn.cfg.login.auth {
262            AuthConfig::Chap(_) => l.set_chap_login(),
263            AuthConfig::None => l.set_plain_login(),
264        }
265
266        let login_pdu = l.execute(&self.cancel).await.context("login failed")?;
267        let hdr = login_pdu.header_view()?;
268
269        let tsih = hdr.tsih.get();
270        ensure!(tsih != 0, "TSIH=0 in final Login Response");
271
272        let sess = self
273            .sessions
274            .entry(tsih)
275            .or_insert_with(|| {
276                Arc::new(Session {
277                    tsih,
278                    isid,
279                    target_name: target_name.clone(),
280                    conns: DashMap::with_capacity(self.max_connections as usize),
281                    cmd_sn: Arc::new(AtomicU32::new(hdr.exp_cmd_sn.get())),
282                    itt_gen: Arc::new(AtomicU32::new(
283                        hdr.initiator_task_tag.get().wrapping_add(1),
284                    )),
285                })
286            })
287            .clone();
288
289        let inserted = sess.conns.insert(
290            cid,
291            Arc::new(Connection {
292                cid,
293                conn: conn.clone(),
294                exp_stat_sn: Arc::new(AtomicU32::new(hdr.stat_sn.get().wrapping_add(1))),
295            }),
296        );
297        ensure!(
298            inserted.is_none(),
299            "CID={cid} already exists in TSIH={tsih}"
300        );
301
302        if let Some(w) = self.self_weak.get().cloned() {
303            conn.bind_pool_session(w, tsih, cid);
304        } else {
305            warn!(
306                "Pool::attach_self() was not called; unsolicited NOP auto-reply will be \
307                 disabled"
308            );
309        }
310
311        Ok(tsih)
312    }
313
314    /// Logout a single TCP connection (CID). Removes the entry on success.
315    async fn logout_connection(
316        &self,
317        tsih: u16,
318        cid: u16,
319        reason: LogoutReason,
320    ) -> Result<()> {
321        let sess = self
322            .sessions
323            .get(&tsih)
324            .with_context(|| format!("unknown TSIH={tsih}"))?
325            .clone();
326        let conn = sess
327            .conns
328            .get(&cid)
329            .with_context(|| format!("CID={cid} not found in TSIH={tsih}"))?
330            .clone();
331
332        let mut lo = LogoutCtx::new(
333            conn.conn.clone(),
334            sess.itt_gen.clone(),
335            sess.cmd_sn.clone(),
336            conn.exp_stat_sn.clone(),
337            cid,
338            reason.clone(),
339        );
340        lo.execute(&conn.conn.stop_writes)
341            .await
342            .context("logout (CloseConnection) failed")?;
343
344        // Local cleanup
345        if reason != LogoutReason::RemoveConnectionForRecovery {
346            sess.conns.remove(&cid);
347            if sess.conns.is_empty() {
348                self.sessions.remove(&tsih);
349            }
350        }
351        Ok(())
352    }
353
354    /// Logout the entire session by TSIH and purge local state.
355    pub async fn logout_session(&self, tsih: u16) -> Result<()> {
356        let sess = self
357            .sessions
358            .get(&tsih)
359            .with_context(|| format!("unknown TSIH={tsih}"))?
360            .clone();
361
362        if let Some(cid0) = sess.conns.iter().map(|e| *e.key()).min() {
363            let conn = sess
364                .conns
365                .get(&cid0)
366                .expect("CID just collected must exist")
367                .clone();
368
369            let mut lo = LogoutCtx::new(
370                conn.conn.clone(),
371                sess.itt_gen.clone(),
372                sess.cmd_sn.clone(),
373                conn.exp_stat_sn.clone(),
374                cid0,
375                LogoutReason::CloseSession,
376            );
377            lo.execute(&conn.conn.stop_writes)
378                .await
379                .context("logout (CloseSession) failed")?;
380        }
381
382        if let Some((_, s)) = self.sessions.remove(&tsih) {
383            // Drain connections to drop their Arcs eagerly (optional)
384            for cid in s.conns.iter().map(|kv| *kv.key()).collect::<Vec<_>>() {
385                let _ = s.conns.remove(&cid);
386            }
387        }
388        Ok(())
389    }
390
391    /// Unified logout handler by reason.
392    /// - CloseSession: ignores `cid` (you may pass None), sends Logout on any
393    ///   active connection and removes the entire session from the pool
394    ///   locally.
395    /// - CloseConnection: requires `cid`, removes only that connection; if no
396    ///   connections are left, removes the session as well.
397    /// - RemoveConnectionForRecovery: requires `cid`, removes only that
398    ///   connection; keeps the session even if it temporarily has 0 connections
399    ///   (used for recovery).
400    pub async fn logout(
401        &self,
402        tsih: u16,
403        reason: LogoutReason,
404        cid: Option<u16>,
405    ) -> Result<()> {
406        match reason {
407            LogoutReason::CloseSession => self.logout_session(tsih).await,
408            LogoutReason::CloseConnection | LogoutReason::RemoveConnectionForRecovery => {
409                self.logout_connection(tsih, cid.context("failed to get cid")?, reason)
410                    .await
411            },
412        }
413    }
414
415    /// Gracefully shut down the entire pool:
416    /// 1) Quiesce writes on all connections (no new PDUs).
417    /// 2) Wait for in-flight requests to drain (bounded by
418    ///    `max_wait_per_conn`).
419    /// 3) Send exactly one Logout(CloseSession) per session.
420    /// 4) Half-close the write side (TCP FIN) on all connections.
421    /// 5) Cancel the root token to stop remaining I/O.
422    pub async fn shutdown_gracefully(&self, max_wait_per_conn: Duration) -> Result<()> {
423        let all_connections: Vec<Arc<Connection>> = self
424            .sessions
425            .iter()
426            .flat_map(|s| {
427                s.conns
428                    .iter()
429                    .map(|c| c.value().clone())
430                    .collect::<Vec<_>>()
431            })
432            .collect();
433
434        debug!("notify state machines to stop writing ti socket");
435        for c in &all_connections {
436            if let Err(e) = c.conn.graceful_quiesce(max_wait_per_conn).await {
437                warn!("drain failed on TSIH={}?, CID={}: {}", c.cid, c.cid, e);
438            }
439        }
440
441        debug!("call logout session for 1 connectionf of all sessions");
442        let tsihs = self.sessions.iter().map(|e| *e.key()).collect::<Vec<_>>();
443        for tsih in tsihs {
444            if let Err(e) = self.logout_session(tsih).await {
445                warn!(
446                    "logout_session(TSIH={}) failed during shutdown: {}",
447                    tsih, e
448                );
449                if let Some((_, s)) = self.sessions.remove(&tsih) {
450                    for cid in s.conns.iter().map(|kv| *kv.key()).collect::<Vec<_>>() {
451                        let _ = s.conns.remove(&cid);
452                    }
453                }
454            }
455        }
456
457        debug!("close socket to target on connection");
458        for c in &all_connections {
459            if let Err(e) = c.conn.half_close_writes().await {
460                warn!("half_close_writes failed on CID={}: {}", c.cid, e);
461            }
462        }
463
464        self.sessions.clear();
465
466        debug!("Set cancel enable");
467        self.cancel.cancel();
468        info!("Pool graceful shutdown completed.");
469        Ok(())
470    }
471
472    /// Build a state-machine context for (TSIH, CID), inject counters and run
473    /// it.
474    ///
475    /// Usage:
476    /// pool.execute_with(tsih, cid, |conn, itt, cmd_sn, exp_stat_sn| {
477    ///     NopCtx::new(conn, lun, itt, cmd_sn, exp_stat_sn, ttt)
478    /// }).await?;
479    pub async fn execute_with<Ctx, Res, Build>(
480        &self,
481        tsih: u16,
482        cid: u16,
483        build: Build,
484    ) -> Result<Res>
485    where
486        Build: for<'a> Fn(
487            Arc<ClientConnection>,
488            Arc<AtomicU32>, // ITT
489            Arc<AtomicU32>, // CmdSN
490            Arc<AtomicU32>, // ExpStatSN
491        ) -> Ctx,
492        Ctx: StateMachineCtx<Ctx, Res>,
493    {
494        for attempt in 0..=MAX_CONNECTION_RECOVERY_ATTEMPTS {
495            let sess = self
496                .sessions
497                .get(&tsih)
498                .with_context(|| format!("unknown TSIH={tsih}"))?
499                .clone();
500            let conn = sess
501                .conns
502                .get(&cid)
503                .with_context(|| format!("CID={cid} not found in TSIH={tsih}"))?
504                .clone();
505
506            if conn.conn.is_poisoned() {
507                warn!(
508                    "TSIH={}, CID={} is poisoned before execute attempt {}",
509                    tsih,
510                    cid,
511                    attempt + 1,
512                );
513            } else {
514                let mut ctx = build(
515                    conn.conn.clone(),
516                    sess.itt_gen.clone(),
517                    sess.cmd_sn.clone(),
518                    conn.exp_stat_sn.clone(),
519                );
520                match ctx.execute(&conn.conn.stop_writes).await {
521                    Ok(res) => return Ok(res),
522                    Err(error) if conn.conn.is_poisoned() => {
523                        warn!(
524                            "TSIH={}, CID={} poisoned during execute attempt {}: {}",
525                            tsih,
526                            cid,
527                            attempt + 1,
528                            error
529                        );
530                    },
531                    Err(error) => return Err(error),
532                }
533            }
534
535            if attempt == MAX_CONNECTION_RECOVERY_ATTEMPTS {
536                self.drop_connection_local(tsih, cid);
537                return Err(anyhow::anyhow!(
538                    "connection recovery attempts exhausted for TSIH={}, CID={}",
539                    tsih,
540                    cid
541                ));
542            }
543
544            match self.recover_connection(tsih, cid, conn.clone()).await {
545                Ok(()) => {
546                    debug!(
547                        "recovered TSIH={}, CID={} after poisoned connection",
548                        tsih, cid
549                    );
550                },
551                Err(error) => {
552                    warn!(
553                        "failed to recover TSIH={}, CID={} on attempt {}: {}",
554                        tsih,
555                        cid,
556                        attempt + 1,
557                        error
558                    );
559                },
560            }
561        }
562
563        Err(anyhow::anyhow!(
564            "connection recovery attempts exhausted for TSIH={}, CID={}",
565            tsih,
566            cid
567        ))
568    }
569
570    pub(crate) async fn execute_nop_reply(
571        &self,
572        tsih: u16,
573        cid: u16,
574        pdu: data_fromat::PduResponse<NopInResponse>,
575    ) -> Result<()> {
576        let sess = self
577            .sessions
578            .get(&tsih)
579            .with_context(|| format!("unknown TSIH={tsih}"))?
580            .clone();
581        let conn = sess
582            .conns
583            .get(&cid)
584            .with_context(|| format!("CID={cid} not found in TSIH={tsih}"))?
585            .clone();
586
587        let mut ctx = NopCtx::for_reply(
588            conn.conn.clone(),
589            sess.itt_gen.clone(),
590            sess.cmd_sn.clone(),
591            conn.exp_stat_sn.clone(),
592            pdu,
593        )
594        .expect("failed to build NopCtx::for_reply");
595        ctx.execute(&conn.conn.stop_writes).await.map(|_| ())
596    }
597}
598
599impl Drop for Pool {
600    fn drop(&mut self) {
601        // Keep Drop short and non-blocking. We don't spawn long tasks here:
602        // the runtime may already be shutting down and spawned tasks might never run.
603        for sess in self.sessions.iter() {
604            for c in sess.conns.iter() {
605                c.value().conn.stop_writes.cancel();
606            }
607        }
608        // Abort remaining I/O at the nearest await.
609        self.cancel.cancel();
610    }
611}