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}