venus_core/execute/
windows_dll.rs

1//! Windows DLL hot-reload handler.
2//!
3//! On Windows, loaded DLLs cannot be deleted or overwritten while in use.
4//! This module provides a UUID-based strategy to work around this limitation:
5//!
6//! 1. When loading a DLL, copy it to a unique temp file with UUID suffix
7//! 2. Load the copy instead of the original
8//! 3. Track which copies are in use
9//! 4. Clean up old copies when safe
10//!
11//! # Example
12//!
13//! ```ignore
14//! use venus_core::execute::WindowsDllHandler;
15//!
16//! let mut handler = WindowsDllHandler::new(PathBuf::from(".venus/build/temp"));
17//!
18//! // Load a DLL (on Windows, this creates a UUID copy)
19//! let loadable_path = handler.prepare_for_load(&compiled_dll_path)?;
20//! let library = Library::new(&loadable_path)?;
21//!
22//! // When done with the library (must drop it first)
23//! drop(library);
24//! handler.release(&loadable_path);
25//!
26//! // Clean up old copies
27//! handler.cleanup_old_copies()?;
28//! ```
29
30use std::collections::HashMap;
31use std::fs;
32use std::io;
33use std::path::{Path, PathBuf};
34use std::time::{Duration, SystemTime};
35
36#[cfg(windows)]
37use uuid::Uuid;
38
39/// Handler for Windows DLL hot-reload.
40///
41/// Manages UUID-named copies of DLLs to allow recompilation while
42/// previous versions are still loaded.
43pub struct WindowsDllHandler {
44    /// Directory for temporary DLL copies.
45    temp_dir: PathBuf,
46
47    /// Map from temp path to original path for tracking.
48    /// Key: UUID-named temp path, Value: original DLL path
49    active_copies: HashMap<PathBuf, PathBuf>,
50
51    /// Maximum age for unused DLL copies before cleanup (default: 1 hour).
52    max_age: Duration,
53}
54
55impl WindowsDllHandler {
56    /// Create a new Windows DLL handler.
57    ///
58    /// # Arguments
59    ///
60    /// * `temp_dir` - Directory for temporary DLL copies
61    pub fn new(temp_dir: PathBuf) -> Self {
62        Self {
63            temp_dir,
64            active_copies: HashMap::new(),
65            max_age: Duration::from_secs(3600), // 1 hour default
66        }
67    }
68
69    /// Set the maximum age for cleanup.
70    pub fn with_max_age(mut self, max_age: Duration) -> Self {
71        self.max_age = max_age;
72        self
73    }
74
75    /// Prepare a DLL for loading.
76    ///
77    /// On Windows, this copies the DLL to a UUID-named file in the temp directory.
78    /// On other platforms, this returns the original path unchanged.
79    ///
80    /// # Arguments
81    ///
82    /// * `dll_path` - Path to the compiled DLL
83    ///
84    /// # Returns
85    ///
86    /// Path to load (either the original or a UUID copy).
87    pub fn prepare_for_load(&mut self, dll_path: &Path) -> io::Result<PathBuf> {
88        #[cfg(windows)]
89        {
90            self.create_uuid_copy(dll_path)
91        }
92
93        #[cfg(not(windows))]
94        {
95            // On non-Windows platforms, just return the original path
96            Ok(dll_path.to_path_buf())
97        }
98    }
99
100    /// Create a UUID-named copy of a DLL (Windows-specific).
101    #[cfg(windows)]
102    fn create_uuid_copy(&mut self, dll_path: &Path) -> io::Result<PathBuf> {
103        // Ensure temp directory exists
104        fs::create_dir_all(&self.temp_dir)?;
105
106        // Generate UUID-based filename
107        let uuid = Uuid::new_v4();
108        let original_name = dll_path
109            .file_stem()
110            .and_then(|s| s.to_str())
111            .unwrap_or("cell");
112        let extension = dll_path.extension().and_then(|s| s.to_str()).unwrap_or("dll");
113
114        let temp_name = format!("{}-{}.{}", original_name, uuid, extension);
115        let temp_path = self.temp_dir.join(temp_name);
116
117        // Copy the DLL
118        fs::copy(dll_path, &temp_path)?;
119
120        // Track the copy
121        self.active_copies
122            .insert(temp_path.clone(), dll_path.to_path_buf());
123
124        tracing::debug!(
125            "Created DLL copy: {} -> {}",
126            dll_path.display(),
127            temp_path.display()
128        );
129
130        Ok(temp_path)
131    }
132
133    /// Release a loaded DLL path.
134    ///
135    /// Call this after dropping the loaded library to mark the temp file
136    /// as eligible for cleanup.
137    ///
138    /// # Arguments
139    ///
140    /// * `loaded_path` - Path that was returned by `prepare_for_load`
141    pub fn release(&mut self, loaded_path: &Path) {
142        self.active_copies.remove(loaded_path);
143    }
144
145    /// Check if a path is an active copy.
146    pub fn is_active(&self, path: &Path) -> bool {
147        self.active_copies.contains_key(path)
148    }
149
150    /// Get all active copy paths.
151    pub fn active_paths(&self) -> impl Iterator<Item = &Path> {
152        self.active_copies.keys().map(|p| p.as_path())
153    }
154
155    /// Clean up old DLL copies.
156    ///
157    /// Removes temp files that are:
158    /// 1. Not currently tracked as active
159    /// 2. Older than `max_age`
160    ///
161    /// # Returns
162    ///
163    /// Number of files cleaned up.
164    pub fn cleanup_old_copies(&self) -> io::Result<usize> {
165        if !self.temp_dir.exists() {
166            return Ok(0);
167        }
168
169        let cutoff = SystemTime::now()
170            .checked_sub(self.max_age)
171            .unwrap_or(SystemTime::UNIX_EPOCH);
172
173        let mut cleaned = 0;
174
175        for entry in fs::read_dir(&self.temp_dir)? {
176            let entry = entry?;
177            let path = entry.path();
178
179            // Skip if this is an active copy
180            if self.active_copies.contains_key(&path) {
181                continue;
182            }
183
184            // Check file age - collapse nested conditionals for clarity
185            let is_old = entry
186                .metadata()
187                .and_then(|m| m.modified())
188                .map(|modified| modified < cutoff)
189                .unwrap_or(false);
190
191            if is_old && fs::remove_file(&path).is_ok() {
192                tracing::debug!("Cleaned up old DLL: {}", path.display());
193                cleaned += 1;
194            }
195        }
196
197        if cleaned > 0 {
198            tracing::info!("Cleaned up {} old DLL copies", cleaned);
199        }
200
201        Ok(cleaned)
202    }
203
204    /// Force cleanup of all non-active copies.
205    ///
206    /// Attempts to remove all temp files that are not currently active.
207    /// Files that are locked will be skipped.
208    ///
209    /// # Returns
210    ///
211    /// Number of files cleaned up.
212    pub fn cleanup_all(&self) -> io::Result<usize> {
213        if !self.temp_dir.exists() {
214            return Ok(0);
215        }
216
217        let mut cleaned = 0;
218
219        for entry in fs::read_dir(&self.temp_dir)? {
220            let entry = entry?;
221            let path = entry.path();
222
223            // Skip if this is an active copy
224            if self.active_copies.contains_key(&path) {
225                continue;
226            }
227
228            // Skip non-DLL files
229            let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
230            if !matches!(extension, "dll" | "so" | "dylib") {
231                continue;
232            }
233
234            // Try to remove (may fail on Windows if still locked)
235            if let Ok(()) = fs::remove_file(&path) {
236                tracing::debug!("Force cleaned DLL: {}", path.display());
237                cleaned += 1;
238            }
239        }
240
241        Ok(cleaned)
242    }
243
244    /// Get the temp directory path.
245    pub fn temp_dir(&self) -> &Path {
246        &self.temp_dir
247    }
248}
249
250impl Default for WindowsDllHandler {
251    fn default() -> Self {
252        Self::new(PathBuf::from(".venus/build/temp"))
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use std::thread;
260    use tempfile::tempdir;
261
262    #[test]
263    fn test_handler_creation() {
264        let handler = WindowsDllHandler::new(PathBuf::from("/tmp/test"));
265        assert_eq!(handler.temp_dir(), Path::new("/tmp/test"));
266        assert!(handler.active_paths().next().is_none());
267    }
268
269    #[test]
270    fn test_with_max_age() {
271        let handler = WindowsDllHandler::new(PathBuf::from("/tmp/test"))
272            .with_max_age(Duration::from_secs(60));
273        assert_eq!(handler.max_age, Duration::from_secs(60));
274    }
275
276    #[test]
277    fn test_prepare_for_load_non_windows() {
278        let temp = tempdir().unwrap();
279        let mut handler = WindowsDllHandler::new(temp.path().join("temp"));
280
281        let dll_path = temp.path().join("test.so");
282        fs::write(&dll_path, b"fake dll").unwrap();
283
284        let result = handler.prepare_for_load(&dll_path).unwrap();
285
286        // On non-Windows, should return original path
287        #[cfg(not(windows))]
288        assert_eq!(result, dll_path);
289
290        // On Windows, should return a UUID copy
291        #[cfg(windows)]
292        {
293            assert_ne!(result, dll_path);
294            assert!(result.exists());
295            assert!(handler.is_active(&result));
296        }
297    }
298
299    #[test]
300    fn test_release() {
301        let temp = tempdir().unwrap();
302        let mut handler = WindowsDllHandler::new(temp.path().join("temp"));
303
304        let fake_path = temp.path().join("fake.dll");
305        handler.active_copies.insert(fake_path.clone(), PathBuf::from("original.dll"));
306
307        assert!(handler.is_active(&fake_path));
308        handler.release(&fake_path);
309        assert!(!handler.is_active(&fake_path));
310    }
311
312    #[test]
313    fn test_cleanup_old_copies() {
314        let temp = tempdir().unwrap();
315        let temp_dir = temp.path().join("temp");
316        fs::create_dir_all(&temp_dir).unwrap();
317
318        let handler = WindowsDllHandler::new(temp_dir.clone())
319            .with_max_age(Duration::from_millis(10));
320
321        // Create an old file
322        let old_file = temp_dir.join("old-test.dll");
323        fs::write(&old_file, b"old").unwrap();
324
325        // Wait for it to age
326        thread::sleep(Duration::from_millis(20));
327
328        // Clean up
329        let cleaned = handler.cleanup_old_copies().unwrap();
330
331        assert_eq!(cleaned, 1);
332        assert!(!old_file.exists());
333    }
334
335    #[test]
336    fn test_cleanup_skips_active() {
337        let temp = tempdir().unwrap();
338        let temp_dir = temp.path().join("temp");
339        fs::create_dir_all(&temp_dir).unwrap();
340
341        let mut handler = WindowsDllHandler::new(temp_dir.clone())
342            .with_max_age(Duration::from_millis(10));
343
344        // Create a file and mark it active
345        let active_file = temp_dir.join("active.dll");
346        fs::write(&active_file, b"active").unwrap();
347        handler.active_copies.insert(active_file.clone(), PathBuf::from("original.dll"));
348
349        // Wait for it to age
350        thread::sleep(Duration::from_millis(20));
351
352        // Clean up should skip it
353        let cleaned = handler.cleanup_old_copies().unwrap();
354
355        assert_eq!(cleaned, 0);
356        assert!(active_file.exists());
357    }
358
359    #[test]
360    fn test_cleanup_all() {
361        let temp = tempdir().unwrap();
362        let temp_dir = temp.path().join("temp");
363        fs::create_dir_all(&temp_dir).unwrap();
364
365        let mut handler = WindowsDllHandler::new(temp_dir.clone());
366
367        // Create files
368        let file1 = temp_dir.join("test1.dll");
369        let file2 = temp_dir.join("test2.so");
370        let active = temp_dir.join("active.dylib");
371
372        fs::write(&file1, b"1").unwrap();
373        fs::write(&file2, b"2").unwrap();
374        fs::write(&active, b"active").unwrap();
375
376        handler.active_copies.insert(active.clone(), PathBuf::from("original.dylib"));
377
378        // Clean up all
379        let cleaned = handler.cleanup_all().unwrap();
380
381        assert_eq!(cleaned, 2);
382        assert!(!file1.exists());
383        assert!(!file2.exists());
384        assert!(active.exists()); // Active file preserved
385    }
386}