Release v0.2.1: merge dev into main
This commit is contained in:
commit
cfd9eb8745
5 files changed
+96
-6
No files matched your search
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.2.1
|
||||||
|
|
||||||
|
- Fix high idle watcher CPU usage caused by directory scans generating filesystem open events and triggering further scans.
|
||||||
|
- Ignore read/open access events before queuing watcher work, while retaining write-close events and filesystem overflow recovery.
|
||||||
|
- Batch queued filesystem events into one scan while preserving periodic reconciliation and partial-download retries.
|
||||||
|
- Add a Linux idle CPU regression check alongside the download, rename, duplicate and restart integration checks.
|
||||||
|
|
||||||
|
Validation: 59 automated tests, Clippy, formatting, and release build passed. Local Linux idle profiling confirmed the watcher no longer continuously rescans its download directory.
|
||||||
|
|
||||||
## 0.2.0
|
## 0.2.0
|
||||||
|
|
||||||
- Import components into global or portable project libraries, creating and registering libraries when needed.
|
- Import components into global or portable project libraries, creating and registering libraries when needed.
|
||||||
|
|||||||
Generated
+1
-1
@@ -2869,7 +2869,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uniloader"
|
name = "uniloader"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "uniloader"
|
name = "uniloader"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
|
|
||||||
|
|||||||
+64
-4
@@ -17,7 +17,10 @@ use anyhow::{Context, Result, bail};
|
|||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use importer::{Request, Session};
|
use importer::{Request, Session};
|
||||||
use notify::{RecursiveMode, Watcher};
|
use notify::{
|
||||||
|
Event, EventKind, RecursiveMode, Watcher,
|
||||||
|
event::{AccessKind, AccessMode},
|
||||||
|
};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, BTreeSet},
|
collections::{BTreeMap, BTreeSet},
|
||||||
fs,
|
fs,
|
||||||
@@ -141,10 +144,25 @@ fn complete_zip(p: &Path) -> Result<bool> {
|
|||||||
}
|
}
|
||||||
Ok(supported)
|
Ok(supported)
|
||||||
}
|
}
|
||||||
|
fn event_requires_scan(event: &Event) -> bool {
|
||||||
|
// Reading a watched directory emits OPEN events on Linux, including from
|
||||||
|
// our own scans. Only a writer closing a file can make access relevant.
|
||||||
|
event.need_rescan()
|
||||||
|
|| !matches!(event.kind, EventKind::Access(_))
|
||||||
|
|| matches!(
|
||||||
|
event.kind,
|
||||||
|
EventKind::Access(AccessKind::Close(AccessMode::Write))
|
||||||
|
)
|
||||||
|
}
|
||||||
fn watch(cfg: Config, noninteractive: bool) -> Result<()> {
|
fn watch(cfg: Config, noninteractive: bool) -> Result<()> {
|
||||||
let _watch_lock = storage::Lock::watcher()?;
|
let _watch_lock = storage::Lock::watcher()?;
|
||||||
let (events_tx, events_rx) = mpsc::channel();
|
let (events_tx, events_rx) = mpsc::channel();
|
||||||
let mut watcher = notify::recommended_watcher(events_tx)?;
|
let mut watcher = notify::recommended_watcher(move |event: notify::Result<Event>| {
|
||||||
|
// Filter before queuing so reads cannot keep the receive loop awake.
|
||||||
|
if event.as_ref().map_or(true, event_requires_scan) {
|
||||||
|
let _ = events_tx.send(event);
|
||||||
|
}
|
||||||
|
})?;
|
||||||
for dir in &cfg.watch.directories {
|
for dir in &cfg.watch.directories {
|
||||||
if !dir.is_dir() {
|
if !dir.is_dir() {
|
||||||
bail!(
|
bail!(
|
||||||
@@ -223,8 +241,19 @@ fn watch(cfg: Config, noninteractive: bool) -> Result<()> {
|
|||||||
let mut last_scan = Instant::now();
|
let mut last_scan = Instant::now();
|
||||||
loop {
|
loop {
|
||||||
match events_rx.recv_timeout(Duration::from_millis(500)) {
|
match events_rx.recv_timeout(Duration::from_millis(500)) {
|
||||||
Ok(Ok(_)) => scan(&mut waiting, &seen),
|
Ok(event) => {
|
||||||
Ok(Err(e)) => eprintln!("watch error: {e}"),
|
let mut rescan = false;
|
||||||
|
// Bound draining so sustained downloads cannot starve retries.
|
||||||
|
for event in std::iter::once(event).chain(events_rx.try_iter().take(1023)) {
|
||||||
|
match event {
|
||||||
|
Ok(_) => rescan = true,
|
||||||
|
Err(e) => eprintln!("watch error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rescan {
|
||||||
|
scan(&mut waiting, &seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||||
Err(mpsc::RecvTimeoutError::Disconnected) => bail!("filesystem watcher disconnected"),
|
Err(mpsc::RecvTimeoutError::Disconnected) => bail!("filesystem watcher disconnected"),
|
||||||
}
|
}
|
||||||
@@ -290,6 +319,7 @@ fn watch(cfg: Config, noninteractive: bool) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
if matches!(cli.command, Some(Commands::Dialog)) {
|
if matches!(cli.command, Some(Commands::Dialog)) {
|
||||||
@@ -375,3 +405,33 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod watch_tests {
|
||||||
|
use super::*;
|
||||||
|
use notify::event::{CreateKind, Flag, ModifyKind, RemoveKind};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scans_changes_and_write_closes_but_ignores_read_access() {
|
||||||
|
for kind in [
|
||||||
|
EventKind::Any,
|
||||||
|
EventKind::Create(CreateKind::File),
|
||||||
|
EventKind::Modify(ModifyKind::Any),
|
||||||
|
EventKind::Remove(RemoveKind::File),
|
||||||
|
EventKind::Access(AccessKind::Close(AccessMode::Write)),
|
||||||
|
] {
|
||||||
|
assert!(event_requires_scan(&Event::new(kind)), "{kind:?}");
|
||||||
|
}
|
||||||
|
for kind in [
|
||||||
|
AccessKind::Any,
|
||||||
|
AccessKind::Read,
|
||||||
|
AccessKind::Open(AccessMode::Read),
|
||||||
|
AccessKind::Open(AccessMode::Write),
|
||||||
|
AccessKind::Close(AccessMode::Read),
|
||||||
|
] {
|
||||||
|
let event = Event::new(EventKind::Access(kind));
|
||||||
|
assert!(!event_requires_scan(&event), "{kind:?}");
|
||||||
|
assert!(event_requires_scan(&event.set_flag(Flag::Rescan)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,6 +124,27 @@ fn watcher_handles_partial_and_renamed_downloads_without_blocking_or_reimporting
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.contains("Watching")
|
.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 first = archive("First", 1);
|
||||||
let second = archive("Independent", 2);
|
let second = archive("Independent", 2);
|
||||||
let third = archive("Renamed", 3);
|
let third = archive("Renamed", 3);
|
||||||
|
|||||||
Reference in new issue
Block a user