1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
//! D-2 idle-engine eviction for [`ProjectRegistry`] (memory-pressure
//! remediation).
//!
//! Extracted into a sibling module so `registry.rs` stays comfortably under
//! the 2000-line Large-File gate while the MCP lifecycle batch (T2–T7) keeps
//! adding eviction-adjacent code.
use std::path::Path;
use crate::cli::registry::ProjectRegistry;
impl ProjectRegistry {
/// Record that `path` was just used (D-2 idle-eviction clock).
pub(crate) async fn touch_last_used(&self, path: &Path) {
self.last_used
.write()
.await
.insert(path.to_path_buf(), std::time::Instant::now());
}
/// Evict loaded engines that have been idle (no `get_or_load`/touch) for
/// longer than `max_idle`. Projects with an active call (lock held) are
/// skipped. Returns the number of projects evicted.
///
/// D-2 memory-pressure remediation: a long-lived MCP process that touched
/// a large project (e.g. the 51 GiB-index workstation project) must not
/// retain that engine's mmaps/heap for the process lifetime. The next
/// tool call transparently reloads via `get_or_load`.
///
/// **Atomicity (Codex P2):** every candidate is checked and evicted under
/// ONE `projects` write + `last_used` read hold (acquired in that order,
/// matching `get_or_load`'s `projects` read -> `last_used` write order, so
/// the two paths can never deadlock). `get_or_load` refreshes `last_used`
/// *and* clones the project `Arc` while its `projects` read guard is still
/// live (the `Arc::clone` happens in the `return` inside that guard's
/// block), so a fresh timestamp observed under our held write lock
/// provably means an `Arc` is outstanding for this project — we skip it
/// rather than close storage out from under the pending request.
/// `try_write()` additionally skips a caller that is currently inside the
/// inner lock. Together these close the window in which an eviction could
/// hand a later-locking caller a closed index (previously the freshness
/// check, the removal, and the close each ran under separate lock
/// acquisitions).
pub async fn evict_idle_engines(&self, max_idle: std::time::Duration) -> usize {
let candidates: Vec<std::path::PathBuf> = {
let last_used = self.last_used.read().await;
let projects = self.projects.read().await;
projects
.keys()
.filter(|path| {
last_used
.get(*path)
.map(|last| last.elapsed() > max_idle)
.unwrap_or(true)
})
.cloned()
.collect()
};
let mut evicted = 0;
for path in candidates {
// One atomic sequence per candidate (Codex P2). Lock order is
// `projects` write FIRST, then `last_used` read — matching
// `get_or_load` (projects.read -> last_used.write via
// `touch_last_used`), so the sweep can never deadlock against an
// in-flight tool call. Holding the projects write lock through the
// checks + remove + close means no `get_or_load` read section can
// be in flight: the freshness value we read is authoritative, and
// no new Arc can be cloned between the removal and the close.
let mut projects = self.projects.write().await;
let last_used = self.last_used.read().await;
if !projects.contains_key(&path) {
continue;
}
// A `get_or_load` landed since the snapshot: it refreshed
// `last_used`, so an Arc is outstanding — never close storage out
// from under that pending request.
let touched_recently = last_used
.get(&path)
.map(|last| last.elapsed() <= max_idle)
.unwrap_or(true);
if touched_recently {
continue;
}
// A caller is currently inside the inner lock: in-flight request.
let in_flight = projects
.get(&path)
.is_some_and(|handle| handle.try_write().is_err());
if in_flight {
continue;
}
// Under the held write lock the key cannot vanish between the
// checks and the removal; still, degrade gracefully rather than
// panicking the sweep task if the invariant ever breaks.
let Some(handle) = projects.remove(&path) else {
continue;
};
match handle.try_write() {
Ok(mut idx) => {
if let Err(e) = idx.close() {
tracing::warn!(
"Failed to close storage for evicted project {}: {}",
path.display(),
e
);
}
}
// A caller acquired the inner lock between our checks (a
// pre-existing Arc holder, e.g. a long-parked handler): leave
// the index alive; storage closes when its last Arc drops.
Err(()) => tracing::debug!(
"Skipped close for in-flight evicted project {}",
path.display()
),
}
tracing::info!("Evicted project: {}", path.display());
drop(projects);
drop(last_used);
self.cleanup_evicted(&path).await;
evicted += 1;
}
evicted
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::leindex::LeIndex;
#[tokio::test]
async fn test_evict_idle_engines_removes_idle_project() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("main.rs"), "fn main() {}\n").unwrap();
let leindex = LeIndex::new(tmp.path()).unwrap();
let registry = ProjectRegistry::with_initial_project(5, leindex);
let canonical = tmp.path().canonicalize().unwrap();
assert_eq!(registry.len().await, 1);
// Age the D-2 timestamp past the idle window, then sweep.
registry.last_used.write().await.insert(
canonical.clone(),
std::time::Instant::now() - std::time::Duration::from_secs(3600),
);
let evicted = registry
.evict_idle_engines(std::time::Duration::from_secs(600))
.await;
assert_eq!(evicted, 1);
assert_eq!(registry.len().await, 0);
// The evicted project reloads transparently on the next request.
let handle = registry.get_or_load(None).await.unwrap();
assert_eq!(handle.read().await.project_path(), &canonical);
}
#[tokio::test]
async fn test_evict_idle_engines_skips_recent_and_in_flight() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("main.rs"), "fn main() {}\n").unwrap();
let leindex = LeIndex::new(tmp.path()).unwrap();
let registry = ProjectRegistry::with_initial_project(5, leindex);
let canonical = tmp.path().canonicalize().unwrap();
// Recently-touched project: not idle, must survive the sweep.
registry.touch_last_used(&canonical).await;
let evicted = registry
.evict_idle_engines(std::time::Duration::from_secs(600))
.await;
assert_eq!(evicted, 0);
assert_eq!(registry.len().await, 1);
// In-flight project (an active tool call holds the lock): even with a
// stale timestamp, eviction must skip it (D-2 consistency guard).
let handle = registry.get_or_load(None).await.unwrap();
// get_or_load touches last_used, so age the timestamp AFTER the load,
// then hold the read lock to simulate an active tool call.
registry.last_used.write().await.insert(
canonical.clone(),
std::time::Instant::now() - std::time::Duration::from_secs(3600),
);
let _active_call = handle.read().await;
let evicted = registry
.evict_idle_engines(std::time::Duration::from_secs(600))
.await;
assert_eq!(evicted, 0, "in-flight engine must not be evicted");
assert_eq!(registry.len().await, 1);
drop(_active_call);
// After the call completes, the stale project is evictable again.
let evicted = registry
.evict_idle_engines(std::time::Duration::from_secs(600))
.await;
assert_eq!(evicted, 1);
assert_eq!(registry.len().await, 0);
}
}