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