From c629753af1c80ecca19a189bbb37eeaa2e009154 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 17:14:27 -0800 Subject: [PATCH] Plug-ins: settings and the cache total stay in memory between storage calls A settings get no longer reads and parses settings.json each time. The parsed settings are kept per plug-in folder and read again only when the file's size or modified time changes. A set to the value a key already has writes nothing, and every other change is written with an atomic write before the call returns. plugin.remove drops the kept copy. cacheBytes in plugin.list, plugin.info and plugin.install comes from a total kept while the cache folder keeps its modified time, instead of a listing of up to 8,192 items per call. A cache write still lists the folder to enforce the caps. docs/plugins.md states what a run counts and that a cache call moves a whole entry in one step. docs/plugins.md and docs/mcp.md now say when a later plugin.remove retries a leftover .removed folder. --- crates/engine/src/store.rs | 161 +++++++++++++++++++++++------ crates/engine/src/tests/plugins.rs | 138 +++++++++++++++++++++++-- crates/plugins/src/host.rs | 2 +- crates/plugins/src/storage.rs | 5 +- docs/mcp.md | 4 +- docs/plugins.md | 18 +++- docs/target-app-parity.md | 1 + 7 files changed, 280 insertions(+), 49 deletions(-) diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 27b9ad8ac..1836c7bd6 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -32,7 +32,8 @@ use std::collections::BTreeMap; use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::SystemTime; use vectorcraft_pdf::{PdfPreset, builtin_preset}; use vectorcraft_plugins::safe_mode; @@ -79,9 +80,9 @@ pub(crate) const MAX_CACHE_SCAN: usize = storage::MAX_CACHE_ENTRIES * 2; /// Held while a plug-in's settings or cache change, so two runs can't pass a cap together or lose /// each other's settings. static PLUGIN_DATA_LOCK: Mutex<()> = Mutex::new(()); -/// The closed flag of each plug-in folder's storage, shared by every storage made for the folder -/// until the plug-in is removed, which sets it and drops it. -static PLUGIN_DATA_OPEN: Mutex>> = Mutex::new(BTreeMap::new()); +/// The state of each plug-in folder's storage, shared by every storage made for the folder until +/// the plug-in is removed, which closes it and drops it. +static PLUGIN_DATA_OPEN: Mutex>> = Mutex::new(BTreeMap::new()); /// The file name (without extension) a stored item called `name` gets: its name made safe for any /// file system, then a hash of the name, so that names that differ only in characters a file name @@ -474,7 +475,7 @@ impl Session { /// Delete the settings and cache of plug-in `id` from the store, and stop keeping any for it. /// The folder is first renamed out of the way (`.removed`), and that fails, leaving it /// as it was, when the rename does; what the deletion then leaves stays under that name, with a - /// log record, and is deleted at the plug-in's next removal. + /// log record. A later call for the id tries to delete it again. pub(crate) fn remove_plugin_storage(&self, id: &str) -> Result<(), StorageError> { vectorcraft_plugins::registry::set_storage(id, None); let Some(dir) = &self.plugin_dir else { return Ok(()) }; @@ -483,8 +484,8 @@ impl Session { // Every storage handed out for the folder, including those runs still hold, is closed once // the folder is out of the way; until then they keep working. let close = || { - if let Some(closed) = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).remove(&path) { - closed.store(true, Ordering::Release); + if let Some(data) = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).remove(&path) { + data.closed.store(true, Ordering::Release); } }; // A `storage` folder that is a link leads out of the store: nothing there is deleted. A link @@ -543,22 +544,67 @@ pub(crate) fn plugin_data_dir(dir: &Path, id: &str) -> PathBuf { fn plugin_storage(dir: Option<&Path>, id: &str) -> Option> { let dir = dir?; let data = plugin_data_dir(dir, id); - let closed = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).entry(data.clone()).or_default().clone(); - Some(Arc::new(PluginStorage { root: dir.to_path_buf(), dir: data, closed })) + let state = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).entry(data.clone()).or_default().clone(); + Some(Arc::new(PluginStorage { root: dir.to_path_buf(), dir: data, state })) +} + +/// A file's size and modified time (`None`: there is no file). +type FileStamp = Option<(u64, SystemTime)>; + +/// The stamp of the file at `path`. +fn file_stamp(path: &Path) -> Result { + match std::fs::metadata(path) { + Ok(m) => Ok(Some((m.len(), m.modified().map_err(|e| io(&e))?))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(io(&e)), + } +} + +/// The modified time of the folder at `dir` (`None`: it can't be read, or there is no folder). +fn folder_stamp(dir: &Path) -> Option { + std::fs::symlink_metadata(dir).ok()?.modified().ok() +} + +/// What every storage made for one plug-in folder shares ([`PLUGIN_DATA_OPEN`]). +#[derive(Default)] +struct PluginData { + /// Set, under [`PLUGIN_DATA_LOCK`], once the plug-in is removed. + closed: AtomicBool, + kept: Mutex, +} + +/// What the folder's files last held in this process. +#[derive(Default)] +struct Kept { + settings: Option, + cache: Option, +} + +/// The settings by key, and the settings file's stamp when they were read or written. +struct KeptSettings { + stamp: FileStamp, + values: BTreeMap>, +} + +/// The bytes of the cache entries, and the cache folder's stamp when they were added up. +#[derive(Clone, Copy)] +struct KeptCache { + stamp: Option, + bytes: u64, } /// The settings and cache of one plug-in, in its folder of the plug-in store (see the module docs). /// Keys and entry names are [`storage::valid_name`]s; an entry's file name is its name as /// [`case_safe`] writes it, so that names differing only in case get different files. A link /// anywhere in the folder's path below the store is refused, and so is a cache file that is not a -/// plain file. +/// plain file. The settings stay in memory ([`Kept`]) while the settings file keeps its size and +/// modified time, and every change is written to the file before the call returns. The total +/// `cache_bytes` reports stays in memory while the cache folder keeps its modified time. pub(crate) struct PluginStorage { /// The plug-in store, and the plug-in's folder in it. root: PathBuf, dir: PathBuf, - /// Set, under [`PLUGIN_DATA_LOCK`], once the plug-in is removed; shared by every storage of the - /// folder ([`PLUGIN_DATA_OPEN`]). - closed: Arc, + state: Arc, } /// The sizes of `files` added up, at most `u64::MAX`: the sizes come from the file system. @@ -631,17 +677,29 @@ impl PluginStorage { Ok(()) } - /// The settings, by key. A file that is too large or doesn't parse is moved aside - /// ([`BAD_SETTINGS_FILE`]) with a log record, and the settings are empty. `meter` is charged the - /// file's size before it is read. - fn read_settings(&self, meter: &mut Meter) -> Result>, StorageError> { + fn kept(&self) -> MutexGuard<'_, Kept> { + self.state.kept.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// The settings: those in `kept` when the settings file still has their stamp, else the file's, + /// which then replace them. + fn read_settings<'a>(&self, kept: &'a mut Option, meter: &mut Meter) -> Result<&'a mut KeptSettings, StorageError> { let path = self.settings_path(); self.no_links(&path)?; - let size = match std::fs::metadata(&path) { - Ok(m) => m.len(), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()), - Err(e) => return Err(io(&e)), + let stamp = file_stamp(&path)?; + let held = match kept.take() { + Some(held) if held.stamp == stamp => held, + _ => self.load_settings(stamp, meter)?, }; + Ok(kept.insert(held)) + } + + /// Reads the settings file, whose stamp is `stamp`. A file that is too large or doesn't parse is + /// moved aside ([`BAD_SETTINGS_FILE`]) with a log record, and the settings are empty. `meter` is + /// charged the file's size before it is read. + fn load_settings(&self, stamp: FileStamp, meter: &mut Meter) -> Result { + let path = self.settings_path(); + let Some((size, _)) = stamp else { return Ok(KeptSettings { stamp, values: BTreeMap::new() }) }; let readable = size <= storage::MAX_SETTINGS_BYTES as u64; if readable { meter.take(size)?; @@ -657,12 +715,30 @@ impl PluginStorage { settings.filter(|s| s.len() <= storage::MAX_SETTINGS && s.values().all(|v| v.len() <= storage::MAX_SETTING_BYTES)) }); match parsed { - Some(settings) => Ok(settings), + Some(values) => Ok(KeptSettings { stamp, values }), None => { let aside = self.dir.join(BAD_SETTINGS_FILE); log::warn!("the plug-in settings {} don't read: moved to {} and started empty", path.display(), aside.display()); std::fs::rename(&path, &aside).map_err(|e| io(&e))?; - Ok(BTreeMap::new()) + Ok(KeptSettings { stamp: None, values: BTreeMap::new() }) + } + } + } + + /// Writes the settings in `kept` to the file and records the file's new stamp. When that fails, + /// or settings were written and the file is gone, `kept` is emptied, so the next call reads the + /// file again. + fn save_settings(&self, kept: &mut Option, meter: &mut Meter) -> Result<(), StorageError> { + let Some(held) = kept.as_mut() else { return Ok(()) }; + let written = self.write_settings(&held.values, meter); + match (written, file_stamp(&self.settings_path())) { + (Ok(()), Ok(stamp)) if stamp.is_some() || held.values.is_empty() => { + held.stamp = stamp; + Ok(()) + } + (written, _) => { + *kept = None; + written } } } @@ -690,7 +766,7 @@ impl PluginStorage { /// Fails with [`StorageError::Unavailable`] once the storage is closed. fn open(&self) -> Result<(), StorageError> { - if self.closed.load(Ordering::Acquire) { Err(StorageError::Unavailable) } else { Ok(()) } + if self.state.closed.load(Ordering::Acquire) { Err(StorageError::Unavailable) } else { Ok(()) } } /// The entries of the cache folder (plain files named as [`Self::entry_path`] names them, which @@ -727,28 +803,33 @@ impl Storage for PluginStorage { fn setting(&self, key: &str, meter: &mut Meter) -> Result>, StorageError> { let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); self.open()?; - Ok(self.read_settings(meter)?.remove(key)) + let mut kept = self.kept(); + Ok(self.read_settings(&mut kept.settings, meter)?.values.get(key).cloned()) } fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); self.open()?; - let mut settings = self.read_settings(meter)?; + let mut kept = self.kept(); + let settings = &mut self.read_settings(&mut kept.settings, meter)?.values; if value.len() > storage::MAX_SETTING_BYTES || (!settings.contains_key(key) && settings.len() >= storage::MAX_SETTINGS) { return Err(StorageError::Full); } + if settings.get(key).is_some_and(|old| old == value) { + return Ok(()); + } settings.insert(key.to_string(), value.to_vec()); - self.write_settings(&settings, meter) + self.save_settings(&mut kept.settings, meter) } fn delete_setting(&self, key: &str, meter: &mut Meter) -> Result { let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); self.open()?; - let mut settings = self.read_settings(meter)?; - if settings.remove(key).is_none() { + let mut kept = self.kept(); + if self.read_settings(&mut kept.settings, meter)?.values.remove(key).is_none() { return Ok(false); } - self.write_settings(&settings, meter).map(|()| true) + self.save_settings(&mut kept.settings, meter).map(|()| true) } fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result>, StorageError> { @@ -782,7 +863,10 @@ impl Storage for PluginStorage { meter.take(value.len() as u64)?; std::fs::create_dir_all(self.cache_dir()).map_err(|e| io(&e))?; self.no_links(&path)?; - write_atomic(&path, value).map_err(|e| io(&e)) + write_atomic(&path, value).map_err(|e| io(&e))?; + let stamp = folder_stamp(&self.cache_dir()); + self.kept().cache = stamp.map(|_| KeptCache { stamp, bytes: total }); + Ok(()) } fn remove_cache_entry(&self, name: &str) -> Result { @@ -791,7 +875,10 @@ impl Storage for PluginStorage { let path = self.entry_path(name); self.no_links(&path)?; match std::fs::remove_file(&path) { - Ok(()) => Ok(true), + Ok(()) => { + self.kept().cache = None; + Ok(true) + } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), Err(e) => Err(io(&e)), } @@ -819,6 +906,7 @@ impl Storage for PluginStorage { freed = freed.saturating_add(m.len()); } } + self.kept().cache = Some(KeptCache { stamp: folder_stamp(&dir), bytes: 0 }); Ok(freed) } @@ -826,7 +914,14 @@ impl Storage for PluginStorage { if self.open().is_err() { return 0; } - self.cache_files().map(|(files, _)| sum_sizes(&files)).unwrap_or(0) + let stamp = folder_stamp(&self.cache_dir()); + if let Some(kept) = self.kept().cache.filter(|k| k.stamp == stamp) { + return kept.bytes; + } + let Ok((files, _)) = self.cache_files() else { return 0 }; + let bytes = sum_sizes(&files); + self.kept().cache = Some(KeptCache { stamp, bytes }); + bytes } } diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 5d947b900..f9472e545 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1548,9 +1548,14 @@ fn plugin_storage_writes_are_atomic(st: &dyn vectorcraft_plugins::storage::Stora std::fs::remove_file(&entry_copy).unwrap(); } +/// Gives the file at `path` the modified time `when`, whatever the file system's clock says. +fn set_modified(path: &std::path::Path, when: std::time::SystemTime) { + std::fs::File::options().write(true).open(path).unwrap().set_modified(when).unwrap(); +} + /// Each storage call is charged the bytes it reads and writes, before it does: a settings call the -/// whole settings file it reads and the whole file it writes, a cache call the entry it reads or -/// writes. A call that would pass what its meter has left fails and changes nothing. +/// whole settings file when it reads the file and the whole file when it writes it, a cache call the +/// entry it reads or writes. A call that would pass what its meter has left fails and changes nothing. fn plugin_storage_calls_are_charged_the_files_they_read_and_write() { use vectorcraft_plugins::storage::{Meter, StorageError}; let folder = crate::tests_links::Folder::new("plugin-storage-meter"); @@ -1564,22 +1569,25 @@ fn plugin_storage_calls_are_charged_the_files_they_read_and_write() { st.set_setting("big", &[7; 60_000], &mut Meter::unlimited()).unwrap(); st.set_setting("small", b"x", &mut Meter::unlimited()).unwrap(); - // Reading a one-byte setting reads the whole file. + // A file another window changed is read whole, even for a one-byte setting. + let touch = || set_modified(&file, std::fs::metadata(&file).unwrap().modified().unwrap() + std::time::Duration::from_secs(1)); + touch(); let before = size(); let mut m = Meter::unlimited(); assert_eq!(st.setting("small", &mut m).unwrap(), Some(b"x".to_vec())); assert_eq!(m.used(), before); - // Setting it reads the file and writes it again, and so does deleting one. + // Setting it writes the file again, and so does deleting one. let mut m = Meter::unlimited(); st.set_setting("small", b"yz", &mut m).unwrap(); - assert_eq!(m.used(), before + size()); - let (before, mut m) = (size(), Meter::unlimited()); + assert_eq!(m.used(), size()); + let mut m = Meter::unlimited(); assert!(st.delete_setting("small", &mut m).unwrap()); - assert_eq!(m.used(), before + size()); + assert_eq!(m.used(), size()); st.set_setting("small", b"y", &mut Meter::unlimited()).unwrap(); // Less left than the file: nothing is read. Enough to read it but not to write it again: // nothing changes. + touch(); let mut m = Meter::new(size() - 1); assert_eq!(st.setting("small", &mut m), Err(StorageError::Budget)); assert_eq!(m.used(), 0); @@ -1599,6 +1607,122 @@ fn plugin_storage_calls_are_charged_the_files_they_read_and_write() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// A session with the plug-in `id` installed in a new store → its storage and the path of its +/// settings file. +fn storage_in( + s: &mut Session, + folder: &crate::tests_links::Folder, + id: &str, +) -> (std::sync::Arc, std::path::PathBuf) { + s.attach_plugin_store(folder.0.clone()); + install_storing(s, id, ""); + (vectorcraft_plugins::registry::storage(id).unwrap(), crate::store::plugin_data_dir(&folder.0, id).join("settings.json")) +} + +/// The settings stay in memory while the file keeps its size and modified time: a read doesn't open +/// the file, a set to the value already there doesn't write it, a real change is in the file when +/// the call returns, and a file with another time is read again. +#[test] +fn plugin_settings_are_read_from_memory_until_the_file_changes() { + use vectorcraft_plugins::storage::{Meter, StorageError}; + let folder = crate::tests_links::Folder::new("plugin-storage-kept"); + let id = "test.engine.storagekept"; + let mut s = session(); + let (st, file) = storage_in(&mut s, &folder, id); + st.set_setting("zoom", b"hello", &mut Meter::unlimited()).unwrap(); + let written = std::fs::metadata(&file).unwrap().modified().unwrap(); + + // Other bytes of the same size and the same time: the storage can't tell, so what it answers + // shows that it did not read the file. + let other = + std::fs::read_to_string(&file).unwrap().replace(&vectorcraft_format::base64_encode(b"hello"), &vectorcraft_format::base64_encode(b"world")); + std::fs::write(&file, &other).unwrap(); + set_modified(&file, written); + let mut m = Meter::unlimited(); + assert_eq!(st.setting("zoom", &mut m).unwrap(), Some(b"hello".to_vec())); + assert_eq!(m.used(), 0, "answered from memory"); + + // Setting the value it has writes nothing: the file still holds the other bytes. + let mut m = Meter::unlimited(); + st.set_setting("zoom", b"hello", &mut m).unwrap(); + assert_eq!(m.used(), 0, "nothing to write"); + assert_eq!(std::fs::read_to_string(&file).unwrap(), other); + + // Another time: read again. + set_modified(&file, written + std::time::Duration::from_secs(1)); + let mut m = Meter::unlimited(); + assert_eq!(st.setting("zoom", &mut m).unwrap(), Some(b"world".to_vec())); + assert_eq!(m.used(), other.len() as u64); + + // A change reaches the file before the call returns, and a failed one is not kept. + st.set_setting("tile", b"x", &mut Meter::unlimited()).unwrap(); + assert!(std::fs::read_to_string(&file).unwrap().contains("\"tile\"")); + assert_eq!(st.set_setting("tile", b"y", &mut Meter::new(0)), Err(StorageError::Budget)); + assert_eq!(st.setting("tile", &mut Meter::unlimited()).unwrap(), Some(b"x".to_vec())); + assert!(st.delete_setting("tile", &mut Meter::unlimited()).unwrap()); + assert!(!std::fs::read_to_string(&file).unwrap().contains("\"tile\"")); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// `plugin.remove` drops what was kept: a file put back with the removed settings' size and time is +/// read from disk by the storage of the id installed again. +#[test] +fn removing_a_plugin_drops_its_settings_from_memory() { + use vectorcraft_plugins::storage::Meter; + let folder = crate::tests_links::Folder::new("plugin-storage-kept-removed"); + let id = "test.engine.storagekeptremoved"; + let mut s = session(); + let (st, file) = storage_in(&mut s, &folder, id); + st.set_setting("zoom", b"hello", &mut Meter::unlimited()).unwrap(); + let (bytes, written) = (std::fs::read(&file).unwrap(), std::fs::metadata(&file).unwrap().modified().unwrap()); + run(&mut s, "plugin.remove", json!({"id": id})); + + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, &bytes).unwrap(); + set_modified(&file, written); + s.attach_plugin_storage(id); + let st = vectorcraft_plugins::registry::storage(id).unwrap(); + let mut m = Meter::unlimited(); + assert_eq!(st.setting("zoom", &mut m).unwrap(), Some(b"hello".to_vec())); + assert_eq!(m.used(), bytes.len() as u64, "read from the file"); + s.remove_plugin_storage(id).unwrap(); +} + +/// The cache's total stays in memory while the cache folder keeps its time; a write and a clear +/// update it, and removing an entry drops it. A file whose length changes behind the storage leaves +/// the folder's time alone, so the total it reports shows that it did not list the folder. +#[test] +fn plugin_cache_total_is_kept_and_follows_writes_removals_and_clears() { + use vectorcraft_plugins::storage::Meter; + let folder = crate::tests_links::Folder::new("plugin-storage-kept-cache"); + let id = "test.engine.storagekeptcache"; + let mut s = session(); + let (st, file) = storage_in(&mut s, &folder, id); + let cache = file.parent().unwrap().join("cache"); + let m = &mut Meter::unlimited(); + let grow = |name: &str, len: u64| std::fs::File::options().write(true).open(cache.join(name)).unwrap().set_len(len).unwrap(); + + st.set_cache_entry("tile", b"abc", m).unwrap(); + grow("tile.bin", 10); + assert_eq!(st.cache_bytes(), 3, "the write set the total"); + // A write lists the folder for its caps, and starts from what it finds. + st.set_cache_entry("more", b"12345", m).unwrap(); + grow("more.bin", 5); + assert_eq!(st.cache_bytes(), 15); + assert!(st.remove_cache_entry("more").unwrap()); + assert_eq!(st.cache_bytes(), 10, "after a removal the total comes from the folder"); + grow("tile.bin", 20); + assert_eq!(st.cache_bytes(), 10, "and is kept again"); + assert_eq!(st.clear_cache().unwrap(), 20); + assert_eq!(st.cache_bytes(), 0); + + // A folder deleted behind the storage is noticed. + st.set_cache_entry("tile", b"abc", m).unwrap(); + std::fs::remove_dir_all(&cache).unwrap(); + assert_eq!(st.cache_bytes(), 0); + run(&mut s, "plugin.remove", json!({"id": id})); +} + /// A module with other bytes than the plug-in installed with its id, or, with none installed, one /// whose id has settings and a cache in the plug-in store, installs only with `replace: true`, and /// then keeps them; the same module installs again without it, and `replace` must be true or false. diff --git a/crates/plugins/src/host.rs b/crates/plugins/src/host.rs index 017df5a80..f8235a4fb 100644 --- a/crates/plugins/src/host.rs +++ b/crates/plugins/src/host.rs @@ -91,7 +91,7 @@ pub const STORAGE_BUDGET: i32 = -7; pub const MAX_STORAGE_CALLS: usize = 10_000; /// Most bytes one run's storage calls read and write, all together, counting the whole settings -/// file each settings call reads and writes and each cache entry read or written; a call that would +/// file each time it is read or written and each cache entry read or written; a call that would /// pass it returns [`STORAGE_BUDGET`]. pub const MAX_STORAGE_BYTES: usize = 1 << 30; diff --git a/crates/plugins/src/storage.rs b/crates/plugins/src/storage.rs index f3ef2e899..b7de23c45 100644 --- a/crates/plugins/src/storage.rs +++ b/crates/plugins/src/storage.rs @@ -93,8 +93,9 @@ impl std::fmt::Display for StorageError { /// Where one plug-in's settings and cache are kept. Every key and name the host passes is a /// [`valid_name`], and every value is within its cap ([`MAX_SETTING_BYTES`], [`MAX_CACHE_ENTRY`]); /// the storage enforces the totals. The calls that read or write a file charge `meter` the bytes -/// they read and write ([`Meter::take`]) before they do: a settings call the whole settings file it -/// reads and the whole file it writes, a cache call the entry it reads or writes. +/// they read and write ([`Meter::take`]) before they do: a settings call the whole settings file +/// when it reads the file and the whole file when it writes it, a cache call the entry it reads or +/// writes. A settings call that the storage answers from memory charges nothing. pub trait Storage: Send + Sync { /// The value of setting `key`, if it has one. fn setting(&self, key: &str, meter: &mut Meter) -> Result>, StorageError>; diff --git a/docs/mcp.md b/docs/mcp.md index f2b9cd0f3..d296c34db 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6578,7 +6578,9 @@ under its id. One that lists `settings` or `cache` keeps settings or a cache of size as `cacheBytes`, `plugin.clearCache {id?}` empties the cache of one plug-in or of all of them and returns `{cleared: [id], bytesFreed}`, replacing a plug-in keeps both, and `plugin.remove` deletes both (also for an id that isn't installed but still has them). Files it can't delete stay in the plug-in's folder renamed with `.removed`, where -no plug-in reaches them, until a plug-in with that id is removed again ([plugins.md](plugins.md#settings-and-cache)). A module with other bytes than the plug-in installed with its id, or, with none +no plug-in reaches them. A later `plugin.remove` tries again to delete them when a plug-in with that id is installed or +a storage folder for the id exists, and that deletion can fail too; for an id with only a `.removed` folder it does +not ([plugins.md](plugins.md#settings-and-cache)). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has settings or a cache in the `Plug-ins` folder (`needsReplace: true` in its `plugin.inspect` entry), installs only with `replace: true`: ask the user first, since it takes that plug-in's place and gets its settings and cache. Without it, `plugin.install` returns diff --git a/docs/plugins.md b/docs/plugins.md index 94d68697b..d7eb95694 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -269,10 +269,16 @@ the key as `(key, key_len)`, the UTF-8 bytes at `key` in its memory: A call that fails returns `-1` when the key has no value, `-2` when a key, value or buffer doesn't lie inside the module's memory, `-3` when the key is not a valid key, `-4` when the write would pass a cap (nothing changes), `-5` when there is nowhere to keep anything, `-6` when reading or writing the file failed, and `-7` when the run has used -its 10,000 storage calls or its 1 GiB of storage reads and writes. Each call counts the bytes it reads and writes: a -settings call the whole settings file it reads, and a set or delete also the whole file it writes; a cache call the -entry it reads or writes. A read counts them even when the value doesn't fit and nothing is copied. A call that would -pass the 1 GiB returns `-7` before it reads or writes anything. +its 10,000 storage calls or its 1 GiB of storage reads and writes. Each call counts the bytes it reads and writes. A +settings call counts the whole settings file when it reads the file, and a set or delete also counts the whole file +when it writes the file; a cache call counts the entry it reads or writes. A read counts them even when the value +doesn't fit and nothing is copied. A call that would pass the 1 GiB returns `-7` before it reads or writes anything. + +The app keeps the settings in memory after the first call and reads `settings.json` again only when the file's size +or modified time differs from the last read or write. A get answered from memory reads no file and counts no bytes. A +set to the value the key already has writes nothing. Every other set or delete writes the file before the call +returns. A cache call reads or writes the whole entry in memory in one step, up to 64 MiB, and the run waits for it. +A live effect runs while the art draws, so keep the entries it uses small. - A key is 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`, and is not a Windows device name (`CON`, `PRN`, `AUX`, `NUL`, `COM0` to `COM9`, `LPT0` to `LPT9`, in any case). Keys differ in case: `tile` and `Tile` are two. @@ -291,7 +297,9 @@ pass the 1 GiB returns `-7` before it reads or writes anything. (`plugin.install` with `replace: true`, after the user agreed) keeps the storage; `plugin.remove` deletes it, and a run of the plug-in still going gets `-5` from every storage call after that. `plugin.remove` first renames the folder (adding `.removed` to its name). When that fails, `plugin.remove` fails and the plug-in stays installed with its storage as it was. Files that can't be deleted after the rename stay in the - `.removed` folder, where no plug-in reaches them, and the next removal of a plug-in with that id deletes them. + `.removed` folder, where no plug-in reaches them. A later `plugin.remove` tries again to delete them when a plug-in + with that id is installed or a storage folder for the id exists, and that deletion can fail too. For an id that + isn't installed and has only a `.removed` folder, `plugin.remove` does not delete the folder and fails as not found. `plugin.remove` with the id of a plug-in that isn't installed deletes the storage kept for that id, when there is any. `plugin.clearCache` empties the cache and leaves the settings, and `plugin.list` and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8ee5cbe92..d2de3d9e3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1565,6 +1565,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin..` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **Plug-in settings and cache totals in memory (#6228):** the settings stay in memory while `settings.json` keeps its size and modified time, a set to the same value writes nothing, every other change is written to the file before the call returns, and `cacheBytes` comes from a total kept while the `cache` folder keeps its modified time. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one.