Skip to main content

ssh_commander_pg/
lib.rs

1//! PostgreSQL pool for the database explorer.
2//!
3//! Sprint 6 model: each `PgPool` represents one configured Postgres
4//! profile and manages up to `max_size` underlying connections. Callers
5//! identify their work by `session_id` (typically a UUID per query
6//! tab). The pool maps `session_id → connection` so a tab's cursor
7//! always lives on the same wire across `execute → fetch_page →
8//! close_query`. Idle connections are reused for fresh sessions.
9//!
10//! ## Why per-session leasing
11//!
12//! `tokio_postgres::Client` is `Sync`, but a Postgres *session* is
13//! single-threaded by protocol: one transaction at a time, one cursor
14//! at a time. To let two query tabs run independent paginated SELECTs
15//! in parallel, each must hold its own connection for the cursor's
16//! lifetime. Without that, opening cursor A then cursor B on the same
17//! wire kills A — what Sprint 5's single-cursor invariant produced.
18//!
19//! ## SSH tunneling
20//!
21//! The pool dials `config.host:config.port` directly and knows nothing
22//! about SSH. When a profile needs an SSH tunnel, the caller (the
23//! `ConnectionManager`) opens one `SshTunnel`, points the `PgConfig` at
24//! its local forwarded port, and keeps the tunnel alive for the pool's
25//! lifetime. Single tunnel per profile keeps SSH session usage minimal.
26//!
27//! ## Thread safety
28//!
29//! `PgPool` is `Send + Sync`. All public methods take `&self` and
30//! acquire internal locks for short windows (the pool's own metadata
31//! Mutex during lease/release, and a per-connection Mutex during
32//! cursor bookkeeping). FFI callers wrap one `Arc<PgPool>` per
33//! managed connection id.
34
35pub mod config;
36pub mod edit;
37pub mod exec;
38pub mod introspect;
39
40pub use config::{PgAuthMethod, PgConfig, PgTlsMode};
41pub use edit::{InsertColumnInput, InsertedRow, UpdateOutcome};
42pub use exec::{ActiveCursor, ColumnMeta, ExecutionOutcome, PageResult};
43pub use introspect::{
44    ColumnDetail, DbSummary, ObjectType, ObjectTypeKind, Relation, RelationKind, Routine,
45    RoutineKind, SchemaContents, SchemaSummary, Sequence,
46};
47
48use std::collections::HashMap;
49use std::sync::{Arc, Mutex as StdMutex, Weak};
50use std::time::{Duration, Instant};
51
52use rustls::ClientConfig as RustlsClientConfig;
53use tokio::sync::Mutex;
54use tokio::task::JoinHandle;
55use tokio_postgres::config::SslMode as PgSslMode;
56use tokio_postgres::{CancelToken, Client, Config as PgDriverConfig};
57use tokio_postgres_rustls::MakeRustlsConnect;
58use tokio_util::sync::CancellationToken;
59
60/// Default upper bound on connections per pool. Five is plenty for an
61/// interactive explorer (you'd need six query tabs running at once to
62/// hit it) and well below the default `max_connections=100` Postgres
63/// quota that managed providers tend to set.
64const DEFAULT_MAX_POOL_SIZE: usize = 5;
65
66/// Default idle-connection lifetime. Five minutes balances "alt-tabbing
67/// out and back doesn't pay reconnect cost" against "polite to managed
68/// providers that bill on connection-hours". Per-profile override on
69/// `PgConfig.idle_timeout_secs`.
70const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
71
72/// How often the eviction loop wakes up. Independent of the idle
73/// timeout — connections live at most `idle_timeout + EVICTION_INTERVAL`.
74/// Not exposed on `PgConfig`; the cadence is global and the value is
75/// already much smaller than typical idle_timeout settings.
76const EVICTION_INTERVAL: Duration = Duration::from_secs(30);
77
78/// Default minimum idle connections to keep alive past `idle_timeout`.
79/// One warm connection means the next query doesn't pay connect
80/// latency. Per-profile override on `PgConfig.min_idle_connections`.
81const DEFAULT_MIN_IDLE_CONNECTIONS: usize = 1;
82
83/// Stable session id used for the schema browser's introspection
84/// calls (`list_databases`, `list_schemas`, `list_relations`). Uses
85/// a name that can't collide with a UUID-generated tab session id.
86pub const BROWSER_SESSION_ID: &str = "_browser";
87
88/// Errors surfaced from the Postgres explorer layer.
89#[derive(Debug, thiserror::Error)]
90pub enum PgError {
91    #[error("postgres connect failed: {0}")]
92    Connect(String),
93    #[error("postgres auth failed: {0}")]
94    Auth(String),
95    #[error("postgres tls setup failed: {0}")]
96    Tls(String),
97    #[error("ssh tunnel error: {0}")]
98    Tunnel(String),
99    /// Tunnel was requested but the referenced SSH connection isn't
100    /// registered (or has been closed).
101    #[error("ssh tunnel source not found: {0}")]
102    TunnelSourceMissing(String),
103    /// The cursor referenced by a fetch_page / close_query call no
104    /// longer exists. Sprint 6: now scoped to the session — only
105    /// fires if the same session opened a new cursor in between, or
106    /// the session was released.
107    #[error("cursor no longer available: {0}")]
108    CursorExpired(String),
109    #[error("invalid postgres input: {0}")]
110    InvalidInput(String),
111    /// The pool is at `max_size` and all connections are currently
112    /// leased to other sessions. Caller should wait and retry, or
113    /// release another session.
114    #[error("pool exhausted: {0} of {1} connections leased")]
115    PoolExhausted(usize, usize),
116    #[error("postgres driver error: {0}")]
117    Driver(#[from] tokio_postgres::Error),
118}
119
120// ============================================================================
121// Pool
122// ============================================================================
123
124pub struct PgPool {
125    config: PgConfig,
126    /// TLS connector built once at pool init and reused for every
127    /// new connection plus every server-side cancel. Sharing
128    /// matters for TLS-only Postgres deployments (RDS, Supabase,
129    /// Neon) where the server rejects a plaintext cancel handshake;
130    /// the cancel must use the same TLS posture as the data wire.
131    tls_connector: TlsConnectorKind,
132    /// Pool state guarded by a single Mutex. Acquire/release windows
133    /// are short — the actual SQL round trips happen against the
134    /// per-connection Mutex, not this one.
135    inner: Mutex<PoolInner>,
136    max_size: usize,
137    /// Per-pool idle-connection lifetime. Read once at construction
138    /// from `PgConfig.idle_timeout_secs` (or `DEFAULT_IDLE_TIMEOUT`).
139    idle_timeout: Duration,
140    /// Per-pool minimum-idle floor. Eviction won't drop below this
141    /// even when entries are aged.
142    min_idle: usize,
143    /// Cached side-connections for browsing databases other than
144    /// `config.database`. Postgres connections are bound to one
145    /// database at connect time; the schema browser tree shows
146    /// every database on the server, so expanding a non-default
147    /// one needs its own connection. Keyed by database name.
148    /// Lazily populated on first cross-database introspection;
149    /// torn down by `shutdown`.
150    secondary_browsers: Mutex<HashMap<String, SecondaryBrowserEntry>>,
151    /// Signal to the background eviction task that it should stop.
152    /// `shutdown` cancels it explicitly; the task also self-exits
153    /// when the pool's `Weak<Self>` upgrade fails (i.e. all `Arc`s
154    /// have been dropped).
155    eviction_cancel: CancellationToken,
156}
157
158/// One idle connection plus the moment it returned to idle. The
159/// eviction loop reads `since` to decide what to drop. Newly opened
160/// connections also enter idle with `since = now`, so a cold pool
161/// doesn't immediately evict.
162struct IdleEntry {
163    since: Instant,
164    conn: Arc<PooledConnection>,
165}
166
167/// A cross-database browser connection plus its last-use timestamp.
168/// These entries are counted against the same pool limit as ordinary
169/// idle/leased connections and are aged out by the eviction loop.
170struct SecondaryBrowserEntry {
171    since: Instant,
172    conn: Arc<PooledConnection>,
173}
174
175/// Erased TLS strategy for both data connections and cancel
176/// requests. `MakeRustlsConnect` clones cheaply (the inner
177/// `rustls::ClientConfig` is `Arc`-shared), so reusing the same
178/// instance for many connections is fine.
179#[derive(Clone)]
180enum TlsConnectorKind {
181    NoTls,
182    Rustls(MakeRustlsConnect),
183}
184
185struct PoolInner {
186    /// Connections free to lease, with the timestamp they returned to
187    /// idle. The eviction loop scans this list every
188    /// `EVICTION_INTERVAL`.
189    idle: Vec<IdleEntry>,
190    /// Active leases, keyed by caller-supplied session id.
191    leased: HashMap<String, Arc<PooledConnection>>,
192    /// Total connections in existence (idle + leased + currently
193    /// being opened). Bounds growth against `max_size`.
194    total: usize,
195}
196
197struct PooledConnection {
198    client: Client,
199    cancel_token: CancelToken,
200    /// Serializes normal SQL work on this wire. The cancel path never
201    /// takes this lock; it only needs `cancel_token`, which is immutable.
202    operation_lock: Mutex<()>,
203    /// At-most-one active cursor on this wire. Protected by the
204    /// operation lock plus this small metadata mutex.
205    active_cursor: Mutex<Option<ActiveCursor>>,
206    /// Background task that drives this connection's wire protocol.
207    /// Aborted when the connection is dropped from the pool.
208    connection_task: StdMutex<Option<JoinHandle<()>>>,
209}
210
211impl PooledConnection {
212    fn abort_connection_task(&self) {
213        if let Ok(mut task) = self.connection_task.lock()
214            && let Some(task) = task.take()
215        {
216            task.abort();
217        }
218    }
219}
220
221impl Drop for PooledConnection {
222    fn drop(&mut self) {
223        self.abort_connection_task();
224    }
225}
226
227impl PgPool {
228    /// Open a pool with `min_size = 1` (one connection eagerly
229    /// established) and `max_size = DEFAULT_MAX_POOL_SIZE`. Eager
230    /// initial connect surfaces auth/network errors immediately
231    /// rather than deferring them to the first query.
232    ///
233    /// The pool dials `cfg.host:cfg.port` directly. To tunnel through SSH, the
234    /// caller (e.g. `ConnectionManager`) opens an `SshTunnel`, points `cfg` at
235    /// its local forwarded port, and keeps the tunnel alive for the pool's
236    /// lifetime — the pool itself has no knowledge of SSH.
237    pub async fn connect(cfg: PgConfig) -> Result<Arc<Self>, PgError> {
238        // Build the TLS connector once. Subsequent opens (and the
239        // initial open below) reuse it; so does `cancel`.
240        let tls_connector = build_tls_connector(&cfg)?;
241
242        // Eager-connect the first connection so authentication errors
243        // surface up front.
244        let first = open_one(&cfg, &tls_connector).await?;
245
246        let now = Instant::now();
247        // Apply per-profile overrides on top of the built-in
248        // defaults. `0` is a valid `min_idle` (full evacuate) so we
249        // pass it through as-is.
250        let max_size = cfg
251            .max_pool_size
252            .map(|n| n as usize)
253            .filter(|&n| n > 0)
254            .unwrap_or(DEFAULT_MAX_POOL_SIZE);
255        let idle_timeout = cfg
256            .idle_timeout_secs
257            .map(Duration::from_secs)
258            .unwrap_or(DEFAULT_IDLE_TIMEOUT);
259        let min_idle = cfg
260            .min_idle_connections
261            .map(|n| n as usize)
262            .unwrap_or(DEFAULT_MIN_IDLE_CONNECTIONS);
263
264        let pool = Arc::new(Self {
265            config: cfg,
266            tls_connector,
267            inner: Mutex::new(PoolInner {
268                idle: vec![IdleEntry {
269                    since: now,
270                    conn: Arc::new(first),
271                }],
272                leased: HashMap::new(),
273                total: 1,
274            }),
275            max_size,
276            idle_timeout,
277            min_idle,
278            secondary_browsers: Mutex::new(HashMap::new()),
279            eviction_cancel: CancellationToken::new(),
280        });
281
282        // Spawn the eviction loop. We hand it a `Weak<Self>` so the
283        // pool can drop without the task keeping it alive — and a
284        // clone of the cancel token so an explicit `shutdown` can
285        // wake it immediately rather than waiting for the next tick.
286        let weak = Arc::downgrade(&pool);
287        let cancel = pool.eviction_cancel.clone();
288        tokio::spawn(run_eviction(weak, cancel));
289
290        Ok(pool)
291    }
292
293    // ------------------------------------------------------------------
294    // High-level operations
295    // ------------------------------------------------------------------
296
297    /// Schema introspection runs on the connection bound to the
298    /// well-known browser session. Re-using the same session means
299    /// the browser doesn't churn through pool slots on every tree
300    /// refresh.
301    pub async fn list_databases(&self) -> Result<Vec<DbSummary>, PgError> {
302        let conn = self.lease_for_session(BROWSER_SESSION_ID).await?;
303        let _operation = conn.operation_lock.lock().await;
304        Ok(introspect::list_databases(&conn.client).await?)
305    }
306
307    pub async fn list_schemas(&self) -> Result<Vec<SchemaSummary>, PgError> {
308        self.list_schemas_in(None).await
309    }
310
311    /// List schemas in `database`, opening (and caching) a side
312    /// connection when it differs from the connection's default DB.
313    /// Postgres binds a connection to one database at startup, so
314    /// browsing other DBs in the tree requires this routing —
315    /// otherwise every database expansion would show the connected
316    /// DB's schemas.
317    pub async fn list_schemas_in(
318        &self,
319        database: Option<&str>,
320    ) -> Result<Vec<SchemaSummary>, PgError> {
321        let conn = self.browser_connection_for(database).await?;
322        let _operation = conn.operation_lock.lock().await;
323        Ok(introspect::list_schemas(&conn.client).await?)
324    }
325
326    pub async fn list_relations(&self, schema: &str) -> Result<Vec<Relation>, PgError> {
327        self.list_relations_in(schema, None).await
328    }
329
330    pub async fn list_relations_in(
331        &self,
332        schema: &str,
333        database: Option<&str>,
334    ) -> Result<Vec<Relation>, PgError> {
335        let conn = self.browser_connection_for(database).await?;
336        let _operation = conn.operation_lock.lock().await;
337        Ok(introspect::list_relations(&conn.client, schema).await?)
338    }
339
340    /// Unified schema-contents fetch — tables, views, mat-views,
341    /// sequences, routines, and object types in one call. Replaces
342    /// the per-category round-trips the older tree did, and is what
343    /// the DataGrip-style 6-category tree expects.
344    pub async fn list_schema_contents_in(
345        &self,
346        schema: &str,
347        database: Option<&str>,
348    ) -> Result<SchemaContents, PgError> {
349        let conn = self.browser_connection_for(database).await?;
350        let _operation = conn.operation_lock.lock().await;
351        Ok(introspect::list_schema_contents(&conn.client, schema).await?)
352    }
353
354    /// Resolve a browser connection for `database`. When `None` or
355    /// matching the pool's default DB, returns the regular browser
356    /// session's lease. Otherwise opens (or returns cached) a
357    /// secondary connection bound to that database.
358    async fn browser_connection_for(
359        &self,
360        database: Option<&str>,
361    ) -> Result<Arc<PooledConnection>, PgError> {
362        let target = database.unwrap_or(self.config.database.as_str());
363        if target == self.config.database {
364            return self.lease_for_session(BROWSER_SESSION_ID).await;
365        }
366        // Cached secondary?
367        {
368            let mut map = self.secondary_browsers.lock().await;
369            if let Some(entry) = map.get_mut(target) {
370                entry.since = Instant::now();
371                return Ok(entry.conn.clone());
372            }
373        }
374        self.reserve_connection_slot().await?;
375
376        // Open a fresh connection bound to the target database.
377        // Reuse credentials and TLS posture — only the
378        // database name differs.
379        let mut cfg = self.config.clone();
380        cfg.database = target.to_string();
381        let conn = match open_one(&cfg, &self.tls_connector).await {
382            Ok(conn) => conn,
383            Err(e) => {
384                self.release_connection_slot().await;
385                return Err(e);
386            }
387        };
388        let arc = Arc::new(conn);
389        let mut map = self.secondary_browsers.lock().await;
390        // Race: another task may have inserted while we were
391        // opening. Prefer the existing entry to avoid leaking the
392        // freshly-opened one — drop ours, return theirs and release
393        // the slot reserved for the duplicate connection.
394        if let Some(existing) = map.get_mut(target) {
395            existing.since = Instant::now();
396            let existing = existing.conn.clone();
397            drop(map);
398            self.release_connection_slot().await;
399            return Ok(existing);
400        }
401        map.insert(
402            target.to_string(),
403            SecondaryBrowserEntry {
404                since: Instant::now(),
405                conn: arc.clone(),
406            },
407        );
408        Ok(arc)
409    }
410
411    /// Describe a relation's columns for the INSERT form. Runs on
412    /// the browser session to avoid churning a query tab's lease.
413    pub async fn describe_columns(
414        &self,
415        schema: &str,
416        table: &str,
417    ) -> Result<Vec<ColumnDetail>, PgError> {
418        let conn = self.lease_for_session(BROWSER_SESSION_ID).await?;
419        let _operation = conn.operation_lock.lock().await;
420        Ok(introspect::describe_columns(&conn.client, schema, table).await?)
421    }
422
423    /// Run a SQL statement on the connection assigned to `session_id`,
424    /// leasing one if the session is new. Closes any previously-active
425    /// cursor on that connection (per-session behavior — other sessions
426    /// are unaffected).
427    pub async fn execute(
428        &self,
429        session_id: &str,
430        sql: &str,
431        page_size: usize,
432    ) -> Result<ExecutionOutcome, PgError> {
433        let conn = self.lease_for_session(session_id).await?;
434        let _operation = conn.operation_lock.lock().await;
435        let previous = conn.active_cursor.lock().await.take();
436        let (outcome, new_cursor) =
437            exec::open_query(&conn.client, sql, page_size, previous).await?;
438        *conn.active_cursor.lock().await = new_cursor;
439        // If the cursor closed (no more rows), the connection is
440        // logically idle — but we keep the lease so the same session
441        // continues to land on the same wire for follow-up commands
442        // (helpful for SET / temporary tables). Explicit
443        // `release_session` returns it to idle.
444        Ok(outcome)
445    }
446
447    pub async fn fetch_page(
448        &self,
449        session_id: &str,
450        cursor_id: &str,
451        count: usize,
452    ) -> Result<PageResult, PgError> {
453        let conn = self
454            .leased_only(session_id)
455            .await
456            .ok_or_else(|| PgError::CursorExpired(format!("no active session {session_id}")))?;
457        let _operation = conn.operation_lock.lock().await;
458        let Some(cursor) = conn.active_cursor.lock().await.clone() else {
459            return Err(PgError::CursorExpired(format!(
460                "session {session_id} has no active cursor"
461            )));
462        };
463        if cursor.cursor_id != cursor_id {
464            return Err(PgError::CursorExpired(format!(
465                "session {session_id} active cursor is {} (looking for {cursor_id})",
466                cursor.cursor_id
467            )));
468        }
469        exec::fetch_page(&conn.client, &cursor, count).await
470    }
471
472    /// Update a single cell on `(schema, table)` identified by ctid.
473    /// Runs on the session's connection so users editing in one tab
474    /// don't block on another tab's pagination cursor. Returns
475    /// `UpdateOutcome { rows_affected }` — the UI treats `0` as
476    /// "row no longer there, please refresh".
477    #[allow(clippy::too_many_arguments)]
478    pub async fn update_cell(
479        &self,
480        session_id: &str,
481        schema: &str,
482        table: &str,
483        column: &str,
484        column_type: &str,
485        new_value: Option<&str>,
486        ctid: &str,
487    ) -> Result<UpdateOutcome, PgError> {
488        let conn = self.lease_for_session(session_id).await?;
489        let _operation = conn.operation_lock.lock().await;
490        edit::update_cell(
491            &conn.client,
492            schema,
493            table,
494            column,
495            column_type,
496            new_value,
497            ctid,
498        )
499        .await
500    }
501
502    /// Insert one row, returning the requested columns. Runs on the
503    /// session's connection so any session-local state (SET,
504    /// transactions) applies. See [`edit::insert_row`] for the SQL
505    /// shape and parameter rules.
506    pub async fn insert_row(
507        &self,
508        session_id: &str,
509        schema: &str,
510        table: &str,
511        inputs: &[InsertColumnInput],
512        return_columns: &[String],
513    ) -> Result<InsertedRow, PgError> {
514        let conn = self.lease_for_session(session_id).await?;
515        let _operation = conn.operation_lock.lock().await;
516        edit::insert_row(&conn.client, schema, table, inputs, return_columns).await
517    }
518
519    /// Delete one or more rows by ctid on the session's connection.
520    /// Returns the actual rows-deleted count — callers compare
521    /// against the requested count to spot "some rows were already
522    /// gone" (concurrent edit / delete from another session).
523    pub async fn delete_rows(
524        &self,
525        session_id: &str,
526        schema: &str,
527        table: &str,
528        ctids: &[String],
529    ) -> Result<UpdateOutcome, PgError> {
530        let conn = self.lease_for_session(session_id).await?;
531        let _operation = conn.operation_lock.lock().await;
532        edit::delete_rows(&conn.client, schema, table, ctids).await
533    }
534
535    pub async fn close_query(&self, session_id: &str, cursor_id: &str) -> Result<(), PgError> {
536        let Some(conn) = self.leased_only(session_id).await else {
537            return Ok(()); // Nothing to close — idempotent.
538        };
539        let _operation = conn.operation_lock.lock().await;
540        let cursor = {
541            let mut active = conn.active_cursor.lock().await;
542            match active.as_ref() {
543                Some(c) if c.cursor_id == cursor_id => active.take(),
544                _ => None,
545            }
546        };
547        if let Some(cursor) = cursor {
548            exec::close_query(&conn.client, &cursor).await;
549        }
550        Ok(())
551    }
552
553    /// Server-side cancel for whatever query is in flight on the
554    /// session's connection. Uses the same TLS posture as the
555    /// data wire — TLS-only Postgres deployments (RDS, Supabase,
556    /// Neon) reject plaintext cancels, which would silently leave
557    /// the in-flight query running until it timed out. No-op if
558    /// the session has no lease.
559    pub async fn cancel(&self, session_id: &str) -> Result<(), PgError> {
560        let Some(conn) = self.leased_only(session_id).await else {
561            return Ok(());
562        };
563        let token = conn.cancel_token.clone();
564        match &self.tls_connector {
565            TlsConnectorKind::NoTls => token.cancel_query(tokio_postgres::NoTls).await,
566            TlsConnectorKind::Rustls(connector) => token.cancel_query(connector.clone()).await,
567        }
568        .map_err(PgError::Driver)
569    }
570
571    /// Release a session's lease. Closes any active cursor first so
572    /// the underlying connection returns to idle in a clean state.
573    pub async fn release_session(&self, session_id: &str) {
574        let Some(conn) = self.take_lease(session_id).await else {
575            return;
576        };
577        // Close any open cursor + transaction so the connection is
578        // safe to hand to a different session.
579        {
580            let _operation = conn.operation_lock.lock().await;
581            let cursor = conn.active_cursor.lock().await.take();
582            if let Some(cursor) = cursor {
583                exec::close_query(&conn.client, &cursor).await;
584            }
585        }
586        // Return to idle, stamping the moment of release so the
587        // eviction loop can age it out.
588        let mut inner = self.inner.lock().await;
589        inner.idle.push(IdleEntry {
590            since: Instant::now(),
591            conn,
592        });
593    }
594
595    /// Tear down all connections. Used on `disconnect`.
596    pub async fn shutdown(&self) {
597        // Wake the eviction loop so it exits promptly rather than
598        // sleeping on the next tick.
599        self.eviction_cancel.cancel();
600
601        let mut inner = self.inner.lock().await;
602        let mut conns: Vec<Arc<PooledConnection>> = inner.idle.drain(..).map(|e| e.conn).collect();
603        conns.extend(inner.leased.drain().map(|(_, c)| c));
604        inner.total = 0;
605        drop(inner);
606        // Also close any secondary cross-database browser
607        // connections we opened.
608        let secondaries: Vec<Arc<PooledConnection>> = {
609            let mut map = self.secondary_browsers.lock().await;
610            map.drain().map(|(_, entry)| entry.conn).collect()
611        };
612        let conns_with_secondaries = conns.into_iter().chain(secondaries);
613        let conns: Vec<Arc<PooledConnection>> = conns_with_secondaries.collect();
614        for conn in conns {
615            // Best-effort task abort; the wire closes when `Client`
616            // is dropped (which happens when the last Arc to this
617            // PooledConnection drops — usually right here).
618            conn.abort_connection_task();
619        }
620    }
621
622    // ------------------------------------------------------------------
623    // Internal: leasing
624    // ------------------------------------------------------------------
625
626    /// Get the connection currently leased to `session_id`, opening
627    /// a new one (and leasing it) when the session is new.
628    async fn lease_for_session(&self, session_id: &str) -> Result<Arc<PooledConnection>, PgError> {
629        // Fast path: existing lease.
630        {
631            let inner = self.inner.lock().await;
632            if let Some(c) = inner.leased.get(session_id) {
633                return Ok(c.clone());
634            }
635        }
636
637        // Try to grab an idle connection first. LIFO (`pop`) keeps
638        // the most-recently-released connection warm — which is the
639        // youngest, most-likely-to-survive eviction next round.
640        let from_idle = {
641            let mut inner = self.inner.lock().await;
642            inner.idle.pop().map(|e| e.conn)
643        };
644        if let Some(conn) = from_idle {
645            self.assign_lease(session_id, conn.clone()).await;
646            return Ok(conn);
647        }
648
649        // No idle connections. Open a new one if we have room.
650        let need_new = {
651            let inner = self.inner.lock().await;
652            if inner.total >= self.max_size {
653                return Err(PgError::PoolExhausted(inner.total, self.max_size));
654            }
655            true
656        };
657        if need_new {
658            // Reserve the slot before the network round trip so two
659            // simultaneous lease requests don't both think there's
660            // room.
661            {
662                let mut inner = self.inner.lock().await;
663                if inner.total >= self.max_size {
664                    return Err(PgError::PoolExhausted(inner.total, self.max_size));
665                }
666                inner.total += 1;
667            }
668            let new_conn = match open_one(&self.config, &self.tls_connector).await {
669                Ok(c) => c,
670                Err(e) => {
671                    // Roll back the slot reservation on failure so
672                    // the pool can try again later.
673                    let mut inner = self.inner.lock().await;
674                    inner.total = inner.total.saturating_sub(1);
675                    return Err(e);
676                }
677            };
678            let conn = Arc::new(new_conn);
679            self.assign_lease(session_id, conn.clone()).await;
680            return Ok(conn);
681        }
682        unreachable!()
683    }
684
685    async fn assign_lease(&self, session_id: &str, conn: Arc<PooledConnection>) {
686        let mut inner = self.inner.lock().await;
687        inner.leased.insert(session_id.to_string(), conn);
688    }
689
690    async fn reserve_connection_slot(&self) -> Result<(), PgError> {
691        let mut inner = self.inner.lock().await;
692        if inner.total >= self.max_size {
693            return Err(PgError::PoolExhausted(inner.total, self.max_size));
694        }
695        inner.total += 1;
696        Ok(())
697    }
698
699    async fn release_connection_slot(&self) {
700        let mut inner = self.inner.lock().await;
701        inner.total = inner.total.saturating_sub(1);
702    }
703
704    /// Evict idle connections older than the pool's configured
705    /// `idle_timeout`, keeping at least `min_idle` alive. Runs from
706    /// the background eviction task; safe to call manually too.
707    async fn evict_idle(&self) {
708        let now = Instant::now();
709        let to_drop: Vec<Arc<PooledConnection>>;
710        {
711            let mut inner = self.inner.lock().await;
712            // Take the idle list out so we can decide which entries
713            // to keep without holding the lock during async work.
714            // Single-pass keep/drop: iterate in storage order (most
715            // recently released last, since `lease` pops from the
716            // end); the first `min_idle` we encounter are pinned
717            // regardless of age.
718            let snapshot = std::mem::take(&mut inner.idle);
719            let mut keep: Vec<IdleEntry> = Vec::with_capacity(snapshot.len());
720            let mut drop_list: Vec<Arc<PooledConnection>> = Vec::new();
721            for entry in snapshot.into_iter() {
722                let aged = now.duration_since(entry.since) >= self.idle_timeout;
723                if !aged || keep.len() < self.min_idle {
724                    keep.push(entry);
725                } else {
726                    drop_list.push(entry.conn);
727                    inner.total = inner.total.saturating_sub(1);
728                }
729            }
730            inner.idle = keep;
731            to_drop = drop_list;
732        }
733
734        if !to_drop.is_empty() {
735            tracing::debug!(
736                target: "postgres::pool",
737                count = to_drop.len(),
738                "evicted idle postgres connections"
739            );
740        }
741
742        // Abort the connection tasks. Dropping the `Arc` (via
743        // out-of-scope at end of this function) closes the wire when
744        // the last reference goes — which is here, since `idle`
745        // held the only one.
746        for conn in to_drop {
747            conn.abort_connection_task();
748        }
749
750        self.evict_secondary_browsers(now).await;
751    }
752
753    async fn evict_secondary_browsers(&self, now: Instant) {
754        let to_drop: Vec<Arc<PooledConnection>> = {
755            let mut map = self.secondary_browsers.lock().await;
756            let drop_keys = map
757                .iter()
758                .filter_map(|(database, entry)| {
759                    let aged = now.duration_since(entry.since) >= self.idle_timeout;
760                    // The map's Arc is the only strong reference when no
761                    // browser operation is currently using this connection.
762                    if aged && Arc::strong_count(&entry.conn) == 1 {
763                        Some(database.clone())
764                    } else {
765                        None
766                    }
767                })
768                .collect::<Vec<_>>();
769
770            let mut dropped = Vec::with_capacity(drop_keys.len());
771            for database in drop_keys {
772                if let Some(entry) = map.remove(&database) {
773                    dropped.push(entry.conn);
774                }
775            }
776            dropped
777        };
778
779        if to_drop.is_empty() {
780            return;
781        }
782
783        {
784            let mut inner = self.inner.lock().await;
785            for _ in &to_drop {
786                inner.total = inner.total.saturating_sub(1);
787            }
788        }
789
790        tracing::debug!(
791            target: "postgres::pool",
792            count = to_drop.len(),
793            "evicted secondary postgres browser connections"
794        );
795
796        for conn in to_drop {
797            conn.abort_connection_task();
798        }
799    }
800
801    /// Look up the lease for `session_id` without opening a new one.
802    async fn leased_only(&self, session_id: &str) -> Option<Arc<PooledConnection>> {
803        let inner = self.inner.lock().await;
804        inner.leased.get(session_id).cloned()
805    }
806
807    /// Remove and return the lease for `session_id`.
808    async fn take_lease(&self, session_id: &str) -> Option<Arc<PooledConnection>> {
809        let mut inner = self.inner.lock().await;
810        inner.leased.remove(session_id)
811    }
812}
813
814impl Drop for PgPool {
815    fn drop(&mut self) {
816        // Cancel the eviction loop first so it doesn't try to upgrade
817        // the soon-dead `Weak<Self>` and log spurious work.
818        self.eviction_cancel.cancel();
819
820        // Best-effort: abort connection tasks. Async shutdown is the
821        // documented path for clean teardown; this guards against
822        // forgotten shutdown calls.
823        if let Ok(mut inner) = self.inner.try_lock() {
824            let mut conns: Vec<Arc<PooledConnection>> =
825                inner.idle.drain(..).map(|e| e.conn).collect();
826            conns.extend(inner.leased.drain().map(|(_, c)| c));
827            for conn in conns {
828                conn.abort_connection_task();
829            }
830        }
831        // Best-effort close of secondary cross-database browsers.
832        if let Ok(mut map) = self.secondary_browsers.try_lock() {
833            for (_, entry) in map.drain() {
834                entry.conn.abort_connection_task();
835            }
836        }
837    }
838}
839
840// ============================================================================
841// Background eviction (private)
842// ============================================================================
843
844/// Background loop that prunes idle connections aged past
845/// `IDLE_TIMEOUT`. Holds a `Weak<PgPool>` so it can't extend the
846/// pool's lifetime — when all `Arc<PgPool>`s are dropped, the next
847/// upgrade fails and the loop exits.
848async fn run_eviction(pool: Weak<PgPool>, cancel: CancellationToken) {
849    let mut ticker = tokio::time::interval(EVICTION_INTERVAL);
850    // Skip the immediate first tick — `connect` just opened a
851    // connection so there's nothing to evict yet.
852    ticker.tick().await;
853    loop {
854        tokio::select! {
855            _ = cancel.cancelled() => return,
856            _ = ticker.tick() => {
857                let Some(pool) = pool.upgrade() else { return };
858                pool.evict_idle().await;
859                // Drop the strong ref before the next sleep so the
860                // pool can be reclaimed promptly when the manager
861                // discards it.
862                drop(pool);
863            }
864        }
865    }
866}
867
868// ============================================================================
869// Connection construction (private)
870// ============================================================================
871
872async fn open_one(cfg: &PgConfig, tls: &TlsConnectorKind) -> Result<PooledConnection, PgError> {
873    let driver_cfg = build_driver_config(cfg)?;
874    match tls {
875        TlsConnectorKind::NoTls => {
876            let (client, connection) = driver_cfg
877                .connect(tokio_postgres::NoTls)
878                .await
879                .map_err(classify_connect_error)?;
880            Ok(spawn_connection(client, connection))
881        }
882        TlsConnectorKind::Rustls(connector) => {
883            // `MakeRustlsConnect` clones cheaply (Arc-shared
884            // ClientConfig); each connect consumes its own clone.
885            let (client, connection) = driver_cfg
886                .connect(connector.clone())
887                .await
888                .map_err(classify_connect_error)?;
889            Ok(spawn_connection(client, connection))
890        }
891    }
892}
893
894/// Build the TLS connector used for both the data wire and cancel
895/// handshakes for this pool. Installing the rustls crypto provider
896/// once per pool is sufficient — the call is idempotent across
897/// multiple pools (the second install returns Err and we ignore it).
898fn build_tls_connector(cfg: &PgConfig) -> Result<TlsConnectorKind, PgError> {
899    match cfg.tls {
900        PgTlsMode::Disable => Ok(TlsConnectorKind::NoTls),
901        PgTlsMode::Prefer | PgTlsMode::Require | PgTlsMode::VerifyFull => {
902            let _ = rustls::crypto::ring::default_provider().install_default();
903            let tls_config = build_rustls_config(cfg.tls)?;
904            Ok(TlsConnectorKind::Rustls(MakeRustlsConnect::new(tls_config)))
905        }
906    }
907}
908
909fn build_driver_config(cfg: &PgConfig) -> Result<PgDriverConfig, PgError> {
910    let mut driver = PgDriverConfig::new();
911    driver.host(&cfg.host).port(cfg.port);
912    driver.dbname(&cfg.database).user(&cfg.user);
913
914    let password = match &cfg.auth {
915        PgAuthMethod::Password { password } => password.clone(),
916        PgAuthMethod::Keychain { account } => ssh_commander_keychain::load_password(
917            ssh_commander_keychain::CredentialKind::PostgresPassword,
918            account,
919        )
920        .map_err(|e| PgError::Auth(format!("keychain load failed for {account}: {e}")))?
921        .ok_or_else(|| {
922            PgError::Auth(format!("no keychain entry for postgres account {account}"))
923        })?,
924    };
925    if !password.is_empty() {
926        driver.password(password);
927    }
928
929    if let Some(name) = &cfg.application_name {
930        driver.application_name(name);
931    }
932    if let Some(secs) = cfg.connect_timeout_secs {
933        driver.connect_timeout(Duration::from_secs(secs));
934    }
935
936    driver.ssl_mode(match cfg.tls {
937        PgTlsMode::Disable => PgSslMode::Disable,
938        PgTlsMode::Prefer => PgSslMode::Prefer,
939        PgTlsMode::Require | PgTlsMode::VerifyFull => PgSslMode::Require,
940    });
941    Ok(driver)
942}
943
944fn build_rustls_config(mode: PgTlsMode) -> Result<RustlsClientConfig, PgError> {
945    let mut roots = rustls::RootCertStore::empty();
946    let native = rustls_native_certs::load_native_certs();
947    for cert in native.certs {
948        let _ = roots.add(cert);
949    }
950
951    let cfg = match mode {
952        PgTlsMode::VerifyFull => RustlsClientConfig::builder()
953            .with_root_certificates(roots)
954            .with_no_client_auth(),
955        _ => RustlsClientConfig::builder()
956            .dangerous()
957            .with_custom_certificate_verifier(std::sync::Arc::new(NoCertVerifier))
958            .with_no_client_auth(),
959    };
960    Ok(cfg)
961}
962
963fn classify_connect_error(e: tokio_postgres::Error) -> PgError {
964    if let Some(db_err) = e.as_db_error() {
965        let code = db_err.code().code();
966        if code == "28P01" || code == "28000" {
967            return PgError::Auth(db_err.message().to_string());
968        }
969    }
970    PgError::Connect(e.to_string())
971}
972
973fn spawn_connection<S, T>(
974    client: Client,
975    connection: tokio_postgres::Connection<S, T>,
976) -> PooledConnection
977where
978    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
979    T: tokio_postgres::tls::TlsStream + Unpin + Send + 'static,
980{
981    let cancel_token = client.cancel_token();
982    let task = tokio::spawn(async move {
983        if let Err(e) = connection.await {
984            tracing::warn!("postgres connection task ended with error: {e}");
985        }
986    });
987    PooledConnection {
988        client,
989        cancel_token,
990        operation_lock: Mutex::new(()),
991        active_cursor: Mutex::new(None),
992        connection_task: StdMutex::new(Some(task)),
993    }
994}
995
996#[derive(Debug)]
997struct NoCertVerifier;
998
999impl rustls::client::danger::ServerCertVerifier for NoCertVerifier {
1000    fn verify_server_cert(
1001        &self,
1002        _end_entity: &rustls::pki_types::CertificateDer<'_>,
1003        _intermediates: &[rustls::pki_types::CertificateDer<'_>],
1004        _server_name: &rustls::pki_types::ServerName<'_>,
1005        _ocsp_response: &[u8],
1006        _now: rustls::pki_types::UnixTime,
1007    ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
1008        Ok(rustls::client::danger::ServerCertVerified::assertion())
1009    }
1010
1011    fn verify_tls12_signature(
1012        &self,
1013        _message: &[u8],
1014        _cert: &rustls::pki_types::CertificateDer<'_>,
1015        _dss: &rustls::DigitallySignedStruct,
1016    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
1017        Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
1018    }
1019
1020    fn verify_tls13_signature(
1021        &self,
1022        _message: &[u8],
1023        _cert: &rustls::pki_types::CertificateDer<'_>,
1024        _dss: &rustls::DigitallySignedStruct,
1025    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
1026        Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
1027    }
1028
1029    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
1030        vec![
1031            rustls::SignatureScheme::RSA_PKCS1_SHA256,
1032            rustls::SignatureScheme::RSA_PKCS1_SHA384,
1033            rustls::SignatureScheme::RSA_PKCS1_SHA512,
1034            rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
1035            rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
1036            rustls::SignatureScheme::ED25519,
1037            rustls::SignatureScheme::RSA_PSS_SHA256,
1038            rustls::SignatureScheme::RSA_PSS_SHA384,
1039            rustls::SignatureScheme::RSA_PSS_SHA512,
1040        ]
1041    }
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046    use super::*;
1047
1048    #[test]
1049    fn driver_config_uses_correct_ssl_mode() {
1050        let mut cfg = PgConfig::local("db", "u");
1051        cfg.tls = PgTlsMode::Require;
1052        let driver = build_driver_config(&cfg).expect("driver cfg");
1053        assert!(matches!(driver.get_ssl_mode(), PgSslMode::Require));
1054
1055        cfg.tls = PgTlsMode::Disable;
1056        let driver = build_driver_config(&cfg).expect("driver cfg");
1057        assert!(matches!(driver.get_ssl_mode(), PgSslMode::Disable));
1058    }
1059
1060    #[test]
1061    fn driver_config_omits_password_when_empty() {
1062        let cfg = PgConfig::local("db", "u");
1063        let driver = build_driver_config(&cfg).expect("driver cfg");
1064        assert!(driver.get_password().is_none());
1065    }
1066
1067    /// Pure logic test of the eviction policy: given idle entries
1068    /// with various ages, the keep/drop split honors `IDLE_TIMEOUT`
1069    /// and `MIN_IDLE_CONNECTIONS`. Doesn't open real connections —
1070    /// the policy is just arithmetic over `(since, idx)` pairs.
1071    #[test]
1072    fn eviction_policy_keeps_min_idle_and_drops_aged() {
1073        // The keep/drop math against (idle_timeout, min_idle) inputs.
1074        // Mirrors `evict_idle` so a future change to the loop forces
1075        // a test update.
1076        fn run_policy(
1077            entries: Vec<(usize, Instant)>,
1078            now: Instant,
1079            idle_timeout: Duration,
1080            min_idle: usize,
1081        ) -> (Vec<usize>, Vec<usize>) {
1082            let mut keep: Vec<usize> = Vec::new();
1083            let mut drop_idx: Vec<usize> = Vec::new();
1084            for (idx, since) in entries {
1085                let aged = now.duration_since(since) >= idle_timeout;
1086                if !aged || keep.len() < min_idle {
1087                    keep.push(idx);
1088                } else {
1089                    drop_idx.push(idx);
1090                }
1091            }
1092            (keep, drop_idx)
1093        }
1094
1095        let now = Instant::now();
1096        let timeout = Duration::from_secs(300);
1097        let aged = now - timeout - Duration::from_secs(1);
1098        let fresh = now - Duration::from_secs(10);
1099
1100        // min_idle = 1 (default): one aged entry survives, fresh
1101        // always survives, rest evicted.
1102        let (keep, drop_idx) = run_policy(
1103            vec![(0, aged), (1, aged), (2, fresh), (3, aged)],
1104            now,
1105            timeout,
1106            1,
1107        );
1108        assert_eq!(keep, vec![0, 2]);
1109        assert_eq!(drop_idx, vec![1, 3]);
1110
1111        // min_idle = 0: all aged entries evicted, only fresh survives.
1112        let (keep, drop_idx) = run_policy(vec![(0, aged), (1, fresh), (2, aged)], now, timeout, 0);
1113        assert_eq!(keep, vec![1]);
1114        assert_eq!(drop_idx, vec![0, 2]);
1115    }
1116}