use std::{
fs, io,
path::{Path, PathBuf},
sync::atomic::{AtomicU32, Ordering},
thread,
time::{Duration, SystemTime, UNIX_EPOCH},
};
const PREFIX: &str = "tauri-plugin-dotnet-shadow-";
const COPY_ATTEMPTS: u32 = 30;
const RETRY_DELAY: Duration = Duration::from_millis(100);
const MIN_AGE_TO_REMOVE: Duration = Duration::from_secs(60);
#[cfg(not(any(windows, target_os = "linux")))]
const UNKNOWN_LIVENESS_AGE: Duration = Duration::from_secs(24 * 60 * 60);
static COUNTER: AtomicU32 = AtomicU32::new(0);
#[derive(Debug)]
pub(crate) struct ShadowCopy {
pub assembly: PathBuf,
pub folder: PathBuf,
pub files: usize,
}
pub(crate) fn create(assembly: &Path) -> io::Result<ShadowCopy> {
create_in(&std::env::temp_dir(), assembly)
}
fn create_in(root: &Path, assembly: &Path) -> io::Result<ShadowCopy> {
let source = assembly
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the backend path has no folder"))?;
let name = assembly
.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the backend path has no file name"))?;
fs::create_dir_all(root)?;
remove_stale_in(root, SystemTime::now());
let folder = new_folder(root)?;
let files = match copy_tree(source, &folder) {
Ok(files) => files,
Err(e) => {
let _ = fs::remove_dir_all(&folder);
return Err(e);
}
};
Ok(ShadowCopy {
assembly: folder.join(name),
folder,
files,
})
}
fn new_folder(root: &Path) -> io::Result<PathBuf> {
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
for _ in 0..100 {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let folder = root.join(format!("{PREFIX}{}-{stamp}-{n}", std::process::id()));
match fs::create_dir(&folder) {
Ok(()) => return Ok(folder),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(io::ErrorKind::AlreadyExists, "could not find an unused folder name"))
}
fn copy_tree(source: &Path, destination: &Path) -> io::Result<usize> {
fs::create_dir_all(destination)?;
let mut files = 0;
for entry in fs::read_dir(source)? {
let entry = entry?;
let target = destination.join(entry.file_name());
if entry.file_type()?.is_dir() {
files += copy_tree(&entry.path(), &target)?;
} else {
copy_file(&entry.path(), &target)?;
files += 1;
}
}
Ok(files)
}
fn copy_file(source: &Path, target: &Path) -> io::Result<()> {
let mut attempt = 0;
loop {
match fs::copy(source, target) {
Ok(_) => return Ok(()),
Err(e) if attempt < COPY_ATTEMPTS => {
attempt += 1;
log::debug!("tauri-plugin-dotnet: copying {} failed ({e}), retry {attempt}", source.display());
thread::sleep(RETRY_DELAY);
}
Err(e) => {
return Err(io::Error::new(e.kind(), format!("could not copy {}: {e}", source.display())));
}
}
}
}
fn remove_stale_in(root: &Path, now: SystemTime) {
let Ok(entries) = fs::read_dir(root) else { return };
for entry in entries.flatten() {
let name = entry.file_name();
let Some((pid, created_ms)) = name.to_str().and_then(parse_name) else { continue };
let created = UNIX_EPOCH + Duration::from_millis(created_ms.min(u64::MAX as u128) as u64);
let age = now.duration_since(created).unwrap_or_default();
if age < MIN_AGE_TO_REMOVE || pid == std::process::id() || in_use(pid, &entry.path(), age) {
continue;
}
if let Err(e) = fs::remove_dir_all(entry.path()) {
log::debug!("tauri-plugin-dotnet: could not remove {}: {e}", entry.path().display());
}
}
}
fn parse_name(name: &str) -> Option<(u32, u128)> {
let mut parts = name.strip_prefix(PREFIX)?.split('-');
let pid = parts.next()?.parse().ok()?;
let created = parts.next()?.parse().ok()?;
parts.next()?.parse::<u32>().ok()?;
parts.next().is_none().then_some((pid, created))
}
#[cfg(windows)]
fn in_use(_pid: u32, folder: &Path, _age: Duration) -> bool {
let Ok(entries) = fs::read_dir(folder) else { return false };
entries.flatten().any(|entry| {
let path = entry.path();
path.extension().is_some_and(|e| e.eq_ignore_ascii_case("dll"))
&& fs::OpenOptions::new().write(true).open(&path).is_err()
})
}
#[cfg(target_os = "linux")]
fn in_use(pid: u32, _folder: &Path, _age: Duration) -> bool {
Path::new("/proc").join(pid.to_string()).exists()
}
#[cfg(not(any(windows, target_os = "linux")))]
fn in_use(_pid: u32, _folder: &Path, age: Duration) -> bool {
age < UNKNOWN_LIVENESS_AGE
}
#[cfg(test)]
mod tests {
use super::*;
struct Scratch(PathBuf);
impl Scratch {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("tauri-plugin-dotnet-test-shadow-{}-{name}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn file(&self, relative: &str, contents: &str) -> PathBuf {
let path = self.0.join(relative);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, contents).unwrap();
path
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn backend(scratch: &Scratch) -> PathBuf {
scratch.file("build/MyApp.Backend.deps.json", "deps");
scratch.file("build/MyApp.Backend.runtimeconfig.json", "config");
scratch.file("build/Tauri.Plugin.DotNet.dll", "plugin");
scratch.file("build/runtimes/win-x64/native/e_sqlite3.dll", "native");
scratch.file("build/MyApp.Backend.dll", "backend v1")
}
fn fake_copy(root: &Path, pid: u32, age: Duration, with_dll: bool) -> PathBuf {
let created = SystemTime::now().duration_since(UNIX_EPOCH).unwrap() - age;
let folder = root.join(format!("{PREFIX}{pid}-{}-0", created.as_millis()));
fs::create_dir_all(&folder).unwrap();
fs::write(folder.join("MyApp.Backend.json"), "x").unwrap();
if with_dll {
fs::write(folder.join("MyApp.Backend.dll"), "x").unwrap();
}
folder
}
const OLD: Duration = Duration::from_secs(600);
#[test]
fn copies_the_whole_folder_including_subfolders_and_leaves_the_source_alone() {
let scratch = Scratch::new("copies");
let assembly = backend(&scratch);
let copy = create_in(&scratch.0.join("temp"), &assembly).unwrap();
assert_eq!(copy.files, 5);
assert_eq!(copy.assembly, copy.folder.join("MyApp.Backend.dll"));
assert_eq!(fs::read_to_string(©.assembly).unwrap(), "backend v1");
assert_eq!(
fs::read_to_string(copy.folder.join("runtimes/win-x64/native/e_sqlite3.dll")).unwrap(),
"native"
);
assert_eq!(fs::read_to_string(&assembly).unwrap(), "backend v1");
}
#[test]
fn a_later_build_does_not_change_an_existing_copy() {
let scratch = Scratch::new("later-build");
let assembly = backend(&scratch);
let copy = create_in(&scratch.0.join("temp"), &assembly).unwrap();
fs::write(&assembly, "backend v2").unwrap();
assert_eq!(fs::read_to_string(©.assembly).unwrap(), "backend v1");
let next = create_in(&scratch.0.join("temp"), &assembly).unwrap();
assert_eq!(fs::read_to_string(&next.assembly).unwrap(), "backend v2");
}
#[test]
fn every_copy_gets_its_own_folder() {
let scratch = Scratch::new("unique");
let assembly = backend(&scratch);
let root = scratch.0.join("temp");
let a = create_in(&root, &assembly).unwrap();
let b = create_in(&root, &assembly).unwrap();
assert_ne!(a.folder, b.folder);
assert!(a.folder.starts_with(&root) && b.folder.starts_with(&root));
}
#[test]
fn a_missing_backend_folder_is_an_error_and_leaves_no_folder_behind() {
let scratch = Scratch::new("missing");
let root = scratch.0.join("temp");
let error = create_in(&root, &scratch.0.join("not-here/MyApp.Backend.dll")).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::NotFound);
let leftovers = fs::read_dir(&root).map(|d| d.count()).unwrap_or(0);
assert_eq!(leftovers, 0);
}
#[test]
fn names_round_trip_and_odd_names_are_rejected() {
assert_eq!(parse_name("tauri-plugin-dotnet-shadow-42-1789913594838-3"), Some((42, 1789913594838)));
assert_eq!(parse_name("tauri-plugin-dotnet-shadow-42-17-3-extra"), None);
assert_eq!(parse_name("tauri-plugin-dotnet-shadow-x-17-3"), None);
assert_eq!(parse_name("tauri-plugin-dotnet-shadow-42-17"), None);
assert_eq!(parse_name("tauri-plugin-dotnet-1-2-3"), None);
assert_eq!(parse_name("other-42-17-3"), None);
}
#[test]
fn removes_old_copies_of_ended_processes() {
let scratch = Scratch::new("stale");
let ended = fake_copy(&scratch.0, u32::MAX - 1, OLD, false);
remove_stale_in(&scratch.0, SystemTime::now());
assert!(!ended.exists());
}
#[test]
fn keeps_young_copies_this_processes_copies_and_things_that_are_not_copies() {
let scratch = Scratch::new("keep");
let young = fake_copy(&scratch.0, u32::MAX - 1, Duration::from_secs(5), false);
let own = fake_copy(&scratch.0, std::process::id(), OLD, false);
let stranger = scratch.0.join("some-other-folder");
fs::create_dir(&stranger).unwrap();
let odd_name = scratch.0.join(format!("{PREFIX}not-a-pid"));
fs::create_dir(&odd_name).unwrap();
remove_stale_in(&scratch.0, SystemTime::now());
assert!(young.exists() && own.exists() && stranger.exists() && odd_name.exists());
}
#[cfg(target_os = "linux")]
#[test]
fn keeps_the_copy_of_a_running_process() {
let scratch = Scratch::new("running");
let running = fake_copy(&scratch.0, 1, OLD, false);
remove_stale_in(&scratch.0, SystemTime::now());
assert!(running.exists());
}
#[cfg(windows)]
#[test]
fn keeps_a_copy_whose_assembly_is_loaded() {
use std::os::windows::fs::OpenOptionsExt;
let scratch = Scratch::new("loaded");
let loaded = fake_copy(&scratch.0, u32::MAX - 1, OLD, true);
let held = fs::OpenOptions::new()
.read(true)
.share_mode(1) .open(loaded.join("MyApp.Backend.dll"))
.unwrap();
remove_stale_in(&scratch.0, SystemTime::now());
assert!(loaded.exists(), "a copy with a loaded assembly must stay");
drop(held);
remove_stale_in(&scratch.0, SystemTime::now());
assert!(!loaded.exists(), "once nothing holds it, it goes");
}
#[cfg(windows)]
#[test]
fn waits_for_a_file_that_is_busy_for_a_moment() {
use std::os::windows::fs::OpenOptionsExt;
let scratch = Scratch::new("busy");
let assembly = backend(&scratch);
let busy = fs::OpenOptions::new().read(true).share_mode(0).open(&assembly).unwrap();
let release = thread::spawn(move || {
thread::sleep(Duration::from_millis(350));
drop(busy);
});
let started = std::time::Instant::now();
let copy = create_in(&scratch.0.join("temp"), &assembly).unwrap();
release.join().unwrap();
assert!(started.elapsed() >= Duration::from_millis(300), "it should have had to wait");
assert_eq!(fs::read_to_string(©.assembly).unwrap(), "backend v1");
}
}