Skip to main content

lean_rs_host/host/
pool.rs

1//! Session pooling for amortising `Lean.importModules` across reused
2//! environments.
3//!
4//! Re-importing the Lean prelude is the dominant FFI cost on the host
5//! stack—measured on a dev macOS rig at roughly 4×–5× the cost of
6//! reusing an existing session (see the `session_reuse_amortises_import`
7//! timing note in `host/tests.rs`). [`SessionPool`] keeps a bounded
8//! free-list of previously imported `Lean.Environment` values keyed by
9//! their imports list; on [`SessionPool::acquire`], a matching entry is
10//! popped and rewrapped under the caller-supplied
11//! [`crate::host::LeanCapabilities`] borrow, and on
12//! [`PooledSession::drop`], the environment goes back to the pool (or is
13//! released if capacity is full).
14//!
15//! ## Capability-agnostic storage
16//!
17//! Entries store the bare imported environment as
18//! `Obj<'lean>` (a refcounted handle to the Lean
19//! `Environment` value), not a full [`crate::LeanSession`]. The session
20//! borrows from the capability via `'c`; storing one in the pool would
21//! tie the pool's lifetime to a single capability borrow. Storing the
22//! bare environment instead lets each [`SessionPool::acquire`] thread a
23//! fresh capability borrow without touching `'lean`. Environments are
24//! Lean values bound to the runtime, not to the capability that imported
25//! them, so this rewrapping is semantically free.
26//!
27//! ## Capacity policy
28//!
29//! [`SessionPool::with_capacity`] sets a hard upper bound on the
30//! free-list size. On release, if the pool is at capacity, the
31//! environment is dropped immediately (its `Obj<'lean>`
32//! `Drop` runs `lean_dec` and the underlying allocation is freed). The
33//! free list is FIFO on `take` and LRU on `push`, so the most
34//! recently-released environment is the next to be reused—hot OS
35//! caches stay warm. There is no eviction-by-age or eviction-by-distinct-key
36//! policy beyond the capacity bound.
37//!
38//! ## Staleness eviction
39//!
40//! One thing does evict below capacity. Lean sizes `Environment.extensions`
41//! exactly once, when the environment is imported, and an import that runs a
42//! module's `initialize` block can register a new environment extension
43//! process-globally. Every environment that already exists then has a
44//! permanently short array — `private` on both the field and the growth
45//! helper, so no repair is possible — and elaborating a `namespace`,
46//! `section`, or `open … in` against it panics once per out-of-range slot.
47//!
48//! Each entry therefore records the extension-registration stamp it was
49//! imported under ([`crate::LeanSession::extension_registry_epoch`]), and
50//! [`SessionPool::acquire`] compares it against a live read before handing the
51//! environment back. A mismatch drops the entry, counts a
52//! [`SessionPoolKeyMissReason::StaleEnvironment`] miss, and imports fresh; the
53//! same live stamp then sweeps the rest of the free list. The sweep is lazy —
54//! it runs on acquire, not at the moment of registration — so
55//! [`PoolStats::stale_evictions`] lags the registration by one acquire.
56//!
57//! [`SessionPool::drain`] explicitly drops every cached free-list entry
58//! without discarding the pool itself. It releases the Rust-owned
59//! environment references the pool is holding; it does not reset Lean's
60//! process-global runtime state, module initializer flags, interned
61//! names, compacted `.olean` regions, or allocator arenas.
62//!
63//! ## Threading
64//!
65//! [`SessionPool`] is `!Send + !Sync` (inherited from the contained
66//! `Obj<'lean>` and the `RefCell` that wraps the free list). The pool
67//! is a per-thread reuse helper; cross-thread pooling is explicitly
68//! out of scope. Per-pool stats are `Cell<PoolStats>`—
69//! single-threaded but uniform with the per-session
70//! [`crate::host::session::SessionStats`] story.
71
72use core::cell::{Cell, RefCell};
73
74use std::path::PathBuf;
75#[cfg(not(target_os = "linux"))]
76use std::process::Command;
77
78use lean_rs::LeanRuntime;
79use lean_rs::Obj;
80use lean_rs::ResourceExhaustedFacts;
81use lean_rs::error::LeanError;
82use lean_rs::error::LeanResult;
83
84use crate::host::cancellation::{LeanCancellationToken, check_cancellation};
85use crate::host::capabilities::LeanCapabilities;
86use crate::host::progress::LeanProgressSink;
87use crate::host::session::{LeanImportStats, LeanSession, LeanSessionImportProfile};
88
89// -- PoolStats: pool-level reuse metrics ---------------------------------
90
91/// Cumulative metrics for one [`SessionPool`].
92///
93/// Snapshot via [`SessionPool::stats`]. Counters never reset—to
94/// compute a delta, take two snapshots and subtract.
95///
96/// `imports_performed + reused == acquired` by construction: every
97/// [`SessionPool::acquire`] call increments `acquired` exactly once
98/// plus either `imports_performed` (cache miss) or `reused` (cache
99/// hit). Similarly, `released_to_pool + released_dropped` counts every
100/// [`PooledSession::drop`] firing. `released_to_pool` is cumulative:
101/// an entry counted there may later be removed by [`SessionPool::drain`],
102/// which records the removal in `drained`.
103#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
104pub struct PoolStats {
105    /// Number of fresh `Lean.importModules` calls performed because no
106    /// pooled environment matched the requested imports list.
107    pub imports_performed: u64,
108    /// Number of acquire calls that found a matching pooled environment
109    /// and reused it instead of re-importing.
110    pub reused: u64,
111    /// Total acquire calls (== `imports_performed + reused`).
112    pub acquired: u64,
113    /// Number of release events that pushed the environment back onto
114    /// the free list.
115    pub released_to_pool: u64,
116    /// Number of release events that dropped the environment because
117    /// the pool was at capacity.
118    pub released_dropped: u64,
119    /// Number of explicit [`SessionPool::drain`] calls.
120    pub drains: u64,
121    /// Number of cached environments dropped by explicit drains.
122    pub drained: u64,
123    /// Number of fresh imports refused by [`SessionPoolMemoryPolicy`].
124    pub fresh_import_refusals: u64,
125    /// Number of process RSS samples taken before fresh imports.
126    pub rss_samples: u64,
127    /// Number of process RSS samples that were unavailable.
128    pub rss_samples_unavailable: u64,
129    /// Number of acquire calls that matched a reusable session key.
130    pub key_hits: u64,
131    /// Number of acquire calls that could not reuse a session key.
132    pub key_misses: u64,
133    /// Number of distinct session keys observed by this pool.
134    pub distinct_keys_seen: u64,
135    /// Number of fresh imports avoided by key hits.
136    pub fresh_imports_avoided: u64,
137    /// Key misses because the pool had no reusable entry.
138    pub miss_empty_pool: u64,
139    /// Key misses because the pool has zero reuse capacity.
140    pub miss_reuse_disabled: u64,
141    /// Key misses because cached entries existed but none matched the requested key.
142    pub miss_no_matching_key: u64,
143    /// Key misses because the matching entry predated an environment-extension
144    /// registration and could no longer be elaborated against.
145    pub miss_stale_environment: u64,
146    /// Number of cached environments dropped because a later import
147    /// registered a Lean environment extension underneath them.
148    ///
149    /// Counts *entries*, not acquires: one stale acquire drops the entry it
150    /// matched plus every other entry the same sweep found, so this is at
151    /// least `miss_stale_environment`.
152    pub stale_evictions: u64,
153    /// Most recent key-miss reason.
154    pub last_miss_reason: Option<SessionPoolKeyMissReason>,
155}
156
157/// Why a same-process pool acquire could not reuse a warm session key.
158#[derive(Clone, Copy, Debug, Eq, PartialEq)]
159pub enum SessionPoolKeyMissReason {
160    EmptyPool,
161    ReuseDisabled,
162    NoMatchingKey,
163    /// A matching entry existed but was imported before a later import
164    /// registered an environment extension. See
165    /// [`crate::LeanSession::extension_registry_epoch`].
166    StaleEnvironment,
167}
168
169impl SessionPoolKeyMissReason {
170    pub const fn label(self) -> &'static str {
171        match self {
172            Self::EmptyPool => "empty_pool",
173            Self::ReuseDisabled => "reuse_disabled",
174            Self::StaleEnvironment => "stale_environment",
175            Self::NoMatchingKey => "no_matching_key",
176        }
177    }
178}
179
180/// Policy for refusing fresh imports in a same-process [`SessionPool`].
181///
182/// Reusing an already-imported environment does not grow Lean's process-global
183/// import state. Fresh imports can, so this policy is checked only on cache
184/// miss, immediately before `Lean.importModules` would run.
185#[derive(Clone, Debug, Default, Eq, PartialEq)]
186pub struct SessionPoolMemoryPolicy {
187    max_fresh_imports: Option<u64>,
188    max_rss_kib: Option<u64>,
189}
190
191impl SessionPoolMemoryPolicy {
192    /// Disable import/RSS refusals.
193    ///
194    /// This preserves the historical [`SessionPool::with_capacity`] behavior
195    /// and is appropriate only for short-lived processes, tests with tiny
196    /// import counts, or explicit profiling workloads.
197    #[must_use]
198    pub fn disabled() -> Self {
199        Self::default()
200    }
201
202    /// Refuse the next cache-miss import once `limit` fresh imports have
203    /// already run through this pool.
204    #[must_use]
205    pub fn max_fresh_imports(mut self, limit: u64) -> Self {
206        self.max_fresh_imports = Some(limit.max(1));
207        self
208    }
209
210    /// Refuse the next cache-miss import when current process RSS is at or
211    /// above `limit_kib`.
212    #[must_use]
213    pub fn max_rss_kib(mut self, limit_kib: u64) -> Self {
214        self.max_rss_kib = Some(limit_kib.max(1));
215        self
216    }
217
218    /// Return the configured fresh-import limit.
219    #[must_use]
220    pub fn max_fresh_imports_limit(&self) -> Option<u64> {
221        self.max_fresh_imports
222    }
223
224    /// Return the configured process RSS ceiling in KiB.
225    #[must_use]
226    pub fn max_rss_kib_limit(&self) -> Option<u64> {
227        self.max_rss_kib
228    }
229}
230
231/// Configuration for a same-process [`SessionPool`].
232#[derive(Clone, Debug, Eq, PartialEq)]
233pub struct SessionPoolConfig {
234    capacity: usize,
235    memory_policy: SessionPoolMemoryPolicy,
236}
237
238impl SessionPoolConfig {
239    /// Create a pool configuration with a fixed free-list capacity.
240    #[must_use]
241    pub fn new(capacity: usize) -> Self {
242        Self {
243            capacity,
244            memory_policy: SessionPoolMemoryPolicy::disabled(),
245        }
246    }
247
248    /// Set the policy used before cache-miss imports.
249    #[must_use]
250    pub fn memory_policy(mut self, policy: SessionPoolMemoryPolicy) -> Self {
251        self.memory_policy = policy;
252        self
253    }
254
255    /// Return the configured free-list capacity.
256    #[must_use]
257    pub fn capacity(&self) -> usize {
258        self.capacity
259    }
260
261    /// Return the configured memory policy.
262    #[must_use]
263    pub fn memory_policy_ref(&self) -> &SessionPoolMemoryPolicy {
264        &self.memory_policy
265    }
266}
267
268// -- SessionPoolKey: cache key for imported environments -----------------
269
270/// Free-list key: the imported-environment identity a pooled session was
271/// imported with.
272///
273/// Order matters because `Lean.importModules` is order-sensitive—a
274/// later import can shadow an earlier one. Equality is structural and
275/// canonical (the same capability root, profile, and `&[&str]` always
276/// produces the same key).
277#[derive(Clone, Eq, PartialEq)]
278struct SessionPoolKey {
279    project_root: PathBuf,
280    imports: Vec<String>,
281    import_profile: LeanSessionImportProfile,
282}
283
284impl SessionPoolKey {
285    fn from_capabilities(
286        caps: &LeanCapabilities<'_, '_>,
287        imports: &[&str],
288        import_profile: LeanSessionImportProfile,
289    ) -> Self {
290        Self {
291            project_root: caps.host().project().root().to_path_buf(),
292            imports: imports.iter().map(|&s| s.to_owned()).collect(),
293            import_profile,
294        }
295    }
296}
297
298// -- PooledEntry: one slot on the free list ------------------------------
299
300struct PooledEntry<'lean> {
301    key: SessionPoolKey,
302    environment: Obj<'lean>,
303    import_stats: LeanImportStats,
304    /// The process-global extension-registration stamp as it stood when this
305    /// environment was imported. Reuse is safe only while it still matches the
306    /// live stamp — see [`crate::LeanSession::extension_registry_epoch`].
307    extension_registry_epoch: u64,
308}
309
310// -- PoolInner: RefCell-protected free list ------------------------------
311
312struct PoolInner<'lean> {
313    /// FIFO on take, LIFO on push (newest entries near the back; the
314    /// most-recently-released entry matching a given imports key is the
315    /// one acquire pops). The list scan is linear, which is fine for
316    /// the small capacities this pool is sized for—pooling is for
317    /// amortising imports across O(10s) of sessions, not for managing
318    /// thousands.
319    free: Vec<PooledEntry<'lean>>,
320    seen_keys: Vec<SessionPoolKey>,
321}
322
323impl<'lean> PoolInner<'lean> {
324    /// Pop the most recently released entry whose session key matches.
325    fn take_matching(&mut self, key: &SessionPoolKey) -> Option<PooledEntry<'lean>> {
326        let idx = self.free.iter().rposition(|entry| &entry.key == key)?;
327        Some(self.free.remove(idx))
328    }
329}
330
331// -- SessionPool ---------------------------------------------------------
332
333/// A capacity-bounded reuse pool of imported Lean environments.
334///
335/// Built with [`Self::with_capacity`]; environments enter the pool
336/// through [`PooledSession::drop`] (returning a previously-acquired
337/// session). Pool entries are keyed by canonical Lake project root,
338/// ordered imports, and import profile. A single pool may be shared
339/// across multiple [`LeanCapabilities`] values with the same runtime;
340/// roots and profiles still partition the reusable environments.
341///
342/// Neither [`Send`] nor [`Sync`] (inherited from the contained
343/// `Obj<'lean>` values).
344pub struct SessionPool<'lean> {
345    runtime: &'lean LeanRuntime,
346    capacity: usize,
347    memory_policy: SessionPoolMemoryPolicy,
348    inner: RefCell<PoolInner<'lean>>,
349    last_import_stats: RefCell<Option<LeanImportStats>>,
350    stats: Cell<PoolStats>,
351}
352
353impl<'lean> SessionPool<'lean> {
354    /// Build an empty pool with hard upper bound `capacity` on stored
355    /// environments.
356    ///
357    /// A `capacity` of 0 disables reuse—every [`Self::acquire`] call
358    /// imports fresh and every release drops the environment. This is
359    /// useful for tests that want metrics without recycling, and as the
360    /// degenerate point that proves the pool's metrics agree with
361    /// repeated `caps.session(..., None, None)` calls.
362    ///
363    /// The `runtime` borrow witnesses `'lean` and is stored so the pool
364    /// itself outlives every entry on its free list—even after every
365    /// [`PooledSession`] has been dropped, the pool retains a usable
366    /// runtime reference.
367    #[must_use]
368    pub fn with_capacity(runtime: &'lean LeanRuntime, capacity: usize) -> Self {
369        Self::with_config(runtime, SessionPoolConfig::new(capacity))
370    }
371
372    /// Build an empty pool from an explicit configuration.
373    #[must_use]
374    pub fn with_config(runtime: &'lean LeanRuntime, config: SessionPoolConfig) -> Self {
375        let capacity = config.capacity;
376        Self {
377            runtime,
378            capacity,
379            memory_policy: config.memory_policy,
380            inner: RefCell::new(PoolInner {
381                free: Vec::with_capacity(capacity),
382                seen_keys: Vec::new(),
383            }),
384            last_import_stats: RefCell::new(None),
385            stats: Cell::new(PoolStats::default()),
386        }
387    }
388
389    /// Build an empty pool with a fresh-import memory policy.
390    #[must_use]
391    pub fn with_memory_policy(runtime: &'lean LeanRuntime, capacity: usize, policy: SessionPoolMemoryPolicy) -> Self {
392        Self::with_config(runtime, SessionPoolConfig::new(capacity).memory_policy(policy))
393    }
394
395    /// Acquire a session targeting `imports` under `caps`.
396    ///
397    /// If a pooled environment was previously released with the same
398    /// canonical project root, default import profile, and ordered
399    /// `imports` list, it is rewrapped under the supplied capability
400    /// borrow and returned—no `Lean.importModules` runs. Otherwise the
401    /// pool calls [`LeanCapabilities::session`] internally to perform a
402    /// fresh import. Either way, the resulting [`PooledSession`] returns
403    /// the underlying environment to the pool on `Drop`.
404    ///
405    /// `caps` must come from the same [`LeanRuntime`] the pool was
406    /// constructed with; this is structurally enforced by the shared
407    /// `'lean` lifetime parameter.
408    ///
409    /// # Errors
410    ///
411    /// Returns [`lean_rs::LeanError::Cancelled`] if `cancellation` is
412    /// already cancelled before the pool can reuse or import an
413    /// environment.
414    ///
415    /// Returns [`lean_rs::LeanError::LeanException`] if a fresh import is
416    /// required and the Lean-side `lean_rs_host_session_import` shim
417    /// raises through `IO`. Cached environments never re-fail.
418    pub fn acquire<'p, 'c>(
419        &'p self,
420        caps: &'c LeanCapabilities<'lean, 'c>,
421        imports: &[&str],
422        cancellation: Option<&LeanCancellationToken>,
423        progress: Option<&dyn LeanProgressSink>,
424    ) -> LeanResult<PooledSession<'lean, 'p, 'c>> {
425        self.acquire_with_profile(
426            caps,
427            imports,
428            LeanSessionImportProfile::default(),
429            cancellation,
430            progress,
431        )
432    }
433
434    /// Acquire a session targeting `imports` with an explicit import profile.
435    ///
436    /// This is the profile-aware variant of [`Self::acquire`]. Profiles are
437    /// part of the session-safety key; a legacy compatibility import never
438    /// aliases a lighter default-profile environment.
439    ///
440    /// # Errors
441    ///
442    /// Same as [`Self::acquire`].
443    pub fn acquire_with_profile<'p, 'c>(
444        &'p self,
445        caps: &'c LeanCapabilities<'lean, 'c>,
446        imports: &[&str],
447        import_profile: LeanSessionImportProfile,
448        cancellation: Option<&LeanCancellationToken>,
449        progress: Option<&dyn LeanProgressSink>,
450    ) -> LeanResult<PooledSession<'lean, 'p, 'c>> {
451        let _span = tracing::debug_span!(
452            target: "lean_rs",
453            "lean_rs.host.pool.acquire",
454            profile = import_profile.label(),
455            imports_len = imports.len(),
456            imports_first = imports.first().copied().unwrap_or("<empty>"),
457        )
458        .entered();
459        check_cancellation(cancellation)?;
460        debug_assert!(
461            core::ptr::eq(self.runtime, caps.host().runtime()),
462            "pool runtime and capability runtime must agree; the shared 'lean parameter normally enforces this",
463        );
464        let key = SessionPoolKey::from_capabilities(caps, imports, import_profile);
465        self.remember_seen_key(&key);
466        let matched = {
467            let mut inner = self.inner.borrow_mut();
468            match inner.take_matching(&key) {
469                Some(entry) => Ok(entry),
470                None => Err(self.miss_reason(&inner)),
471            }
472        };
473        // A pooled environment is reusable only while the process-global
474        // extension registry stands exactly where it stood when that
475        // environment was imported: Lean sizes `Environment.extensions` once,
476        // at import, and elaborating a `namespace`, `section`, or `open … in`
477        // against a short array panics once per out-of-range slot with no
478        // repair available. See `LeanSession::extension_registry_epoch`.
479        let reused = match matched {
480            Ok(entry) => {
481                let entry_epoch = entry.extension_registry_epoch;
482                let session = LeanSession::from_environment_with_import_stats(
483                    caps,
484                    entry.environment,
485                    entry.import_stats,
486                    entry_epoch,
487                )?;
488                let live = session.live_extension_registry_epoch().ok();
489                if live == Some(entry_epoch) {
490                    self.bump_reused();
491                    Some(session)
492                } else {
493                    // This is a bare `LeanSession`, not a `PooledSession`, so
494                    // dropping it releases the environment outright rather
495                    // than returning it to the free list.
496                    drop(session);
497                    self.bump_stale_evictions(1);
498                    self.bump_key_miss(SessionPoolKeyMissReason::StaleEnvironment);
499                    None
500                }
501            }
502            Err(reason) => {
503                self.bump_key_miss(reason);
504                None
505            }
506        };
507        let (session, hit) = match reused {
508            Some(session) => (session, true),
509            None => {
510                self.enforce_before_fresh_import(imports)?;
511                let session = caps.session_with_profile(imports, import_profile, cancellation, progress)?;
512                self.remember_import_stats(session.import_stats().clone());
513                self.bump_imported();
514                (session, false)
515            }
516        };
517        // Sweep with the stamp in hand. A reused session's stamp was just
518        // confirmed live; a freshly imported one's was read inside that
519        // import's own lock, so it is live too. Either way this costs no extra
520        // FFI call, and it keeps a dead entry from occupying a slot the
521        // capacity bound would otherwise spend evicting a live one.
522        self.sweep_stale(Some(session.extension_registry_epoch()));
523        tracing::debug!(target: "lean_rs", hit = hit, "lean_rs.host.pool.acquire.result");
524        Ok(PooledSession {
525            pool: self,
526            key,
527            session: Some(session),
528        })
529    }
530
531    /// Snapshot the accumulated pool metrics.
532    ///
533    /// Counters never reset; subtract two snapshots to measure activity
534    /// over an interval. See [`PoolStats`] for the field invariants
535    /// (e.g. `imports_performed + reused == acquired`).
536    #[must_use]
537    pub fn stats(&self) -> PoolStats {
538        self.stats.get()
539    }
540
541    /// Number of environments currently sitting on the free list.
542    ///
543    /// This is the count of warm imports available for the next
544    /// [`Self::acquire`] without going through `Lean.importModules`.
545    /// Explicit drains and cache hits both remove entries from this
546    /// count; releases may add entries back up to [`Self::capacity`].
547    #[must_use]
548    pub fn len(&self) -> usize {
549        self.inner.borrow().free.len()
550    }
551
552    /// `true` iff [`Self::len`] is zero; every subsequent
553    /// [`Self::acquire`] will perform a fresh import.
554    #[must_use]
555    pub fn is_empty(&self) -> bool {
556        self.len() == 0
557    }
558
559    /// Configured hard upper bound on the free list.
560    ///
561    /// Set by [`Self::with_capacity`]. A pool releasing a
562    /// [`PooledSession`] while at capacity drops the environment
563    /// instead of pushing it back; that release shows up in
564    /// [`PoolStats::released_dropped`] rather than `released_to_pool`.
565    #[must_use]
566    pub fn capacity(&self) -> usize {
567        self.capacity
568    }
569
570    /// Return the configured memory policy.
571    #[must_use]
572    pub fn memory_policy(&self) -> &SessionPoolMemoryPolicy {
573        &self.memory_policy
574    }
575
576    /// Drop every cached environment currently retained by the pool.
577    ///
578    /// Returns the number of free-list entries removed. Each removed
579    /// entry drops its owned `Obj<'lean>` environment, which releases
580    /// one Lean refcount via `lean_dec`.
581    ///
582    /// Checked-out [`PooledSession`] values are not affected: they own
583    /// their sessions until drop, and may return their environments to
584    /// this same pool later if capacity permits. A later [`Self::drain`]
585    /// call can remove those returned entries.
586    ///
587    /// This is a cache-eviction API, not a runtime recycle API. It does
588    /// not reset Lean's process-global runtime state, initialized module
589    /// flags, interned names, compacted `.olean` regions, or allocator
590    /// arenas, and should not be treated as an RSS reset.
591    pub fn drain(&self) -> usize {
592        let mut inner = self.inner.borrow_mut();
593        let drained = inner.free.len();
594        inner.free.clear();
595
596        let mut s = self.stats.get();
597        s.drains = s.drains.saturating_add(1);
598        s.drained = s.drained.saturating_add(u64::try_from(drained).unwrap_or(u64::MAX));
599        self.stats.set(s);
600
601        tracing::debug!(
602            target: "lean_rs",
603            drained = drained,
604            "lean_rs.host.pool.drain",
605        );
606        drained
607    }
608
609    fn bump_reused(&self) {
610        let mut s = self.stats.get();
611        s.reused = s.reused.saturating_add(1);
612        s.acquired = s.acquired.saturating_add(1);
613        s.key_hits = s.key_hits.saturating_add(1);
614        s.fresh_imports_avoided = s.fresh_imports_avoided.saturating_add(1);
615        s.last_miss_reason = None;
616        self.stats.set(s);
617    }
618
619    fn bump_imported(&self) {
620        let mut s = self.stats.get();
621        s.imports_performed = s.imports_performed.saturating_add(1);
622        s.acquired = s.acquired.saturating_add(1);
623        self.stats.set(s);
624    }
625
626    fn remember_seen_key(&self, key: &SessionPoolKey) {
627        let mut inner = self.inner.borrow_mut();
628        if inner.seen_keys.iter().all(|seen| seen != key) {
629            inner.seen_keys.push(key.clone());
630            let mut s = self.stats.get();
631            s.distinct_keys_seen = u64::try_from(inner.seen_keys.len()).unwrap_or(u64::MAX);
632            self.stats.set(s);
633        }
634    }
635
636    fn miss_reason(&self, inner: &PoolInner<'_>) -> SessionPoolKeyMissReason {
637        if self.capacity == 0 {
638            SessionPoolKeyMissReason::ReuseDisabled
639        } else if inner.free.is_empty() {
640            SessionPoolKeyMissReason::EmptyPool
641        } else {
642            SessionPoolKeyMissReason::NoMatchingKey
643        }
644    }
645
646    /// Drop every free-list entry whose environment predates `live`.
647    ///
648    /// `None` means the stamp could not be read, in which case every entry
649    /// compares unequal and the whole free list goes. That is the only
650    /// direction that cannot leave a short-`extensions` environment in
651    /// service, and it costs at most one re-import per pooled profile.
652    ///
653    /// This reaches only the free list. A [`PooledSession`] that is checked
654    /// out while some *other* code path imports a registering module stays
655    /// checked out; nothing at this layer can revoke it.
656    fn sweep_stale(&self, live: Option<u64>) {
657        let evicted = {
658            let mut inner = self.inner.borrow_mut();
659            let before = inner.free.len();
660            inner.free.retain(|entry| live == Some(entry.extension_registry_epoch));
661            before.saturating_sub(inner.free.len())
662        };
663        if evicted > 0 {
664            self.bump_stale_evictions(u64::try_from(evicted).unwrap_or(u64::MAX));
665        }
666    }
667
668    fn bump_stale_evictions(&self, count: u64) {
669        let mut s = self.stats.get();
670        s.stale_evictions = s.stale_evictions.saturating_add(count);
671        self.stats.set(s);
672        tracing::debug!(
673            target: "lean_rs",
674            count = count,
675            "lean_rs.host.pool.stale_eviction",
676        );
677    }
678
679    fn bump_key_miss(&self, reason: SessionPoolKeyMissReason) {
680        let mut s = self.stats.get();
681        s.key_misses = s.key_misses.saturating_add(1);
682        match reason {
683            SessionPoolKeyMissReason::EmptyPool => {
684                s.miss_empty_pool = s.miss_empty_pool.saturating_add(1);
685            }
686            SessionPoolKeyMissReason::ReuseDisabled => {
687                s.miss_reuse_disabled = s.miss_reuse_disabled.saturating_add(1);
688            }
689            SessionPoolKeyMissReason::NoMatchingKey => {
690                s.miss_no_matching_key = s.miss_no_matching_key.saturating_add(1);
691            }
692            SessionPoolKeyMissReason::StaleEnvironment => {
693                s.miss_stale_environment = s.miss_stale_environment.saturating_add(1);
694            }
695        }
696        s.last_miss_reason = Some(reason);
697        self.stats.set(s);
698    }
699
700    fn bump_fresh_import_refusal(&self) {
701        let mut s = self.stats.get();
702        s.fresh_import_refusals = s.fresh_import_refusals.saturating_add(1);
703        self.stats.set(s);
704    }
705
706    fn bump_rss_sample(&self, unavailable: bool) {
707        let mut s = self.stats.get();
708        s.rss_samples = s.rss_samples.saturating_add(1);
709        if unavailable {
710            s.rss_samples_unavailable = s.rss_samples_unavailable.saturating_add(1);
711        }
712        self.stats.set(s);
713    }
714
715    fn remember_import_stats(&self, stats: LeanImportStats) {
716        *self.last_import_stats.borrow_mut() = Some(stats);
717    }
718
719    fn latest_import_stats_diagnostic(&self) -> String {
720        self.last_import_stats.borrow().as_ref().map_or_else(
721            || String::from("last_import_stats=unavailable"),
722            |stats| format!("last_import_stats=available {}", stats.memory_diagnostic()),
723        )
724    }
725
726    fn latest_import_stats_for_resource_facts(&self) -> Option<String> {
727        self.last_import_stats
728            .borrow()
729            .as_ref()
730            .map(LeanImportStats::memory_diagnostic)
731    }
732
733    fn resource_refusal(
734        &self,
735        cause: &str,
736        message: String,
737        current_rss_kib: Option<u64>,
738        limit_kib: Option<u64>,
739        import_count: Option<u64>,
740        import_limit: Option<u64>,
741        requested_imports: u64,
742    ) -> LeanError {
743        lean_rs::__host_internals::host_resource_exhausted_with_facts(
744            message,
745            ResourceExhaustedFacts {
746                cause: cause.to_owned(),
747                work_entered_lean: false,
748                current_rss_kib,
749                limit_kib,
750                import_count,
751                import_limit,
752                requested_imports: Some(requested_imports),
753                last_import_stats: self.latest_import_stats_for_resource_facts(),
754            },
755        )
756    }
757
758    fn enforce_before_fresh_import(&self, imports: &[&str]) -> LeanResult<()> {
759        let stats = self.stats.get();
760        if let Some(limit) = self.memory_policy.max_fresh_imports
761            && stats.imports_performed >= limit
762        {
763            self.bump_fresh_import_refusal();
764            return Err(self.resource_refusal(
765                "same_process_fresh_import_limit",
766                format!(
767                "same-process SessionPool refused fresh import #{} for {} import(s): max_fresh_imports={limit}; {}; reuse a pooled environment or cycle the worker process",
768                stats.imports_performed.saturating_add(1),
769                imports.len(),
770                self.latest_import_stats_diagnostic(),
771                ),
772                None,
773                None,
774                Some(stats.imports_performed),
775                Some(limit),
776                imports.len() as u64,
777            ));
778        }
779
780        if let Some(limit_kib) = self.memory_policy.max_rss_kib {
781            match current_process_rss_kib() {
782                Some(current_kib) if current_kib >= limit_kib => {
783                    self.bump_rss_sample(false);
784                    self.bump_fresh_import_refusal();
785                    return Err(self.resource_refusal(
786                        "same_process_rss_ceiling",
787                        format!(
788                        "same-process SessionPool refused fresh import for {} import(s): current RSS {current_kib} KiB reached max_rss_kib={limit_kib}; {}; cycle the worker process to reset Lean process-global import state",
789                        imports.len(),
790                        self.latest_import_stats_diagnostic(),
791                        ),
792                        Some(current_kib),
793                        Some(limit_kib),
794                        Some(stats.imports_performed),
795                        None,
796                        imports.len() as u64,
797                    ));
798                }
799                Some(_) => self.bump_rss_sample(false),
800                None => {
801                    self.bump_rss_sample(true);
802                    self.bump_fresh_import_refusal();
803                    return Err(self.resource_refusal(
804                        "same_process_rss_sample_unavailable",
805                        format!(
806                        "same-process SessionPool refused fresh import for {} import(s): current RSS sample unavailable while max_rss_kib={limit_kib} is configured; {}",
807                        imports.len(),
808                        self.latest_import_stats_diagnostic(),
809                        ),
810                        None,
811                        Some(limit_kib),
812                        Some(stats.imports_performed),
813                        None,
814                        imports.len() as u64,
815                    ));
816                }
817            }
818        }
819
820        Ok(())
821    }
822
823    fn release(
824        &self,
825        key: SessionPoolKey,
826        env: Obj<'lean>,
827        import_stats: LeanImportStats,
828        extension_registry_epoch: u64,
829    ) {
830        let mut inner = self.inner.borrow_mut();
831        let mut s = self.stats.get();
832        let kept = inner.free.len() < self.capacity;
833        if kept {
834            inner.free.push(PooledEntry {
835                key,
836                environment: env,
837                import_stats,
838                extension_registry_epoch,
839            });
840            s.released_to_pool = s.released_to_pool.saturating_add(1);
841        } else {
842            // Drop `env`: its `Obj` Drop runs `lean_dec` and the
843            // environment allocation is freed if the refcount reaches 0.
844            drop(env);
845            s.released_dropped = s.released_dropped.saturating_add(1);
846        }
847        self.stats.set(s);
848        tracing::trace!(
849            target: "lean_rs",
850            kept = kept,
851            "lean_rs.host.pool.release",
852        );
853    }
854}
855
856impl core::fmt::Debug for SessionPool<'_> {
857    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
858        f.debug_struct("SessionPool")
859            .field("capacity", &self.capacity)
860            .field("memory_policy", &self.memory_policy)
861            .field("len", &self.len())
862            .field("stats", &self.stats.get())
863            .finish()
864    }
865}
866
867#[cfg(target_os = "linux")]
868fn current_process_rss_kib() -> Option<u64> {
869    let status = std::fs::read_to_string("/proc/self/status").ok()?;
870    status.lines().find_map(|line| {
871        let rest = line.strip_prefix("VmRSS:")?;
872        rest.split_whitespace().next()?.parse::<u64>().ok()
873    })
874}
875
876#[cfg(not(target_os = "linux"))]
877fn current_process_rss_kib() -> Option<u64> {
878    let output = Command::new("ps")
879        .args(["-o", "rss=", "-p", &std::process::id().to_string()])
880        .output()
881        .ok()?;
882    if !output.status.success() {
883        return None;
884    }
885    let text = String::from_utf8_lossy(&output.stdout);
886    text.trim().parse::<u64>().ok().filter(|value| *value > 0)
887}
888
889// -- PooledSession -------------------------------------------------------
890
891/// A [`LeanSession`] borrowed from a [`SessionPool`].
892///
893/// Behaves as a [`LeanSession`] through [`core::ops::Deref`] /
894/// [`core::ops::DerefMut`]—every session method is reachable directly:
895///
896/// ```ignore
897/// let pool = lean_rs::SessionPool::with_capacity(runtime, 4);
898/// let mut sess = pool.acquire(&caps, &["MyLib"], None, None)?;
899/// let kind = sess.declaration_kind("MyLib.thing", None)?;
900/// // dropping `sess` returns the imported environment to the pool
901/// ```
902///
903/// On `Drop`, the underlying imported environment is returned to the
904/// pool (or released if the pool is at capacity). Per-session
905/// [`crate::host::session::SessionStats`] are scoped to the lifetime of
906/// this checkout—they start at zero on every acquire and are
907/// inaccessible after release.
908///
909/// Three lifetimes: `'lean` (runtime), `'p` (pool borrow), `'c`
910/// (capability borrow). Neither [`Send`] nor [`Sync`] (inherited from
911/// the contained [`LeanSession`]).
912pub struct PooledSession<'lean, 'p, 'c> {
913    pool: &'p SessionPool<'lean>,
914    key: SessionPoolKey,
915    /// `Option` so [`Drop`] can take the session by value without
916    /// resorting to `ManuallyDrop`. Always `Some` between
917    /// construction and `Drop`.
918    session: Option<LeanSession<'lean, 'c>>,
919}
920
921impl<'lean, 'c> core::ops::Deref for PooledSession<'lean, '_, 'c> {
922    type Target = LeanSession<'lean, 'c>;
923
924    // PROOF OBLIGATION: `session` is initialised to `Some` at the only
925    // construction site (`SessionPool::acquire`) and is taken to `None`
926    // exactly once, inside `Drop::drop`. `Deref::deref` is only callable
927    // through a `&self` borrow, which is not possible during `Drop`, so
928    // observing `None` here is structurally impossible.
929    #[allow(clippy::expect_used, reason = "see PROOF OBLIGATION above")]
930    fn deref(&self) -> &Self::Target {
931        self.session
932            .as_ref()
933            .expect("session is Some between PooledSession::acquire and Drop::drop")
934    }
935}
936
937#[allow(
938    single_use_lifetimes,
939    clippy::elidable_lifetime_names,
940    reason = "the named lifetimes line up with `Deref::Target = LeanSession<'lean, 'c>` above; \
941              elision flips the inferred bound and breaks the trait-signature check"
942)]
943impl<'lean, 'c> core::ops::DerefMut for PooledSession<'lean, '_, 'c> {
944    // Same PROOF OBLIGATION as the `Deref` impl above: `DerefMut::deref_mut`
945    // is unreachable from inside `Drop::drop`, so `session` is always
946    // `Some` here.
947    #[allow(clippy::expect_used, reason = "see PROOF OBLIGATION on Deref impl")]
948    fn deref_mut(&mut self) -> &mut LeanSession<'lean, 'c> {
949        self.session
950            .as_mut()
951            .expect("session is Some between PooledSession::acquire and Drop::drop")
952    }
953}
954
955impl Drop for PooledSession<'_, '_, '_> {
956    fn drop(&mut self) {
957        if let Some(session) = self.session.take() {
958            let import_stats = session.import_stats().clone();
959            // Recorded, not re-read: this is the stamp the environment was
960            // imported under, and the next acquire compares it against a live
961            // read rather than trusting it.
962            let extension_registry_epoch = session.extension_registry_epoch();
963            let env = session.into_environment();
964            self.pool
965                .release(self.key.clone(), env, import_stats, extension_registry_epoch);
966        }
967    }
968}
969
970impl core::fmt::Debug for PooledSession<'_, '_, '_> {
971    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
972        f.debug_struct("PooledSession").finish_non_exhaustive()
973    }
974}