Footage: one watcher thread that wakes the interface only when a file changed, instead of a 2 s poll

Settings ▸ Import ▸ Reload Footage Changed on Disk used to spawn a stat thread every two seconds
from the UI thread, request a repaint 100 ms later to collect it, and request another repaint every
2 s in between, for every project with items, focused or not: the whole interface redrew on a timer
for footage that hadn't changed.

`footage_reload::Watcher` is one long-lived worker: the interface tells it what to watch (recomputed
when the project revision or the preference changes) and whether the window is in front; it stamps
the files every 2 s while the window is in front, every 10 s while another app has the front, at once
on focus regain and on a new set, and hands the stamps over — calling `ctx.request_repaint()` — only
when one differs from its last look. Nothing watched means the thread sleeps. `apply_stamps` and the
reload semantics are unchanged; `start_scan`/`poll_scan`/`Session::footage_scan` are gone. No new
dependencies: the stat loop is also what footage on a network share relies on, where FSEvents would
not report another machine's writes.

Tests: the watcher wakes the interface for the first stamps and for each change and never for an
unchanged file; behind another app it rests until the window is back; nothing watched is nothing
stamped and a new set is reported whole (and dropping it ends the thread); the host test
`the_watcher_notices_a_footage_file_changed_on_disk` saves a PSD from "another app" and sees it
reloaded once.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jason Luker
2026-10-29 15:41:53 -07:00
co-authored by Claude Fable 5.1
parent 2a61a05fd4
commit 26e6145cee
8 changed files with 328 additions and 70 deletions
+249 -32
View File
@@ -9,31 +9,35 @@
//! render reads the new pixels.
//!
//! The automatic part compares each footage file's size and modification time with what they
//! were when the file was last seen. The desktop app takes those stamps every two seconds (and
//! when its window comes back to the front) on a worker thread, so a file on a slow network share
//! never holds up the interface ([`start_scan`], [`poll_scan`]); `footage.reloadChanged` takes them
//! on the spot (CLI, agents, tests). Like the footage check after File ▸ Open, an automatic reload
//! records the state of the disk: it is not an undo step and doesn't modify the project.
//! were when the file was last seen. The desktop app keeps a [`Watcher`]: a worker thread that
//! stamps the files every two seconds while the window is in front (every ten seconds while it
//! is not, and at once when it comes back to the front) and wakes the interface only when a stamp
//! changed, so a file on a slow network share never holds up the interface and an idle app never
//! redraws for footage that didn't change; `footage.reloadChanged` takes the stamps on the spot
//! (CLI, agents, tests). Like the footage check after File ▸ Open, an automatic reload records
//! the state of the disk: it is not an undo step and doesn't modify the project.
use std::collections::BTreeSet;
use std::sync::{Arc, Mutex};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, SourceLayer};
use serde_json::{Value, json};
use crate::Session;
use crate::{Services, Session};
/// A file's size and modification time (see [`crate::Services::stamp`]).
pub type Stamp = (u64, u128);
/// Stamps taken on a worker thread, waiting for [`poll_scan`].
pub type Scan = Arc<Mutex<Option<Vec<(String, Option<Stamp>)>>>>;
/// The stamps of a set of files, in path order.
pub type Stamps = Vec<(String, Option<Stamp>)>;
/// The files footage items read: each item's file and every frame of an image sequence. Image
/// sequences count only with Automatically Reload Footage set to All Footage (After Effects'
/// default, Non-Sequence Footage, leaves them alone: thousands of frames on a share add up).
/// Items with a stored path that is not followed ([`crate::doc_path`]) are not watched.
fn watched(s: &Session) -> BTreeSet<String> {
pub fn watched(s: &Session) -> BTreeSet<String> {
let sequences = s.prefs.import.auto_reload_footage == "all";
let mut paths = BTreeSet::new();
for it in s.project.items.values() {
@@ -267,31 +271,244 @@ pub(crate) fn command(s: &mut Session, _: &Value) -> crate::Result<Value> {
apply_stamps(s, now)
}
/// Start stamping the watched files on a worker thread, unless a scan is still running (or the
/// build has no threads: the web app, whose footage lives in browser storage).
pub fn start_scan(s: &mut Session) {
if s.footage_scan.is_some() || cfg!(target_arch = "wasm32") {
return;
/// How often the watched files are stamped while the window is in front.
pub const FOCUSED_INTERVAL: Duration = Duration::from_secs(2);
/// How often they are stamped while another app has the front: a file saved there is noticed
/// as soon as the window comes back, so this only covers a second screen.
pub const UNFOCUSED_INTERVAL: Duration = Duration::from_secs(10);
enum Watch {
Paths(BTreeSet<String>),
Focused(bool),
}
/// The desktop app's footage watcher: a worker thread that stamps the watched files and hands
/// the stamps over ([`Watcher::take`]) only when one of them changed since it last looked,
/// waking the interface through the callback given to [`Watcher::start`]. The interface tells it
/// what to watch and whether the window is in front; nothing watched means the thread sleeps.
/// Dropping the watcher ends the thread.
pub struct Watcher {
tx: Sender<Watch>,
result: Arc<Mutex<Option<Stamps>>>,
paths: BTreeSet<String>,
focused: Option<bool>,
}
impl Watcher {
/// Start the worker, stamping through `services`; `wake` runs on the worker whenever new
/// stamps are ready to [`Watcher::take`]. `None` where there are no threads (the web app,
/// whose footage lives in browser storage) or one couldn't be started.
pub fn start(services: Arc<dyn Services>, wake: impl Fn() + Send + Sync + 'static) -> Option<Watcher> {
Self::start_with(services, wake, FOCUSED_INTERVAL, UNFOCUSED_INTERVAL)
}
let paths = watched(s);
if paths.is_empty() {
return;
/// [`Watcher::start`] with its own stamping intervals (tests).
pub fn start_with(services: Arc<dyn Services>, wake: impl Fn() + Send + Sync + 'static, focused: Duration, unfocused: Duration) -> Option<Watcher> {
if cfg!(target_arch = "wasm32") {
return None;
}
let (tx, rx) = std::sync::mpsc::channel();
let result: Arc<Mutex<Option<Stamps>>> = Arc::default();
let out = Arc::clone(&result);
std::thread::Builder::new().name("footage-watch".into()).spawn(move || watch(&rx, &*services, &out, &wake, focused, unfocused)).ok()?;
Some(Watcher { tx, result, paths: BTreeSet::new(), focused: None })
}
let services = s.services.clone();
let slot: Scan = Arc::default();
let out = slot.clone();
let spawned = std::thread::Builder::new().name("footage-watch".into()).spawn(move || {
let stamps = paths.into_iter().map(|p| (p.clone(), services.stamp(&p))).collect();
*out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stamps);
});
if spawned.is_ok() {
s.footage_scan = Some(slot);
/// The files to watch ([`watched`]); a changed set is stamped at once.
pub fn set_watched(&mut self, paths: BTreeSet<String>) {
if paths != self.paths {
self.paths = paths.clone();
let _ = self.tx.send(Watch::Paths(paths));
}
}
/// Whether the window is in front; coming back to the front stamps the files at once.
pub fn set_focused(&mut self, focused: bool) {
if self.focused != Some(focused) {
self.focused = Some(focused);
let _ = self.tx.send(Watch::Focused(focused));
}
}
/// Stamps the worker found changed (or the first ones for a new set of files), once; for
/// [`apply_stamps`]. `None` while nothing changed.
pub fn take(&self) -> Option<Stamps> {
self.result.lock().unwrap_or_else(PoisonError::into_inner).take()
}
}
/// Apply a finished scan (`None` while there is none, or it is still running).
pub fn poll_scan(s: &mut Session) -> Option<crate::Result<Value>> {
let stamps = s.footage_scan.as_ref()?.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()?;
s.footage_scan = None;
Some(apply_stamps(s, stamps))
/// The watcher's worker: wait for a message or the interval, stamp, report only changes.
fn watch(
rx: &Receiver<Watch>,
services: &dyn Services,
out: &Mutex<Option<Stamps>>,
wake: &(dyn Fn() + Send + Sync),
focused_every: Duration,
unfocused_every: Duration,
) {
let mut paths = BTreeSet::new();
let mut focused = true;
let mut last: Option<Stamps> = None;
loop {
let msg = if paths.is_empty() {
rx.recv().map_err(|_| RecvTimeoutError::Disconnected)
} else {
rx.recv_timeout(if focused { focused_every } else { unfocused_every })
};
match msg {
Ok(Watch::Paths(p)) => {
paths = p;
// A new set is reported whole, so the session forgets files no longer watched.
last = None;
}
Ok(Watch::Focused(f)) => {
let regained = f && !focused;
focused = f;
if !regained {
continue;
}
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => return,
}
if paths.is_empty() {
continue;
}
let stamps: Stamps = paths.iter().map(|p| (p.clone(), services.stamp(p))).collect();
if last.as_ref() != Some(&stamps) {
last = Some(stamps.clone());
*out.lock().unwrap_or_else(PoisonError::into_inner) = Some(stamps);
wake();
}
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod watcher_tests {
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
/// Files whose stamps the test sets; counts how often they were stamped.
#[derive(Default)]
struct Disk {
stamps: Mutex<HashMap<String, Stamp>>,
looks: AtomicUsize,
}
impl Disk {
fn set(&self, path: &str, stamp: Stamp) {
self.stamps.lock().unwrap_or_else(PoisonError::into_inner).insert(path.into(), stamp);
}
}
impl Services for Disk {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
Err(std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string()))
}
fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> {
Err(std::io::Error::other("read-only"))
}
fn stamp(&self, path: &str) -> Option<Stamp> {
self.looks.fetch_add(1, Ordering::SeqCst);
self.stamps.lock().unwrap_or_else(PoisonError::into_inner).get(path).copied()
}
}
const TICK: Duration = Duration::from_millis(20);
const LONG: Duration = Duration::from_secs(60);
fn wait_for(w: &Watcher, wakes: &AtomicUsize, n: usize) -> Stamps {
let t0 = std::time::Instant::now();
loop {
if wakes.load(Ordering::SeqCst) >= n
&& let Some(s) = w.take()
{
return s;
}
assert!(t0.elapsed() < Duration::from_secs(10), "no report {n} within 10 s");
std::thread::sleep(Duration::from_millis(5));
}
}
fn start(disk: &Arc<Disk>, focused: Duration, unfocused: Duration) -> Option<(Watcher, Arc<AtomicUsize>)> {
let wakes = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&wakes);
let w = Watcher::start_with(
Arc::clone(disk) as Arc<dyn Services>,
move || {
counter.fetch_add(1, Ordering::SeqCst);
},
focused,
unfocused,
)?;
Some((w, wakes))
}
/// The interface is woken for the first stamps of a set and for every change, and for
/// nothing else: an unchanged file polled many times is no wakeup at all.
#[test]
fn the_watcher_wakes_the_interface_only_when_a_stamp_changes() {
let disk = Arc::new(Disk::default());
disk.set("/a.psd", (10, 1));
let (mut w, wakes) = start(&disk, TICK, LONG).unwrap();
w.set_focused(true);
w.set_watched(["/a.psd".to_string()].into());
assert_eq!(wait_for(&w, &wakes, 1), vec![("/a.psd".to_string(), Some((10, 1)))]);
let looked = disk.looks.load(Ordering::SeqCst);
std::thread::sleep(TICK * 15);
assert!(disk.looks.load(Ordering::SeqCst) > looked + 3, "it keeps stamping while the window is in front");
assert_eq!(wakes.load(Ordering::SeqCst), 1, "an unchanged file is no wakeup");
assert!(w.take().is_none());
disk.set("/a.psd", (12, 2));
assert_eq!(wait_for(&w, &wakes, 2), vec![("/a.psd".to_string(), Some((12, 2)))]);
assert_eq!(wakes.load(Ordering::SeqCst), 2);
}
/// Behind another app the files are stamped only at the slow interval; the window coming
/// back to the front stamps them at once.
#[test]
fn behind_another_app_the_watcher_rests_until_the_window_is_back() {
let disk = Arc::new(Disk::default());
disk.set("/b.psd", (1, 1));
let (mut w, wakes) = start(&disk, TICK, LONG).unwrap();
w.set_focused(false);
w.set_watched(["/b.psd".to_string()].into());
wait_for(&w, &wakes, 1);
let looked = disk.looks.load(Ordering::SeqCst);
disk.set("/b.psd", (2, 2));
std::thread::sleep(TICK * 15);
assert_eq!(disk.looks.load(Ordering::SeqCst), looked, "no stamping at the front interval while behind");
assert_eq!(wakes.load(Ordering::SeqCst), 1);
w.set_focused(true);
assert_eq!(wait_for(&w, &wakes, 2), vec![("/b.psd".to_string(), Some((2, 2)))]);
}
/// Nothing to watch: the worker sleeps, stamping nothing; a new set is stamped at once and
/// reported whole, and dropping the watcher ends the worker.
#[test]
fn nothing_watched_is_nothing_stamped_and_a_new_set_is_reported_whole() {
let disk = Arc::new(Disk::default());
disk.set("/c.psd", (1, 1));
disk.set("/d.psd", (2, 2));
let (mut w, wakes) = start(&disk, TICK, TICK).unwrap();
w.set_focused(true);
std::thread::sleep(TICK * 10);
assert_eq!(disk.looks.load(Ordering::SeqCst), 0);
w.set_watched(["/c.psd".to_string(), "/d.psd".to_string()].into());
assert_eq!(wait_for(&w, &wakes, 1), vec![("/c.psd".to_string(), Some((1, 1))), ("/d.psd".to_string(), Some((2, 2)))]);
w.set_watched(["/d.psd".to_string()].into());
assert_eq!(wait_for(&w, &wakes, 2), vec![("/d.psd".to_string(), Some((2, 2)))], "the set is reported whole, so the session forgets /c.psd");
w.set_watched(BTreeSet::new());
std::thread::sleep(TICK * 3);
let looked = disk.looks.load(Ordering::SeqCst);
std::thread::sleep(TICK * 10);
assert_eq!(disk.looks.load(Ordering::SeqCst), looked, "nothing watched, nothing stamped");
let result = Arc::downgrade(&w.result);
drop(w);
let t0 = std::time::Instant::now();
while result.upgrade().is_some() {
assert!(t0.elapsed() < Duration::from_secs(5), "the worker is still running");
std::thread::sleep(Duration::from_millis(5));
}
}
}
-3
View File
@@ -241,8 +241,6 @@ pub struct Session {
/// Each footage file's size and modification time when last seen (Settings ▸ Import ▸
/// Reload Footage Changed on Disk, [`footage_reload`]).
pub footage_stamps: std::collections::HashMap<String, footage_reload::Stamp>,
/// The stamps being taken on a worker thread ([`footage_reload::start_scan`]).
pub footage_scan: Option<footage_reload::Scan>,
/// The disk cache's footage salt and the footage it was taken for ([`Session::refresh_disk_salt`]).
pub disk_salt: Option<(u64, u64)>,
/// How many script-issued commands are running ([`Session::execute_for_script`]): while one
@@ -351,7 +349,6 @@ impl Default for Session {
stored_paths: Default::default(),
footage_stamps: Default::default(),
disk_salt: None,
footage_scan: None,
script_calls: ScriptCalls::default(),
}
}
+2 -1
View File
@@ -153,7 +153,8 @@ page!(Import {
sequence_fps: f64 = 30.0,
report_missing_frames: bool = true,
/// Automatically Reload Footage whose file another app changed (size or modification time),
/// checked every two seconds and when the window comes back to the front: `off`,
/// checked every two seconds while the window is in front, every ten seconds while it is
/// not, and when it comes back to the front: `off`,
/// `nonSequence` (the default, as in After Effects: image sequences are left alone) or `all`.
auto_reload_footage: String = "nonSequence".into(),
/// Interpret unlabeled alpha as: `ask`, `guess`, `ignore`, `straight`, `premultiplied`.
+39 -12
View File
@@ -146,27 +146,54 @@ fn a_file_still_being_written_is_tried_again() {
assert!(near(pixel(&s, cid, 32, 24), BLUE));
}
/// The desktop app's watcher ([`footage_reload::Watcher`]) notices a footage file another app
/// saved, hands its stamps over once, and reloads it through [`footage_reload::apply_stamps`];
/// while nothing changes it hands over nothing (so the interface is never woken for it).
#[test]
fn background_scan_reloads_changed_footage() {
let path = tmp("scan.psd");
fn the_watcher_notices_a_footage_file_changed_on_disk() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
let path = tmp("watched.psd");
std::fs::write(&path, flat(RED, false)).unwrap();
let (mut s, _, cid) = flat_in_comp(&path);
let scan = |s: &mut Session| {
footage_reload::start_scan(s);
let t0 = std::time::Instant::now();
let wakes = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&wakes);
let fast = Duration::from_millis(20);
let mut w = footage_reload::Watcher::start_with(
s.services.clone(),
move || {
counter.fetch_add(1, Ordering::SeqCst);
},
fast,
Duration::from_secs(60),
)
.unwrap();
w.set_focused(true);
w.set_watched(footage_reload::watched(&s));
let next = |w: &footage_reload::Watcher| {
let t0 = Instant::now();
loop {
if let Some(r) = footage_reload::poll_scan(s) {
return r.unwrap();
if let Some(stamps) = w.take() {
return stamps;
}
assert!(t0.elapsed().as_secs() < 10, "scan never finished");
std::thread::yield_now();
assert!(t0.elapsed() < Duration::from_secs(10), "the watcher never reported");
std::thread::sleep(Duration::from_millis(5));
}
};
assert_eq!(scan(&mut s), json!({"files": 0, "reloaded": 0}));
// First look: remembered, nothing to reload.
assert_eq!(footage_reload::apply_stamps(&mut s, next(&w)).unwrap(), json!({"files": 0, "reloaded": 0}));
assert_eq!(wakes.load(Ordering::SeqCst), 1);
std::thread::sleep(fast * 15);
assert!(w.take().is_none(), "an unchanged file is never reported");
assert_eq!(wakes.load(Ordering::SeqCst), 1, "and never wakes the interface");
// Saved in another app: reported once, reloaded, shown.
save(&path, &flat(GREEN, true));
assert_eq!(scan(&mut s), json!({"files": 1, "reloaded": 1}));
assert_eq!(footage_reload::apply_stamps(&mut s, next(&w)).unwrap(), json!({"files": 1, "reloaded": 1}));
assert!(near(pixel(&s, cid, 32, 24), GREEN));
assert!(s.footage_scan.is_none());
assert_eq!(wakes.load(Ordering::SeqCst), 2);
std::thread::sleep(fast * 10);
assert!(w.take().is_none());
}
#[test]
+2
View File
@@ -76,6 +76,8 @@ impl EffectcraftApp {
gpu_checked: false,
gpu_failures: None,
gpu_failure_applied: None,
footage_watcher: None,
footage_watch_key: None,
device_lost_close: None,
dialog_answers: std::sync::mpsc::channel(),
file_dialogs: FileDialogsUp::default(),
+26 -19
View File
@@ -524,33 +524,40 @@ impl EffectcraftApp {
}
ctx.request_repaint_after(std::time::Duration::from_secs(10));
}
self.tick_footage_reload(ctx, now);
self.tick_footage_reload(ctx);
}
/// Settings ▸ Import ▸ Reload Footage Changed on Disk: every two seconds, and as soon as the
/// window comes back to the front (after saving in another app), stamp the footage files on a
/// worker thread and reload the ones that changed.
fn tick_footage_reload(&mut self, ctx: &egui::Context, now: f64) {
/// Settings ▸ Import ▸ Reload Footage Changed on Disk: a worker thread stamps the footage
/// files (every two seconds while the window is in front, and as soon as it comes back to
/// the front after saving in another app) and wakes this frame only when a stamp changed;
/// the changed files are reloaded here. Nothing here asks for a repaint: an idle app with
/// unchanged footage stays asleep.
fn tick_footage_reload(&mut self, ctx: &egui::Context) {
use effectcraft_engine::footage_reload;
if let Some(Err(e)) = footage_reload::poll_scan(&mut self.session) {
if let Some(stamps) = self.footage_watcher.as_ref().and_then(footage_reload::Watcher::take)
&& let Err(e) = footage_reload::apply_stamps(&mut self.session, stamps)
{
self.ui.status = e.to_string();
}
let (last_key, focus_key) = (egui::Id::unique("footageReload.lastScan"), egui::Id::unique("footageReload.focused"));
let focused = self.focused;
let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused);
ctx.data_mut(|d| d.insert_temp(focus_key, focused));
if self.session.prefs.import.auto_reload_footage == "off" || self.session.project.items.is_empty() {
let off = self.session.prefs.import.auto_reload_footage == "off" || self.session.project.items.is_empty();
if off {
if let Some(w) = &mut self.footage_watcher {
w.set_watched(Default::default());
}
self.footage_watch_key = None;
return;
}
let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(0.0);
if now - last >= 2.0 || (focused && !was_focused) {
ctx.data_mut(|d| d.insert_temp(last_key, now));
footage_reload::start_scan(&mut self.session);
if self.footage_watcher.is_none() {
let wake = ctx.clone();
self.footage_watcher = footage_reload::Watcher::start(self.session.services.clone(), move || wake.request_repaint());
}
if self.session.footage_scan.is_some() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
} else {
ctx.request_repaint_after(std::time::Duration::from_secs(2));
let Some(watcher) = &mut self.footage_watcher else { return };
watcher.set_focused(self.focused);
// Which files are watched follows the project and the preference, not the frame.
let key = (self.session.revision, self.session.prefs.import.auto_reload_footage.clone());
if self.footage_watch_key.as_ref() != Some(&key) {
self.footage_watch_key = Some(key);
watcher.set_watched(footage_reload::watched(&self.session));
}
}
}
+5
View File
@@ -312,6 +312,11 @@ pub struct EffectcraftApp {
gpu_checked: bool,
gpu_failures: Option<gpu_failure::GpuFailureBridge>,
gpu_failure_applied: Option<bool>,
/// Settings ▸ Import ▸ Reload Footage Changed on Disk: the worker that stamps the footage
/// files, started with the first project that has items ([`Self::tick_footage_reload`]).
footage_watcher: Option<effectcraft_engine::footage_reload::Watcher>,
/// The project revision and preference the watched set was last computed for.
footage_watch_key: Option<(u64, String)>,
/// The graphics device was lost and the window is closing (see `close_after_device_loss`);
/// `true` keeps crash recovery for the next launch.
device_lost_close: Option<bool>,
+5 -3
View File
@@ -120,9 +120,11 @@ Commands (CLI, MCP, control channel):
- `import.reportMissingFrames`: gaps in an image sequence's numbering are reported on import
(they show as colour-bar placeholders, as in After Effects; see [footage.md](footage.md))
- `import.autoReloadFootage`: Automatically Reload Footage, `off`, `nonSequence` (the default, as
in After Effects) or `all` (image sequences too). Every two seconds,
and as soon as the window comes back to the front, the desktop app compares each footage file's
size and modification time with what they were, and reloads what another app saved: a
in After Effects) or `all` (image sequences too). Every two seconds while its window is in
front (every ten seconds while another app has the front), and as soon as the window comes back
to the front, the desktop app compares each footage file's size and modification time with what
they were, on a worker thread that only wakes the interface when something changed, and reloads
what another app saved: a
Photoshop file edited in PhotoCraft or Photoshop shows its new pixels without File ▸ Reload
Footage. It is not an undo step and doesn't modify the project; a layer item stays on its layer
(found again by name), and a file that can't be read yet (still being written) is tried again once it changes again.