Watching fixes (#2706)

This commit is contained in:
Laurenz 2023-11-19 12:31:42 +01:00 committed by GitHub
parent 43f90b2159
commit e0d6526a53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 119 additions and 142 deletions

1
Cargo.lock generated
View File

@ -2936,7 +2936,6 @@ dependencies = [
"dirs", "dirs",
"ecow", "ecow",
"env_proxy", "env_proxy",
"filetime",
"flate2", "flate2",
"fontdb", "fontdb",
"inferno", "inferno",

View File

@ -42,7 +42,6 @@ csv = "1"
dirs = "5" dirs = "5"
ecow = { version = "0.2", features = ["serde"] } ecow = { version = "0.2", features = ["serde"] }
env_proxy = "0.4" env_proxy = "0.4"
filetime = "0.2"
flate2 = "1" flate2 = "1"
fontdb = { version = "0.15", default-features = false } fontdb = { version = "0.15", default-features = false }
hayagriva = "0.4" hayagriva = "0.4"

View File

@ -32,7 +32,6 @@ comemo = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
ecow = { workspace = true } ecow = { workspace = true }
env_proxy = { workspace = true } env_proxy = { workspace = true }
filetime = { workspace = true }
flate2 = { workspace = true } flate2 = { workspace = true }
fontdb = { workspace = true, features = ["memmap", "fontconfig"] } fontdb = { workspace = true, features = ["memmap", "fontconfig"] }
inferno = { workspace = true } inferno = { workspace = true }

View File

@ -1,4 +1,4 @@
use std::collections::HashSet; use std::collections::HashMap;
use std::io::{self, IsTerminal, Write}; use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -28,13 +28,13 @@ pub fn watch(mut command: CompileCommand) -> StrResult<()> {
.map_err(|err| eco_format!("failed to setup file watching ({err})"))?; .map_err(|err| eco_format!("failed to setup file watching ({err})"))?;
// Watch all the files that are used by the input file and its dependencies. // Watch all the files that are used by the input file and its dependencies.
watch_dependencies(&mut world, &mut watcher, HashSet::new())?; let mut watched = HashMap::new();
watch_dependencies(&mut world, &mut watcher, &mut watched)?;
// Handle events. // Handle events.
let timeout = std::time::Duration::from_millis(100); let timeout = std::time::Duration::from_millis(100);
let output = command.output(); let output = command.output();
loop { loop {
let mut removed = HashSet::new();
let mut recompile = false; let mut recompile = false;
for event in rx for event in rx
.recv() .recv()
@ -46,16 +46,16 @@ pub fn watch(mut command: CompileCommand) -> StrResult<()> {
// Workaround for notify-rs' implicit unwatch on remove/rename // Workaround for notify-rs' implicit unwatch on remove/rename
// (triggered by some editors when saving files) with the inotify // (triggered by some editors when saving files) with the inotify
// backend. By keeping track of the removed files, we can allow // backend. By keeping track of the potentially unwatched files, we
// those we still depend on to be watched again later on. // can allow those we still depend on to be watched again later on.
if matches!( if matches!(
event.kind, event.kind,
notify::EventKind::Remove(notify::event::RemoveKind::File) notify::EventKind::Remove(notify::event::RemoveKind::File)
) { ) {
// Mark the file as unwatched and remove the watch in case it
// still exists.
let path = &event.paths[0]; let path = &event.paths[0];
removed.insert(path.clone()); watched.remove(path);
// Remove the watch in case it still exists.
watcher.unwatch(path).ok(); watcher.unwatch(path).ok();
} }
@ -63,13 +63,6 @@ pub fn watch(mut command: CompileCommand) -> StrResult<()> {
} }
if recompile { if recompile {
// Retrieve the dependencies of the last compilation.
let previous: HashSet<PathBuf> = world
.dependencies()
.filter(|path| !removed.contains(*path))
.map(ToOwned::to_owned)
.collect();
// Reset all dependencies. // Reset all dependencies.
world.reset(); world.reset();
@ -77,36 +70,49 @@ pub fn watch(mut command: CompileCommand) -> StrResult<()> {
compile_once(&mut world, &mut command, true)?; compile_once(&mut world, &mut command, true)?;
comemo::evict(10); comemo::evict(10);
// Adjust the watching. // Adjust the file watching.
watch_dependencies(&mut world, &mut watcher, previous)?; watch_dependencies(&mut world, &mut watcher, &mut watched)?;
} }
} }
} }
/// Adjust the file watching. Watches all new dependencies and unwatches /// Adjust the file watching. Watches all new dependencies and unwatches
/// all `previous` dependencies that are not relevant anymore. /// all previously `watched` files that are no relevant anymore.
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn watch_dependencies( fn watch_dependencies(
world: &mut SystemWorld, world: &mut SystemWorld,
watcher: &mut dyn Watcher, watcher: &mut dyn Watcher,
mut previous: HashSet<PathBuf>, watched: &mut HashMap<PathBuf, bool>,
) -> StrResult<()> { ) -> StrResult<()> {
// Watch new paths that weren't watched yet. // Mark all files as not "seen" so that we may unwatch them if they aren't
for path in world.dependencies() { // in the dependency list.
let watched = previous.remove(path); for seen in watched.values_mut() {
if path.exists() && !watched { *seen = false;
}
// Retrieve the dependencies of the last compilation and watch new paths
// that weren't watched yet. We can't watch paths that don't exist yet
// unfortunately, so we filter those out.
for path in world.dependencies().filter(|path| path.exists()) {
if !watched.contains_key(&path) {
tracing::info!("Watching {}", path.display()); tracing::info!("Watching {}", path.display());
watcher watcher
.watch(path, RecursiveMode::NonRecursive) .watch(&path, RecursiveMode::NonRecursive)
.map_err(|err| eco_format!("failed to watch {path:?} ({err})"))?; .map_err(|err| eco_format!("failed to watch {path:?} ({err})"))?;
} }
// Mark the file as "seen" so that we don't unwatch it.
watched.insert(path, true);
} }
// Unwatch old paths that don't need to be watched anymore. // Unwatch old paths that don't need to be watched anymore.
for path in previous { watched.retain(|path, &mut seen| {
if !seen {
tracing::info!("Unwatching {}", path.display()); tracing::info!("Unwatching {}", path.display());
watcher.unwatch(&path).ok(); watcher.unwatch(path).ok();
} }
seen
});
Ok(()) Ok(())
} }

View File

@ -1,14 +1,10 @@
use std::cell::{Cell, OnceCell, RefCell, RefMut}; use std::cell::{Cell, OnceCell, RefCell, RefMut};
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::hash::Hash;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use chrono::{DateTime, Datelike, Local}; use chrono::{DateTime, Datelike, Local};
use comemo::Prehashed; use comemo::Prehashed;
use filetime::FileTime;
use same_file::Handle;
use siphasher::sip128::{Hasher128, SipHasher13};
use typst::diag::{FileError, FileResult, StrResult}; use typst::diag::{FileError, FileResult, StrResult};
use typst::doc::Frame; use typst::doc::Frame;
use typst::eval::{eco_format, Bytes, Datetime, Library}; use typst::eval::{eco_format, Bytes, Datetime, Library};
@ -37,12 +33,8 @@ pub struct SystemWorld {
book: Prehashed<FontBook>, book: Prehashed<FontBook>,
/// Locations of and storage for lazily loaded fonts. /// Locations of and storage for lazily loaded fonts.
fonts: Vec<FontSlot>, fonts: Vec<FontSlot>,
/// Maps package-path combinations to canonical hashes. All package-path /// Maps file ids to source files and buffers.
/// combinations that point to the same file are mapped to the same hash. To slots: RefCell<HashMap<FileId, FileSlot>>,
/// be used in conjunction with `paths`.
hashes: RefCell<HashMap<FileId, FileResult<PathHash>>>,
/// Maps canonical path hashes to source files and buffers.
slots: RefCell<HashMap<PathHash, PathSlot>>,
/// The current datetime if requested. This is stored here to ensure it is /// The current datetime if requested. This is stored here to ensure it is
/// always the same within one compilation. Reset between compilations. /// always the same within one compilation. Reset between compilations.
now: OnceCell<DateTime<Local>>, now: OnceCell<DateTime<Local>>,
@ -86,7 +78,6 @@ impl SystemWorld {
library: Prehashed::new(typst_library::build()), library: Prehashed::new(typst_library::build()),
book: Prehashed::new(searcher.book), book: Prehashed::new(searcher.book),
fonts: searcher.fonts, fonts: searcher.fonts,
hashes: RefCell::default(),
slots: RefCell::default(), slots: RefCell::default(),
now: OnceCell::new(), now: OnceCell::new(),
export_cache: ExportCache::new(), export_cache: ExportCache::new(),
@ -109,17 +100,16 @@ impl SystemWorld {
} }
/// Return all paths the last compilation depended on. /// Return all paths the last compilation depended on.
pub fn dependencies(&mut self) -> impl Iterator<Item = &Path> { pub fn dependencies(&mut self) -> impl Iterator<Item = PathBuf> + '_ {
self.slots self.slots
.get_mut() .get_mut()
.values() .values()
.filter(|slot| slot.accessed()) .filter(|slot| slot.accessed())
.map(|slot| slot.path.as_path()) .filter_map(|slot| slot.system_path(&self.root).ok())
} }
/// Reset the compilation state in preparation of a new compilation. /// Reset the compilation state in preparation of a new compilation.
pub fn reset(&mut self) { pub fn reset(&mut self) {
self.hashes.borrow_mut().clear();
for slot in self.slots.borrow_mut().values_mut() { for slot in self.slots.borrow_mut().values_mut() {
slot.reset(); slot.reset();
} }
@ -157,11 +147,11 @@ impl World for SystemWorld {
} }
fn source(&self, id: FileId) -> FileResult<Source> { fn source(&self, id: FileId) -> FileResult<Source> {
self.slot(id)?.source() self.slot(id)?.source(&self.root)
} }
fn file(&self, id: FileId) -> FileResult<Bytes> { fn file(&self, id: FileId) -> FileResult<Bytes> {
self.slot(id)?.file() self.slot(id)?.file(&self.root)
} }
fn font(&self, index: usize) -> Option<Font> { fn font(&self, index: usize) -> Option<Font> {
@ -187,59 +177,29 @@ impl World for SystemWorld {
impl SystemWorld { impl SystemWorld {
/// Access the canonical slot for the given file id. /// Access the canonical slot for the given file id.
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn slot(&self, id: FileId) -> FileResult<RefMut<PathSlot>> { fn slot(&self, id: FileId) -> FileResult<RefMut<FileSlot>> {
let mut system_path = PathBuf::new(); Ok(RefMut::map(self.slots.borrow_mut(), |slots| {
let hash = self slots.entry(id).or_insert_with(|| FileSlot::new(id))
.hashes
.borrow_mut()
.entry(id)
.or_insert_with(|| {
// Determine the root path relative to which the file path
// will be resolved.
let buf;
let mut root = &self.root;
if let Some(spec) = id.package() {
buf = prepare_package(spec)?;
root = &buf;
}
// Join the path to the root. If it tries to escape, deny
// access. Note: It can still escape via symlinks.
system_path = id.vpath().resolve(root).ok_or(FileError::AccessDenied)?;
PathHash::new(&system_path)
})
.clone()?;
Ok(RefMut::map(self.slots.borrow_mut(), |paths| {
paths.entry(hash).or_insert_with(|| PathSlot::new(id, system_path))
})) }))
} }
} }
/// Holds canonical data for all paths pointing to the same entity. /// Holds the processed data for a file ID.
/// ///
/// Both fields can be populated if the file is both imported and read(). /// Both fields can be populated if the file is both imported and read().
struct PathSlot { struct FileSlot {
/// The slot's canonical file id. /// The slot's file id.
id: FileId, id: FileId,
/// The slot's path on the system.
path: PathBuf,
/// The lazily loaded and incrementally updated source file. /// The lazily loaded and incrementally updated source file.
source: SlotCell<Source>, source: SlotCell<Source>,
/// The lazily loaded raw byte buffer. /// The lazily loaded raw byte buffer.
file: SlotCell<Bytes>, file: SlotCell<Bytes>,
} }
impl PathSlot { impl FileSlot {
/// Create a new path slot. /// Create a new path slot.
fn new(id: FileId, path: PathBuf) -> Self { fn new(id: FileId) -> Self {
Self { Self { id, file: SlotCell::new(), source: SlotCell::new() }
id,
path,
file: SlotCell::new(),
source: SlotCell::new(),
}
} }
/// Whether the file was accessed in the ongoing compilation. /// Whether the file was accessed in the ongoing compilation.
@ -255,8 +215,10 @@ impl PathSlot {
} }
/// Retrieve the source for this file. /// Retrieve the source for this file.
fn source(&self) -> FileResult<Source> { fn source(&self, root: &Path) -> FileResult<Source> {
self.source.get_or_init(&self.path, |data, prev| { self.source.get_or_init(
|| self.system_path(root),
|data, prev| {
let text = decode_utf8(&data)?; let text = decode_utf8(&data)?;
if let Some(mut prev) = prev { if let Some(mut prev) = prev {
prev.replace(text); prev.replace(text);
@ -264,19 +226,40 @@ impl PathSlot {
} else { } else {
Ok(Source::new(self.id, text.into())) Ok(Source::new(self.id, text.into()))
} }
}) },
)
} }
/// Retrieve the file's bytes. /// Retrieve the file's bytes.
fn file(&self) -> FileResult<Bytes> { fn file(&self, root: &Path) -> FileResult<Bytes> {
self.file.get_or_init(&self.path, |data, _| Ok(data.into())) self.file
.get_or_init(|| self.system_path(root), |data, _| Ok(data.into()))
}
/// The path of the slot on the system.
fn system_path(&self, root: &Path) -> FileResult<PathBuf> {
// Determine the root path relative to which the file path
// will be resolved.
let buf;
let mut root = root;
if let Some(spec) = self.id.package() {
buf = prepare_package(spec)?;
root = &buf;
}
// Join the path to the root. If it tries to escape, deny
// access. Note: It can still escape via symlinks.
self.id.vpath().resolve(root).ok_or(FileError::AccessDenied)
} }
} }
/// Lazily processes data for a file. /// Lazily processes data for a file.
struct SlotCell<T> { struct SlotCell<T> {
/// The processed data.
data: RefCell<Option<FileResult<T>>>, data: RefCell<Option<FileResult<T>>>,
refreshed: Cell<FileTime>, /// A hash of the raw file contents / access error.
fingerprint: Cell<u128>,
/// Whether the slot has been accessed in the current compilation.
accessed: Cell<bool>, accessed: Cell<bool>,
} }
@ -285,7 +268,7 @@ impl<T: Clone> SlotCell<T> {
fn new() -> Self { fn new() -> Self {
Self { Self {
data: RefCell::new(None), data: RefCell::new(None),
refreshed: Cell::new(FileTime::zero()), fingerprint: Cell::new(0),
accessed: Cell::new(false), accessed: Cell::new(false),
} }
} }
@ -304,45 +287,35 @@ impl<T: Clone> SlotCell<T> {
/// Gets the contents of the cell or initialize them. /// Gets the contents of the cell or initialize them.
fn get_or_init( fn get_or_init(
&self, &self,
path: &Path, path: impl FnOnce() -> FileResult<PathBuf>,
f: impl FnOnce(Vec<u8>, Option<T>) -> FileResult<T>, f: impl FnOnce(Vec<u8>, Option<T>) -> FileResult<T>,
) -> FileResult<T> { ) -> FileResult<T> {
let mut borrow = self.data.borrow_mut(); let mut borrow = self.data.borrow_mut();
// If we accessed the file already in this compilation, retrieve it.
if self.accessed.replace(true) {
if let Some(data) = &*borrow {
return data.clone();
}
}
// Read and hash the file.
let result = path().and_then(|p| read(&p));
let fingerprint = typst::util::hash128(&result);
// If the file contents didn't change, yield the old processed data.
if self.fingerprint.replace(fingerprint) == fingerprint {
if let Some(data) = &*borrow { if let Some(data) = &*borrow {
if self.accessed.replace(true) || self.current(path) {
return data.clone(); return data.clone();
} }
} }
self.accessed.set(true);
self.refreshed.set(FileTime::now());
let prev = borrow.take().and_then(Result::ok); let prev = borrow.take().and_then(Result::ok);
let value = read(path).and_then(|data| f(data, prev)); let value = result.and_then(|data| f(data, prev));
*borrow = Some(value.clone()); *borrow = Some(value.clone());
value value
} }
/// Whether the cell contents are still up to date with the file system.
fn current(&self, path: &Path) -> bool {
fs::metadata(path).map_or(false, |meta| {
let modified = FileTime::from_last_modification_time(&meta);
modified < self.refreshed.get()
})
}
}
/// A hash that is the same for all paths pointing to the same entity.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
struct PathHash(u128);
impl PathHash {
fn new(path: &Path) -> FileResult<Self> {
let f = |e| FileError::from_io(e, path);
let handle = Handle::from_path(path).map_err(f)?;
let mut state = SipHasher13::new();
handle.hash(&mut state);
Ok(Self(state.finish128().as_u128()))
}
} }
/// Caches exported files so that we can avoid re-exporting them if they haven't /// Caches exported files so that we can avoid re-exporting them if they haven't

View File

@ -218,12 +218,11 @@ struct TestWorld {
library: Prehashed<Library>, library: Prehashed<Library>,
book: Prehashed<FontBook>, book: Prehashed<FontBook>,
fonts: Vec<Font>, fonts: Vec<Font>,
paths: RefCell<HashMap<PathBuf, PathSlot>>, paths: RefCell<HashMap<FileId, PathSlot>>,
} }
#[derive(Clone)] #[derive(Clone)]
struct PathSlot { struct PathSlot {
system_path: PathBuf,
source: OnceCell<FileResult<Source>>, source: OnceCell<FileResult<Source>>,
buffer: OnceCell<FileResult<Bytes>>, buffer: OnceCell<FileResult<Bytes>>,
} }
@ -270,7 +269,7 @@ impl World for TestWorld {
let slot = self.slot(id)?; let slot = self.slot(id)?;
slot.source slot.source
.get_or_init(|| { .get_or_init(|| {
let buf = read(&slot.system_path)?; let buf = read(&system_path(id)?)?;
let text = String::from_utf8(buf)?; let text = String::from_utf8(buf)?;
Ok(Source::new(id, text)) Ok(Source::new(id, text))
}) })
@ -280,7 +279,7 @@ impl World for TestWorld {
fn file(&self, id: FileId) -> FileResult<Bytes> { fn file(&self, id: FileId) -> FileResult<Bytes> {
let slot = self.slot(id)?; let slot = self.slot(id)?;
slot.buffer slot.buffer
.get_or_init(|| read(&slot.system_path).map(Bytes::from)) .get_or_init(|| read(&system_path(id)?).map(Bytes::from))
.clone() .clone()
} }
@ -303,16 +302,8 @@ impl TestWorld {
} }
fn slot(&self, id: FileId) -> FileResult<RefMut<PathSlot>> { fn slot(&self, id: FileId) -> FileResult<RefMut<PathSlot>> {
let root: PathBuf = match id.package() {
Some(spec) => format!("packages/{}-{}", spec.name, spec.version).into(),
None => PathBuf::new(),
};
let system_path = id.vpath().resolve(&root).ok_or(FileError::AccessDenied)?;
Ok(RefMut::map(self.paths.borrow_mut(), |paths| { Ok(RefMut::map(self.paths.borrow_mut(), |paths| {
paths.entry(system_path.clone()).or_insert_with(|| PathSlot { paths.entry(id).or_insert_with(|| PathSlot {
system_path,
source: OnceCell::new(), source: OnceCell::new(),
buffer: OnceCell::new(), buffer: OnceCell::new(),
}) })
@ -320,7 +311,17 @@ impl TestWorld {
} }
} }
/// Read as file. /// The file system path for a file ID.
fn system_path(id: FileId) -> FileResult<PathBuf> {
let root: PathBuf = match id.package() {
Some(spec) => format!("packages/{}-{}", spec.name, spec.version).into(),
None => PathBuf::new(),
};
id.vpath().resolve(&root).ok_or(FileError::AccessDenied)
}
/// Read a file.
fn read(path: &Path) -> FileResult<Vec<u8>> { fn read(path: &Path) -> FileResult<Vec<u8>> {
// Basically symlinks `assets/files` to `tests/files` so that the assets // Basically symlinks `assets/files` to `tests/files` so that the assets
// are within the test project root. // are within the test project root.