Skip to content

Commit 363d565

Browse files
karthiknadigCopilot
andcommitted
chore: sync issue #504 with main
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2 parents b923035 + aff03b0 commit 363d565

6 files changed

Lines changed: 382 additions & 55 deletions

File tree

crates/pet-python-utils/src/cache.rs

Lines changed: 275 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@ use log::{trace, warn};
66
use std::{
77
collections::{hash_map::Entry, HashMap, HashSet},
88
io,
9-
path::PathBuf,
9+
path::{Path, PathBuf},
1010
sync::{Arc, Mutex},
1111
time::SystemTime,
1212
};
1313

1414
use crate::{
1515
env::ResolvedPythonEnv,
16-
fs_cache::{delete_cache_file, get_cache_from_file, store_cache_in_file},
16+
fs_cache::{
17+
delete_cache_file, executable_cache_key, executable_cache_key_from, get_cache_from_file,
18+
store_cache_in_file,
19+
},
1720
};
1821

1922
lazy_static! {
@@ -22,6 +25,10 @@ lazy_static! {
2225

2326
pub trait CacheEntry: Send + Sync {
2427
fn get(&self) -> Option<ResolvedPythonEnv>;
28+
fn get_for_executable(&self, executable: &Path) -> Option<ResolvedPythonEnv> {
29+
self.get()
30+
.map(|environment| add_executable_alias(environment, executable))
31+
}
2532
fn store(&self, environment: ResolvedPythonEnv);
2633
fn track_symlinks(&self, symlinks: Vec<PathBuf>);
2734
}
@@ -102,6 +109,7 @@ impl CacheImpl {
102109
}
103110
}
104111
fn create_cache(&self, executable: PathBuf) -> LockableCacheEntry {
112+
let cache_key = executable_cache_key(&executable);
105113
let cache_directory = self
106114
.cache_dir
107115
.lock()
@@ -111,11 +119,11 @@ impl CacheImpl {
111119
.locks
112120
.lock()
113121
.expect("locks mutex poisoned")
114-
.entry(executable.clone())
122+
.entry(cache_key.clone())
115123
{
116124
Entry::Occupied(lock) => lock.get().clone(),
117125
Entry::Vacant(lock) => {
118-
let cache = Box::new(CacheEntryImpl::create(cache_directory.clone(), executable))
126+
let cache = Box::new(CacheEntryImpl::create(cache_directory.clone(), cache_key))
119127
as Box<dyn CacheEntry + 'static>;
120128
lock.insert(Arc::new(Mutex::new(cache))).clone()
121129
}
@@ -129,6 +137,83 @@ impl CacheImpl {
129137
/// See: https://github.com/microsoft/python-environment-tools/issues/223
130138
type FilePathWithMTimeCTime = (PathBuf, SystemTime, Option<SystemTime>);
131139

140+
fn current_dir_for_aliases(aliases: &[PathBuf]) -> Option<PathBuf> {
141+
aliases
142+
.iter()
143+
.any(|alias| alias.is_relative())
144+
.then(std::env::current_dir)
145+
.transpose()
146+
.ok()
147+
.flatten()
148+
}
149+
150+
fn add_executable_alias(
151+
mut environment: ResolvedPythonEnv,
152+
executable: &Path,
153+
) -> ResolvedPythonEnv {
154+
let aliases = environment.symlinks.get_or_insert_with(Vec::new);
155+
if !aliases.iter().any(|alias| alias == executable) {
156+
aliases.push(executable.to_path_buf());
157+
aliases.sort();
158+
aliases.dedup();
159+
}
160+
environment
161+
}
162+
163+
fn current_dir_for_cached_aliases(
164+
environment: &ResolvedPythonEnv,
165+
executable: &Path,
166+
) -> Option<PathBuf> {
167+
current_dir_for_cached_aliases_with(environment, executable, std::env::current_dir)
168+
}
169+
170+
fn current_dir_for_cached_aliases_with(
171+
environment: &ResolvedPythonEnv,
172+
executable: &Path,
173+
current_dir: impl FnOnce() -> io::Result<PathBuf>,
174+
) -> Option<PathBuf> {
175+
environment
176+
.symlinks
177+
.as_ref()
178+
.is_some_and(|aliases| {
179+
aliases
180+
.iter()
181+
.any(|alias| alias.is_relative() && alias != executable)
182+
})
183+
.then(current_dir)
184+
.transpose()
185+
.ok()
186+
.flatten()
187+
}
188+
189+
fn bind_validated_executable_alias(
190+
mut environment: ResolvedPythonEnv,
191+
executable: &Path,
192+
tracked_aliases: &[FilePathWithMTimeCTime],
193+
current_dir: Option<&Path>,
194+
) -> ResolvedPythonEnv {
195+
let aliases = environment.symlinks.get_or_insert_with(Vec::new);
196+
aliases.retain(|alias| {
197+
if alias == executable {
198+
return true;
199+
}
200+
if tracked_aliases.iter().any(|tracked| tracked.0 == *alias) {
201+
return true;
202+
}
203+
if alias.is_relative() && current_dir.is_none() {
204+
return false;
205+
}
206+
let key = executable_cache_key_from(alias, current_dir);
207+
tracked_aliases.iter().any(|tracked| tracked.0 == key)
208+
});
209+
if !aliases.iter().any(|alias| alias == executable) {
210+
aliases.push(executable.to_path_buf());
211+
}
212+
aliases.sort();
213+
aliases.dedup();
214+
environment
215+
}
216+
132217
struct CacheEntryImpl {
133218
cache_directory: Option<PathBuf>,
134219
executable: PathBuf,
@@ -146,37 +231,35 @@ impl CacheEntryImpl {
146231
}
147232
}
148233
pub fn verify_in_memory_cache(&self) {
149-
// Check if any of the exes have changed since we last cached this.
150-
for symlink_info in self
234+
let cache_is_valid = self
151235
.symlinks
152236
.lock()
153237
.expect("symlinks mutex poisoned")
154238
.iter()
155-
{
156-
if let Ok(metadata) = symlink_info.0.metadata() {
157-
let mtime_changed = metadata.modified().ok() != Some(symlink_info.1);
158-
// Only check ctime if we have it stored (may be None on Linux)
159-
let ctime_changed = match symlink_info.2 {
160-
Some(stored_ctime) => metadata.created().ok() != Some(stored_ctime),
161-
None => false, // Can't check ctime if we don't have it
162-
};
163-
if mtime_changed || ctime_changed {
164-
trace!(
165-
"Symlink {:?} has changed since we last cached it. original mtime & ctime {:?}, {:?}, current mtime & ctime {:?}, {:?}",
166-
symlink_info.0,
167-
symlink_info.1,
168-
symlink_info.2,
169-
metadata.modified().ok(),
170-
metadata.created().ok()
171-
);
172-
self.envoronment
173-
.lock()
174-
.expect("envoronment mutex poisoned")
175-
.take();
176-
if let Some(cache_directory) = &self.cache_directory {
177-
delete_cache_file(cache_directory, &self.executable);
178-
}
239+
.all(|symlink_info| {
240+
if let Ok(metadata) = symlink_info.0.metadata() {
241+
let mtime_changed = metadata.modified().ok() != Some(symlink_info.1);
242+
let ctime_changed = match symlink_info.2 {
243+
Some(stored_ctime) => metadata.created().ok() != Some(stored_ctime),
244+
None => false,
245+
};
246+
!mtime_changed && !ctime_changed
247+
} else {
248+
false
179249
}
250+
});
251+
252+
if !cache_is_valid {
253+
trace!(
254+
"Tracked executable changed or disappeared for {:?}",
255+
self.executable
256+
);
257+
self.envoronment
258+
.lock()
259+
.expect("envoronment mutex poisoned")
260+
.take();
261+
if let Some(cache_directory) = &self.cache_directory {
262+
delete_cache_file(cache_directory, &self.executable);
180263
}
181264
}
182265
}
@@ -213,16 +296,31 @@ impl CacheEntry for CacheEntryImpl {
213296
}
214297
}
215298

299+
fn get_for_executable(&self, executable: &Path) -> Option<ResolvedPythonEnv> {
300+
let environment = self.get()?;
301+
let current_dir = current_dir_for_cached_aliases(&environment, executable);
302+
let tracked_aliases = self.symlinks.lock().expect("symlinks mutex poisoned");
303+
Some(bind_validated_executable_alias(
304+
environment,
305+
executable,
306+
&tracked_aliases,
307+
current_dir.as_deref(),
308+
))
309+
}
310+
216311
fn store(&self, environment: ResolvedPythonEnv) {
217312
// Get hold of the mtimes and ctimes of the symlinks.
313+
let aliases = environment.symlinks.clone().unwrap_or_default();
314+
let current_dir = current_dir_for_aliases(&aliases);
218315
let mut symlinks = vec![];
219-
for symlink in environment.symlinks.clone().unwrap_or_default().iter() {
316+
for alias in &aliases {
317+
let symlink = executable_cache_key_from(alias, current_dir.as_deref());
220318
if let Ok(metadata) = symlink.metadata() {
221319
// We require mtime, but ctime is optional (not available on all Linux filesystems)
222320
// See: https://github.com/microsoft/python-environment-tools/issues/223
223321
if let Ok(modified) = metadata.modified() {
224322
let created = metadata.created().ok(); // May be None on Linux
225-
symlinks.push((symlink.clone(), modified, created));
323+
symlinks.push((symlink, modified, created));
226324
}
227325
}
228326
}
@@ -259,8 +357,12 @@ impl CacheEntry for CacheEntryImpl {
259357
.iter()
260358
.map(|x| x.0.clone())
261359
.collect();
262-
263-
if symlinks.iter().all(|x| known_symlinks.contains(x)) {
360+
let current_dir = current_dir_for_aliases(&symlinks);
361+
if symlinks
362+
.iter()
363+
.map(|alias| executable_cache_key_from(alias, current_dir.as_deref()))
364+
.all(|key| known_symlinks.contains(&key))
365+
{
264366
return;
265367
}
266368

@@ -283,3 +385,142 @@ impl CacheEntry for CacheEntryImpl {
283385
}
284386
}
285387
}
388+
389+
#[cfg(test)]
390+
mod tests {
391+
use super::*;
392+
use std::sync::atomic::{AtomicUsize, Ordering};
393+
use tempfile::tempdir_in;
394+
395+
fn environment(executable: PathBuf, aliases: Vec<PathBuf>) -> ResolvedPythonEnv {
396+
ResolvedPythonEnv {
397+
executable,
398+
prefix: PathBuf::from("prefix"),
399+
version: "3.12.0".to_string(),
400+
is64_bit: true,
401+
symlinks: Some(aliases),
402+
}
403+
}
404+
405+
fn aliases() -> (tempfile::TempDir, PathBuf, PathBuf) {
406+
let current_dir = std::env::current_dir().unwrap();
407+
let temp_dir = tempdir_in(&current_dir).unwrap();
408+
let absolute = temp_dir.path().join("python");
409+
std::fs::write(&absolute, "python").unwrap();
410+
let relative = absolute.strip_prefix(&current_dir).unwrap().to_path_buf();
411+
(temp_dir, relative, absolute)
412+
}
413+
414+
#[test]
415+
fn relative_and_absolute_aliases_share_in_memory_entry() {
416+
let (_temp_dir, relative, absolute) = aliases();
417+
let cache = CacheImpl::new(None);
418+
419+
let relative_entry = cache.create_cache(relative);
420+
let absolute_entry = cache.create_cache(absolute);
421+
422+
assert!(Arc::ptr_eq(&relative_entry, &absolute_entry));
423+
}
424+
425+
#[test]
426+
fn cache_hit_preserves_canonical_executable_and_current_aliases() {
427+
let (temp_dir, relative, absolute) = aliases();
428+
let canonical = temp_dir.path().join("canonical-python");
429+
std::fs::write(&canonical, "python").unwrap();
430+
let cache = CacheImpl::new(None);
431+
let entry = cache.create_cache(relative.clone());
432+
let entry = entry.lock().unwrap();
433+
entry.store(environment(
434+
canonical.clone(),
435+
vec![relative.clone(), absolute.clone(), canonical.clone()],
436+
));
437+
438+
let relative_hit = entry.get_for_executable(&relative).unwrap();
439+
assert_eq!(relative_hit.executable, canonical);
440+
441+
let absolute_hit = entry.get_for_executable(&absolute).unwrap();
442+
assert_eq!(absolute_hit.executable, canonical);
443+
let hit_aliases = absolute_hit.symlinks.unwrap();
444+
assert!(hit_aliases.contains(&relative));
445+
assert!(hit_aliases.contains(&absolute));
446+
assert!(hit_aliases.contains(&canonical));
447+
}
448+
449+
#[test]
450+
fn disk_cache_reuses_relative_entry_for_absolute_alias() {
451+
let (temp_dir, relative, absolute) = aliases();
452+
let canonical = temp_dir.path().join("canonical-python");
453+
std::fs::write(&canonical, "python").unwrap();
454+
let cache_directory = temp_dir.path().join("cache");
455+
{
456+
let cache = CacheImpl::new(Some(cache_directory.clone()));
457+
let entry = cache.create_cache(relative.clone());
458+
entry.lock().unwrap().store(environment(
459+
canonical.clone(),
460+
vec![relative.clone(), absolute.clone(), canonical.clone()],
461+
));
462+
}
463+
464+
let cache = CacheImpl::new(Some(cache_directory));
465+
let entry = cache.create_cache(absolute.clone());
466+
let hit = entry.lock().unwrap().get_for_executable(&absolute).unwrap();
467+
468+
assert_eq!(hit.executable, canonical);
469+
let hit_aliases = hit.symlinks.unwrap();
470+
assert!(hit_aliases.contains(&relative));
471+
assert!(hit_aliases.contains(&absolute));
472+
assert!(hit_aliases.contains(&canonical));
473+
}
474+
475+
#[test]
476+
fn stale_relative_alias_from_another_working_directory_is_dropped() {
477+
let (temp_dir, relative, absolute) = aliases();
478+
let metadata = absolute.metadata().unwrap();
479+
let tracked_aliases = vec![(
480+
absolute.clone(),
481+
metadata.modified().unwrap(),
482+
metadata.created().ok(),
483+
)];
484+
let stale_working_directory = temp_dir.path().join("another-workspace");
485+
486+
let hit = bind_validated_executable_alias(
487+
environment(absolute.clone(), vec![relative.clone(), absolute.clone()]),
488+
&absolute,
489+
&tracked_aliases,
490+
Some(&stale_working_directory),
491+
);
492+
493+
let hit_aliases = hit.symlinks.unwrap();
494+
assert!(!hit_aliases.contains(&relative));
495+
assert!(hit_aliases.contains(&absolute));
496+
}
497+
498+
#[test]
499+
fn absolute_cache_hit_does_not_query_current_directory() {
500+
let (_temp_dir, _relative, absolute) = aliases();
501+
let current_dir_calls = AtomicUsize::new(0);
502+
let environment = environment(absolute.clone(), vec![absolute.clone()]);
503+
504+
let current_dir = current_dir_for_cached_aliases_with(&environment, &absolute, || {
505+
current_dir_calls.fetch_add(1, Ordering::Relaxed);
506+
std::env::current_dir()
507+
});
508+
509+
assert!(current_dir.is_none());
510+
assert_eq!(current_dir_calls.load(Ordering::Relaxed), 0);
511+
}
512+
513+
#[test]
514+
fn missing_tracked_executable_invalidates_in_memory_entry() {
515+
let (temp_dir, _relative, absolute) = aliases();
516+
let cache = CacheImpl::new(Some(temp_dir.path().join("cache")));
517+
let entry = cache.create_cache(absolute.clone());
518+
let entry = entry.lock().unwrap();
519+
entry.store(environment(absolute.clone(), vec![absolute.clone()]));
520+
assert!(entry.get().is_some());
521+
522+
std::fs::remove_file(&absolute).unwrap();
523+
524+
assert!(entry.get().is_none());
525+
}
526+
}

crates/pet-python-utils/src/env.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ impl ResolvedPythonEnv {
8484
) -> Option<Self> {
8585
let cache = create_cache(executable.to_path_buf());
8686
let entry = cache.lock().expect("cache mutex poisoned");
87-
if let Some(env) = entry.get() {
87+
if let Some(env) = entry.get_for_executable(executable) {
8888
Some(env)
8989
} else if let Some(env) = get_interpreter_details(executable) {
9090
entry.store(env.clone());

0 commit comments

Comments
 (0)