Files
vectorcraft/crates/engine/src/link_watch.rs
T
dotCooCoo d323716068 Links: a link check whose worker ends without a result no longer stops later checks
When the link-watch worker unwound before storing its stamps,
poll_link_scan returned None on every frame and left link_scan set. The
app then asked for a repaint every 100 ms and start_link_scan returned
early, so the app did not look for changed linked files in the
background again for the rest of the session.

poll_link_scan now clears a look that has ended without stamps. The new
ended_without_stamps helper holds the slot's lock across its read of the
stamps and its read of the ended flag, so a look whose worker stores its
stamps during the check is applied. The app goes back to its two-second
wake, and the next look starts on schedule. A look still running and a
look that stored its stamps behave as before.
2026-11-07 07:36:26 +07:00

206 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Linked files that change while their document is open (another app saved them: PhotoCraft,
//! LightCraft, Photoshop, a placed VectorCraft document): Preferences › File Handling › Update
//! Links decides, as it does when a document opens. Automatically reads them again
//! (`links.update`, an undo step), Ask When Modified offers to, Manually leaves them modified in
//! the Links panel.
//!
//! The active document's linked files are compared by size and modification time with what they
//! were when last seen. The desktop app takes the 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 ([`Session::start_link_scan`], [`Session::poll_link_scan`]);
//! `links.updateChanged` takes them on the spot and reads the changed files again (CLI, agents).
//!
//! Stamps are kept per document: one in the background is looked at when it comes to the front.
//! A file seen for the first time is only remembered (one modified while its document was closed
//! is handled when the document opens); one that can't be read yet (another app is still writing
//! it) keeps its old stamp, so the next look tries again. A change is acted on once: undoing an
//! update, or answering No, leaves the link modified until the file changes again.
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex, PoisonError};
use serde_json::{Value, json};
use crate::{Result, Session};
/// A file's size and modification time (nanoseconds since 1970).
pub type Stamp = (u64, u128);
/// Stamps of a document's linked files taken on a worker thread: (document uid, [(file,
/// object ids, stamp)]), the condition the worker signals when it ends, and whether it has.
pub type LinkScan = Arc<(Mutex<Option<(u64, Vec<(String, Vec<u64>, Option<Stamp>)>)>>, Condvar, AtomicBool)>;
/// Held by a look's worker: marks the look as ended and wakes [`Session::wait_link_scan`] when
/// dropped, whether the worker returned or unwound.
pub(crate) struct Ended(pub(crate) LinkScan);
impl Drop for Ended {
fn drop(&mut self) {
let (done, finished, ended) = &*self.0;
let _held = done.lock().unwrap_or_else(PoisonError::into_inner);
ended.store(true, Ordering::SeqCst);
finished.notify_all();
}
}
/// Whether a look's worker ended without storing stamps, with `read_ended` reading the flag. The
/// worker stores its stamps and marks the look as ended under the slot's lock, and this holds that
/// lock across both reads, so a look whose worker is storing its stamps never counts as ended without them.
pub(crate) fn ended_without_stamps(scan: &LinkScan, read_ended: impl FnOnce(&AtomicBool) -> bool) -> bool {
let stamps = scan.0.lock().unwrap_or_else(PoisonError::into_inner);
stamps.is_none() && read_ended(&scan.2)
}
/// The stamp of the linked file stored as `path` in the document saved at `doc_path`; `None` when
/// there is no file, its path isn't followed ([`crate::cmd::fileio::document_path`]) or
/// automation may not read it.
#[cfg(not(target_arch = "wasm32"))]
fn stamp(path: &str, doc_path: Option<&str>) -> Option<Stamp> {
let path = crate::cmd::fileio::document_path(path, doc_path).ok()?;
let meta = crate::cmd::fileio::file_metadata(&path)?;
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
Some((meta.len(), modified.as_nanos()))
}
#[cfg(target_arch = "wasm32")]
fn stamp(_: &str, _: Option<&str>) -> Option<Stamp> {
None
}
/// The linked files of `d` with the ids of the objects showing them.
pub fn linked_files(d: &vectorcraft_doc::Document) -> Vec<(String, Vec<u64>)> {
crate::cmd::links::linked_groups(d)
}
/// `links.updateChanged`: stamp the active document's linked files now and read the changed
/// ones again, whatever Update Links says.
pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p, st.path.as_deref()))).collect();
s.apply_link_stamps(uid, now, true)
}
impl Session {
/// Start stamping the active document's linked files on a worker thread, unless a look is
/// still running (or the build has no files to look at: the web app).
pub fn start_link_scan(&mut self) {
if self.link_scan.is_some() || cfg!(target_arch = "wasm32") {
return;
}
let Some(st) = self.active() else { return };
let (uid, files, doc_path) = (st.uid, crate::cmd::links::linked_groups(&st.doc), st.path.clone());
// A confined caller's roots, else what the caller that opened the document may read (the
// desktop frame is unconfined).
let opened_by = st.opened_by.clone();
if files.is_empty() {
return;
}
let slot: LinkScan = Arc::default();
let out = slot.clone();
let scope = crate::file_access::ScopeHandle::capture();
let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || {
// Marks the look as ended when the worker returns or unwinds.
let _ended = Ended(out.clone());
let stamps = scope.run(|| {
crate::file_access::confine(crate::file_access::current().or(opened_by).as_ref(), || {
files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p, doc_path.as_deref()))).collect()
})
});
*out.0.lock().unwrap_or_else(PoisonError::into_inner) = Some((uid, stamps));
});
if spawned.is_ok() {
self.link_scan = Some(slot);
}
}
/// Apply a finished look (`None` while there is none, or it is still running): see
/// [`Session::apply_link_stamps`]. A look whose worker ended without stamps is cleared, also
/// with `None`, so the next one can start.
pub fn poll_link_scan(&mut self) -> Option<Result<Value>> {
self.link_scan.take_if(|scan| ended_without_stamps(scan, |ended| ended.load(Ordering::SeqCst)));
let (uid, stamps) = self.link_scan.as_ref()?.0.lock().unwrap_or_else(PoisonError::into_inner).take()?;
self.link_scan = None;
Some(self.apply_link_stamps(uid, stamps, false))
}
/// Block until the look in flight has ended (at once when none is running), so that the next
/// [`Session::poll_link_scan`] applies its stamps. A look whose worker stopped without stamps
/// ends too, and the poll then clears it and returns `None`. For tests that drive the look step
/// by step.
pub fn wait_link_scan(&self) {
let Some(scan) = &self.link_scan else { return };
let (done, finished, ended) = &**scan;
let guard = done.lock().unwrap_or_else(PoisonError::into_inner);
drop(finished.wait_while(guard, |stamps| stamps.is_none() && !ended.load(Ordering::SeqCst)).unwrap_or_else(PoisonError::into_inner));
}
/// Compare the stamps of document `uid`'s linked files with the ones last seen and act on
/// the changed ones as Update Links says (`update`: read them again regardless). Returns
/// `{updated: [ids], ask: [ids], modified: [ids]}`: read again, to offer (Ask When Modified),
/// or left modified (Manually).
pub fn apply_link_stamps(&mut self, uid: u64, now: Vec<(String, Vec<u64>, Option<Stamp>)>, update: bool) -> Result<Value> {
let none = || json!({"updated": [], "ask": [], "modified": []});
// The document went to the back meanwhile: it's looked at when it's in front again.
if self.active().map(|d| d.uid) != Some(uid) {
return Ok(none());
}
// A drag is in progress: its next preview or cancel would throw an update away while the new
// stamp stayed recorded. Leave the stamps alone; the first look after the drag sees the change.
if self.in_interaction() {
return Ok(none());
}
// This document's last stamps; those of closed documents are dropped.
let open: HashSet<u64> = self.documents().iter().map(|d| d.uid).collect();
let mut before: HashMap<String, Stamp> = HashMap::new();
self.link_stamps.retain(|(u, p), s| {
if *u == uid {
before.insert(p.clone(), *s);
false
} else {
open.contains(u)
}
});
let mut changed: Vec<(String, Vec<u64>)> = Vec::new();
for (path, ids, stamp) in now {
match (before.get(&path), stamp) {
// Not there right now (an app saving by delete and rename): keep the last stamp.
(Some(old), None) => {
self.link_stamps.insert((uid, path), *old);
}
(old, Some(new)) => {
if old.is_some_and(|o| *o != new) {
changed.push((path.clone(), ids));
}
self.link_stamps.insert((uid, path), new);
}
(None, None) => {}
}
}
if changed.is_empty() {
return Ok(none());
}
let ids: Vec<u64> = changed.iter().flat_map(|(_, ids)| ids.iter().copied()).collect();
let mode = if update { "automatically" } else { self.prefs.update_links.as_str() };
match mode {
"automatically" => {
let roots = crate::file_access::current().or_else(|| self.active().and_then(|d| d.opened_by.clone()));
let r = crate::file_access::confine(roots.as_ref(), || self.execute("links.update", &json!({"ids": ids})))?;
// Not readable yet (still being written): the next look tries again.
let unread: BTreeSet<u64> = r["missing"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
for (path, ids) in &changed {
if ids.iter().any(|i| unread.contains(i))
&& let Some(old) = before.get(path)
{
self.link_stamps.insert((uid, path.clone()), *old);
}
}
Ok(json!({"updated": r["updated"], "ask": [], "modified": []}))
}
"askWhenModified" => Ok(json!({"updated": [], "ask": ids, "modified": []})),
_ => Ok(json!({"updated": [], "ask": [], "modified": ids})),
}
}
}