Files

218 lines
7.6 KiB
Rust

//! Actual filesystem watcher subprocess: no display, user state or KiCad needed.
use std::{
fs,
io::{Cursor, Write},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
thread,
time::{Duration, Instant, SystemTime},
};
use zip::{ZipWriter, write::SimpleFileOptions};
struct Watcher(Child);
impl Drop for Watcher {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn until(label: &str, log: &Path, mut check: impl FnMut() -> bool) {
let start = Instant::now();
while start.elapsed() < Duration::from_secs(5) {
if check() {
return;
}
thread::sleep(Duration::from_millis(40));
}
panic!(
"timed out waiting for {label}; watcher log:\n{}",
fs::read_to_string(log).unwrap_or_default()
);
}
fn archive(name: &str, pad: usize) -> Vec<u8> {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
let fp = format!(
r#"(footprint "{name}" (version 20240108) (layer "F.Cu") (pad "{pad}" smd rect (at 0 0) (size 1 1) (layers "F.Cu" "F.Paste" "F.Mask")))"#
);
let sym = format!(
r#"(kicad_symbol_lib (version 20231120) (generator "watch-fixture") (symbol "{name}" (property "Reference" "U") (property "Footprint" "{name}")))"#
);
for (file, data) in [
(format!("{name}.kicad_mod"), fp),
(format!("{name}.kicad_sym"), sym),
] {
zip.start_file(file, SimpleFileOptions::default()).unwrap();
zip.write_all(data.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
fn completed(root: &Path) -> usize {
let Ok(entries) = fs::read_dir(root.join("data/uniloader/imports")) else {
return 0;
};
entries
.flatten()
.filter(|entry| {
fs::read(entry.path())
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.is_some_and(|record| record["status"] == "completed")
})
.count()
}
fn snapshot(root: &Path) -> Vec<(PathBuf, Vec<u8>, SystemTime)> {
let mut files = vec![];
for tree in [root.join("project"), root.join("data/uniloader/imports")] {
for entry in walkdir::WalkDir::new(tree).into_iter().flatten() {
if entry.file_type().is_file() {
files.push((
entry.path().to_owned(),
fs::read(entry.path()).unwrap(),
entry.metadata().unwrap().modified().unwrap(),
));
}
}
}
files.sort_by(|a, b| a.0.cmp(&b.0));
files
}
#[test]
fn watcher_handles_partial_and_renamed_downloads_without_blocking_or_reimporting() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let downloads = root.join("downloads");
fs::create_dir_all(&downloads).unwrap();
let project = root.join("project/board.kicad_pro");
fs::create_dir_all(project.parent().unwrap()).unwrap();
fs::write(&project, "{}").unwrap();
let config = root.join("config.toml");
fs::write(&config, format!("[libraries]\npath = {:?}\nkicad_config = {:?}\n[watch]\ndirectories = [{:?}]\nsettle_seconds = 0\nretry_seconds = 2\n[destination]\npolicy = \"project\"\nproject = {:?}\n[matching]\nambiguous = \"separate\"\n[ui]\nenabled = false\n", root.join("global"), root.join("kicad"), downloads, project)).unwrap();
let log = root.join("watch.log");
let spawn = || {
let logfile = fs::File::create(&log).unwrap();
Watcher(
Command::new(env!("CARGO_BIN_EXE_uniloader"))
.args([
"--config",
config.to_str().unwrap(),
"--noninteractive",
"watch",
])
.env("HOME", root.join("home"))
.env("XDG_CONFIG_HOME", root.join("config"))
.env("XDG_DATA_HOME", root.join("data"))
.env("XDG_CACHE_HOME", root.join("cache"))
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("DBUS_SESSION_BUS_ADDRESS")
.current_dir(root)
.stdin(Stdio::null())
.stdout(Stdio::from(logfile.try_clone().unwrap()))
.stderr(Stdio::from(logfile))
.spawn()
.unwrap(),
)
};
let watcher = spawn();
until("watcher readiness", &log, || {
fs::read_to_string(&log)
.unwrap_or_default()
.contains("Watching")
});
// Directory scans emit Linux inotify OPEN events themselves. Idle watching
// must block instead of repeatedly scanning in response to its own reads.
#[cfg(target_os = "linux")]
{
let runtime = || {
fs::read_to_string(format!("/proc/{}/schedstat", watcher.0.id()))
.unwrap()
.split_whitespace()
.next()
.unwrap()
.parse::<u64>()
.unwrap()
};
let before = runtime();
thread::sleep(Duration::from_secs(1));
let cpu = Duration::from_nanos(runtime() - before);
assert!(
cpu < Duration::from_millis(200),
"idle watcher spent {cpu:?} on CPU in one second"
);
}
let first = archive("First", 1);
let second = archive("Independent", 2);
let third = archive("Renamed", 3);
// A corrupt/incomplete final filename must not block an independent ZIP.
fs::write(downloads.join("partial.zip"), &first[..20]).unwrap();
fs::write(downloads.join("independent.zip"), &second).unwrap();
// A completed .part is still not a component until its final rename.
fs::write(downloads.join("renamed.zip.part"), &third).unwrap();
until(
"independent component despite partial archive",
&log,
|| completed(root) == 1,
);
assert!(
!root
.join("project/libraries/UniLoader.pretty/First.kicad_mod")
.exists()
);
assert!(
!root
.join("project/libraries/UniLoader.pretty/Renamed.kicad_mod")
.exists()
);
fs::write(downloads.join("partial.zip.part"), &first).unwrap();
fs::rename(
downloads.join("partial.zip.part"),
downloads.join("partial.zip"),
)
.unwrap();
fs::rename(
downloads.join("renamed.zip.part"),
downloads.join("renamed.zip"),
)
.unwrap();
until("both renamed components", &log, || completed(root) == 3);
for (file, bytes) in [
("partial.zip", &first),
("independent.zip", &second),
("renamed.zip", &third),
] {
assert_eq!(
&fs::read(downloads.join(file)).unwrap(),
bytes,
"source archive must be preserved"
);
}
let before = snapshot(root);
// Duplicate creation and write events, including a new pathname for the
// same archive content, must not edit libraries/history again.
fs::write(downloads.join("duplicate.zip"), &first).unwrap();
fs::write(downloads.join("independent.zip"), &second).unwrap();
thread::sleep(Duration::from_millis(800));
assert_eq!(completed(root), 3);
assert_eq!(before, snapshot(root));
drop(watcher);
let restarted = spawn();
until("watcher restart", &log, || {
fs::read_to_string(&log)
.unwrap_or_default()
.contains("Watching")
});
thread::sleep(Duration::from_millis(800));
assert_eq!(completed(root), 3);
assert_eq!(
before,
snapshot(root),
"restart must not rewrite completed imports"
);
drop(restarted);
}