Merge pull request #6732 from dotCooCoo/fix/plugin-removed-rename-back

Plug-ins: a failed rename back of a removed plug-in's settings is reported and undone later
This commit is contained in:
BFlat
2026-11-12 09:07:24 +07:00
committed by GitHub
6 changed files with 291 additions and 34 deletions
+1 -1
View File
@@ -1096,7 +1096,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Plug-in",
[],
None,
"{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, or its grant can't be removed from the plug-in store, the command fails and the plug-in stays installed); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it → {removed}",
"{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, or its grant can't be removed from the plug-in store, the command fails and the plug-in stays installed; when the settings can't be moved back either, the error names the folder they stay in, and the next load, install or removal of the plug-in moves them back); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it → {removed}",
always,
remove
),
+153 -25
View File
@@ -190,6 +190,12 @@ fn lock_disabled(_: &Path) -> Option<std::fs::File> {
thread_local! {
/// Tests: [`Session::put_back_plugin`] fails on this thread, as on a full volume.
pub(crate) static PUT_BACK_FAILS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// Tests: moving a plug-in's settings and cache back from `<folder>.removed` ([`move_back`])
/// fails on this thread, as when another program holds a file in the folder.
pub(crate) static MOVE_BACK_FAILS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// Tests: writing the `.delete` file beside a folder about to be deleted ([`delete_aside`])
/// fails on this thread, as on a full volume.
pub(crate) static MARKER_WRITE_FAILS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Remove `dir/file` → whether it is gone; a file that isn't there is what was wanted.
@@ -590,26 +596,34 @@ impl Session {
}
/// Keep the settings and cache of plug-in `id` in the attached plug-in store from now on; without
/// one, nowhere (its storage calls then fail as unavailable).
/// one, nowhere (its storage calls then fail as unavailable). Settings and cache that a removal
/// kept are moved back first ([`plugin_storage`]).
pub(crate) fn attach_plugin_storage(&self, id: &str) {
vectorcraft_plugins::registry::set_storage(id, plugin_storage(self.plugin_dir.as_deref(), id));
}
/// Does the attached plug-in store have a folder of settings and cache for plug-in `id`? A
/// folder behind a `storage` folder that is a link is not counted: the plug-in's storage calls
/// can't reach it and removing the plug-in leaves it.
/// Does the attached plug-in store have a folder of settings and cache for plug-in `id`, or one
/// that a removal kept ([`aside_paths`], [`is_kept`])? A folder behind a `storage` folder
/// that is a link is not counted: the plug-in's storage calls can't reach it and removing the
/// plug-in leaves it.
pub(crate) fn has_plugin_storage(&self, id: &str) -> bool {
self.plugin_dir
.as_deref()
.is_some_and(|dir| !is_link(&dir.join(PLUGIN_DATA_DIR)) && std::fs::symlink_metadata(plugin_data_dir(dir, id)).is_ok())
self.plugin_dir.as_deref().is_some_and(|dir| {
let path = plugin_data_dir(dir, id);
!is_link(&dir.join(PLUGIN_DATA_DIR)) && (std::fs::symlink_metadata(&path).is_ok() || aside_paths(&path).iter().any(|a| is_kept(a)))
})
}
/// 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 (`<folder>.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. A later call for the id tries to delete it again. `forget` runs once the folder is
/// out of the way, before anything is deleted: when it returns `false`, the folder is renamed
/// back and this fails.
/// Settings and cache that an earlier removal kept ([`is_kept`]) are first moved back when the
/// folder is missing ([`restore_removed`]); what earlier deletions left is deleted. The folder is
/// then renamed out of the way ([`aside_paths`]: `<folder>.removed`, or `<folder>.removing` while
/// `<folder>.removed` holds kept settings), and that fails, leaving it as it was, when the rename
/// does. `forget` runs once the folder is out of the way, before anything is deleted: when it
/// returns `false`, the folder is renamed back and this fails, and the kept settings stay. When
/// that rename fails too, the error also names both paths and the reason, and the next attach or
/// removal for the id moves the folder back. Once `forget` returns `true`, the folder and the
/// kept settings are deleted ([`delete_aside`]); what that deletion leaves stays, with a log
/// record, and a later call for the id tries to delete it again.
pub(crate) fn remove_plugin_storage(&self, id: &str, forget: impl FnOnce() -> bool) -> Result<(), StorageError> {
let not_forgotten = || StorageError::Io(GRANT_NOT_REMOVED.to_string());
vectorcraft_plugins::registry::set_storage(id, None);
@@ -636,28 +650,45 @@ impl Session {
close();
return Ok(());
}
let mut aside = path.clone().into_os_string();
aside.push(".removed");
let aside = PathBuf::from(aside);
let _ = std::fs::remove_dir_all(&aside);
match std::fs::rename(&path, &aside) {
let asides = aside_paths(&path);
if std::fs::symlink_metadata(&path).is_err()
&& let Err(e) = restore_removed(&path)
{
log::warn!("can't move the settings and cache of {} back: {e}", path.display());
}
for aside in asides.iter().filter(|a| !is_kept(a)) {
delete_aside(aside);
}
let target = if std::fs::symlink_metadata(&path).is_err() {
asides.iter().rev().find(|a| is_kept(a)).unwrap_or(&asides[0])
} else if !is_kept(&asides[0]) {
&asides[0]
} else if std::fs::symlink_metadata(&asides[1]).is_err() {
&asides[1]
} else {
return Err(StorageError::Io(format!("{}: {} and {} are in the way", path.display(), asides[0].display(), asides[1].display())));
};
match std::fs::rename(&path, target) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(StorageError::Io(format!("{}: {e}", path.display()))),
Ok(()) => {}
}
if !forget() {
if std::fs::symlink_metadata(&aside).is_ok()
&& let Err(e) = std::fs::rename(&aside, &path)
if std::fs::symlink_metadata(target).is_ok()
&& let Err(e) = move_back(target, &path)
{
log::warn!("can't move {} back: {e}", aside.display());
log::warn!("can't move {} back: {e}", target.display());
return Err(StorageError::Io(format!(
"{GRANT_NOT_REMOVED}, and its settings and cache can't be moved back from {} to {}: {e}",
target.display(),
path.display()
)));
}
return Err(not_forgotten());
}
close();
if std::fs::symlink_metadata(&aside).is_ok()
&& let Err(e) = std::fs::remove_dir_all(&aside)
{
log::warn!("can't delete {}: {e}", aside.display());
for aside in &asides {
delete_aside(aside);
}
Ok(())
}
@@ -688,10 +719,107 @@ pub(crate) fn plugin_data_dir(dir: &Path, id: &str) -> PathBuf {
dir.join(PLUGIN_DATA_DIR).join(format!("id-{}", case_safe(id)))
}
/// Where plug-in `id` keeps its settings and cache in the plug-in store `dir`.
/// `path` with `suffix` added to its last part.
fn suffixed(path: &Path, suffix: &str) -> PathBuf {
let mut named = path.as_os_str().to_os_string();
named.push(suffix);
PathBuf::from(named)
}
/// Where [`Session::remove_plugin_storage`] moves the plug-in folder `path` out of the way:
/// `<path>.removed`, and `<path>.removing` while `<path>.removed` holds settings and cache an
/// earlier removal kept ([`is_kept`]). The second one, when it is there, is the newer.
fn aside_paths(path: &Path) -> [PathBuf; 2] {
[suffixed(path, ".removed"), suffixed(path, ".removing")]
}
/// `<aside>.delete`: an empty file written next to the folder `aside` before it is deleted, so
/// that what a failed deletion leaves there is not taken for settings a removal kept.
fn delete_marker(aside: &Path) -> PathBuf {
suffixed(aside, ".delete")
}
/// Does `aside` hold settings and cache that a removal kept: a folder, or a link in place of one,
/// without a [`delete_marker`] beside it?
fn is_kept(aside: &Path) -> bool {
std::fs::symlink_metadata(aside).is_ok_and(|m| !m.is_file()) && std::fs::symlink_metadata(delete_marker(aside)).is_err()
}
/// Write the empty file `marker` ([`delete_marker`]).
fn write_delete_marker(marker: &Path) -> std::io::Result<()> {
#[cfg(test)]
if MARKER_WRITE_FAILS.get() {
return Err(std::io::Error::other("the volume is full"));
}
std::fs::write(marker, b"")
}
/// Delete the folder `aside` (a file there is left), writing its [`delete_marker`] first and
/// removing it once the folder is gone; what the deletion can't remove stays, with a log record.
/// When the marker can't be written, nothing is deleted, so that the folder stays whole.
fn delete_aside(aside: &Path) {
let marker = delete_marker(aside);
match std::fs::symlink_metadata(aside) {
Err(_) => {
let _ = std::fs::remove_file(&marker);
return;
}
Ok(m) if m.is_file() => return,
Ok(_) => {}
}
if let Err(e) = write_delete_marker(&marker) {
log::warn!("can't save {}: {e}; {} is left as it is", marker.display(), aside.display());
return;
}
match std::fs::remove_dir_all(aside) {
Err(e) => log::warn!("can't delete {}: {e}", aside.display()),
Ok(()) => {
if let Err(e) = std::fs::remove_file(&marker) {
log::warn!("can't remove {}: {e}", marker.display());
}
}
}
}
/// Rename the plug-in folder at `aside` back to `path`.
fn move_back(aside: &Path, path: &Path) -> std::io::Result<()> {
#[cfg(test)]
if MOVE_BACK_FAILS.get() {
return Err(std::io::Error::other("the folder is in use"));
}
std::fs::rename(aside, path)
}
/// Move the newest settings and cache that a removal kept ([`aside_paths`], [`is_kept`]) back to
/// the plug-in folder `path` when there is none. When `path` is there, it is used and the kept
/// folders are left as they are, with a log record, until the plug-in's removal deletes them.
/// Called under [`PLUGIN_DATA_LOCK`].
fn restore_removed(path: &Path) -> std::io::Result<()> {
let asides = aside_paths(path);
let mut kept = asides.iter().rev().filter(|a| is_kept(a));
if std::fs::symlink_metadata(path).is_ok() {
for aside in kept {
log::warn!("{} is used: the settings and cache in {} are left as they are", path.display(), aside.display());
}
return Ok(());
}
kept.next().map_or(Ok(()), |aside| move_back(aside, path))
}
/// Where plug-in `id` keeps its settings and cache in the plug-in store `dir`. Settings and cache
/// that a removal kept are moved back first ([`restore_removed`]); when that fails, the failure is
/// logged and there is no storage (its calls fail as unavailable), so that nothing the plug-in
/// writes takes their place. A later attach or load tries again.
fn plugin_storage(dir: Option<&Path>, id: &str) -> Option<Arc<dyn Storage>> {
let dir = dir?;
let data = plugin_data_dir(dir, id);
if !is_link(&dir.join(PLUGIN_DATA_DIR)) {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
if let Err(e) = restore_removed(&data) {
log::warn!("can't move the settings and cache of {} back: {e}", data.display());
return None;
}
}
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 }))
}
+119
View File
@@ -1973,6 +1973,125 @@ fn a_grant_that_cannot_be_removed_stops_plugin_remove() {
assert!(!std::path::Path::new(&file).exists());
}
/// When `plugin.remove` can't remove the grant and can't move the settings back either, its error
/// says where the settings are, and the plug-in gets no storage until they are back. A second
/// removal that can't remove the grant moves them back rather than deleting them, and so does the
/// next load of the plug-in.
#[test]
fn settings_that_cannot_be_moved_back_are_reported_and_moved_back_later() {
let folder = crate::tests_links::Folder::new("plugin-store-failed-move-back");
let id = "test.engine.permissions.movebackfail";
let approved = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let grants = folder.0.join("grants.json");
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&approved), "grant": ["doc.read"]}));
let recorded = std::fs::read(&grants).unwrap();
// `1e9` is written back as `1000000000.0`, which makes the rewritten file larger than the 1 MiB that is read.
let entry = serde_json::from_slice::<Vec<Value>>(&recorded).unwrap().remove(0);
let full = format!(r#"[{entry},{{"n":[{}]}}]"#, vec!["1e9"; 200_000].join(",")).into_bytes();
std::fs::write(&grants, &full).unwrap();
let data = crate::store::plugin_data_dir(&folder.0, id);
let settings = br#"{"zoom":"aGVsbG8="}"#;
std::fs::create_dir_all(&data).unwrap();
std::fs::write(data.join("settings.json"), settings).unwrap();
let mut aside = data.clone().into_os_string();
aside.push(".removed");
let aside = std::path::PathBuf::from(aside);
crate::store::MOVE_BACK_FAILS.set(true);
let refused = s.execute("plugin.remove", &json!({"id": id})).unwrap_err().to_string();
crate::store::MOVE_BACK_FAILS.set(false);
assert!(refused.contains(crate::store::GRANT_NOT_REMOVED), "{refused}");
assert!(refused.contains(&aside.display().to_string()) && refused.contains("the folder is in use"), "{refused}");
assert!(vectorcraft_plugins::registry::get(id).is_some(), "the plug-in stays installed");
assert!(!data.exists());
assert_eq!(std::fs::read(aside.join("settings.json")).unwrap(), settings);
assert!(vectorcraft_plugins::registry::storage(id).is_none(), "no empty settings take their place");
let refused = s.execute("plugin.remove", &json!({"id": id})).unwrap_err().to_string();
assert_eq!(refused, crate::store::GRANT_NOT_REMOVED);
assert_eq!(std::fs::read(data.join("settings.json")).unwrap(), settings, "the second removal moves the settings back");
assert!(!aside.exists());
crate::store::MOVE_BACK_FAILS.set(true);
assert!(s.execute("plugin.remove", &json!({"id": id})).is_err());
crate::store::MOVE_BACK_FAILS.set(false);
assert!(!data.exists());
// A folder of the plug-in's own made meanwhile is used, and a removal that can't remove the
// grant leaves both.
let newer = br#"{"zoom":"bmV3"}"#;
std::fs::create_dir_all(&data).unwrap();
std::fs::write(data.join("settings.json"), newer).unwrap();
let refused = s.execute("plugin.remove", &json!({"id": id})).unwrap_err().to_string();
assert_eq!(refused, crate::store::GRANT_NOT_REMOVED);
assert_eq!(std::fs::read(data.join("settings.json")).unwrap(), newer);
assert_eq!(std::fs::read(aside.join("settings.json")).unwrap(), settings, "the kept settings stay");
std::fs::remove_dir_all(&data).unwrap();
// A restart loads the plug-in again with its settings.
std::fs::write(&grants, &recorded).unwrap();
vectorcraft_plugins::registry::remove(id);
let mut next = session();
next.attach_plugin_store(folder.0.clone());
assert!(vectorcraft_plugins::registry::get(id).is_some(), "the plug-in loads");
assert_eq!(std::fs::read(data.join("settings.json")).unwrap(), settings, "the load moves the settings back");
assert!(!aside.exists());
run(&mut next, "plugin.remove", json!({"id": id}));
assert!(vectorcraft_plugins::registry::get(id).is_none());
assert!(!data.exists() && !aside.exists(), "the removal deletes the settings");
assert_eq!(std::fs::read_dir(data.parent().unwrap()).unwrap().count(), 0, "nothing is left beside them");
}
/// What a deletion left in a plug-in's renamed settings folder, marked by the `.delete` file beside
/// it, isn't moved back. When that file can't be written, nothing is deleted, so the folder stays
/// whole and counts as kept settings.
#[test]
fn a_removal_deletes_only_folders_it_marked() {
let folder = crate::tests_links::Folder::new("plugin-store-delete-mark");
let id = "test.engine.permissions.deletemark";
let approved = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let install = json!({"dataBase64": vectorcraft_format::base64_encode(&approved), "grant": ["doc.read"]});
let mut next = session();
next.attach_plugin_store(folder.0.clone());
let data = crate::store::plugin_data_dir(&folder.0, id);
let settings = br#"{"zoom":"aGVsbG8="}"#;
let mut aside = data.clone().into_os_string();
aside.push(".removed");
let aside = std::path::PathBuf::from(aside);
let mut marker = aside.clone().into_os_string();
marker.push(".delete");
// The next install starts with no settings, and its removal deletes the leftover and the mark.
std::fs::create_dir_all(&aside).unwrap();
std::fs::write(aside.join("settings.json"), settings).unwrap();
std::fs::write(&marker, b"").unwrap();
run(&mut next, "plugin.install", install.clone());
assert!(!data.exists(), "a leftover of a deletion isn't moved back");
run(&mut next, "plugin.remove", json!({"id": id}));
assert_eq!(std::fs::read_dir(data.parent().unwrap()).unwrap().count(), 0, "the leftover and its mark are deleted");
// When the mark can't be written, the removal deletes nothing: installing the id again needs
// `replace`, and a removal of the id that isn't installed deletes the folder.
run(&mut next, "plugin.install", install.clone());
std::fs::create_dir_all(&data).unwrap();
std::fs::write(data.join("settings.json"), settings).unwrap();
crate::store::MARKER_WRITE_FAILS.set(true);
let removed = next.execute("plugin.remove", &json!({"id": id}));
crate::store::MARKER_WRITE_FAILS.set(false);
assert_eq!(removed.unwrap(), json!({"removed": id}));
assert!(vectorcraft_plugins::registry::get(id).is_none());
assert_eq!(std::fs::read(aside.join("settings.json")).unwrap(), settings, "nothing is deleted without the mark");
assert!(!std::path::Path::new(&marker).exists());
assert!(next.execute("plugin.install", &install).is_err(), "the kept settings go to a new install only with `replace`");
assert!(vectorcraft_plugins::registry::get(id).is_none());
assert_eq!(std::fs::read(aside.join("settings.json")).unwrap(), settings);
run(&mut next, "plugin.remove", json!({"id": id}));
assert_eq!(std::fs::read_dir(data.parent().unwrap()).unwrap().count(), 0, "the removal of the id deletes the kept folder");
}
/// Windows keeps a file another program holds open without delete sharing, so the last grant can't
/// be removed by deleting `grants.json`.
#[cfg(windows)]
+5 -3
View File
@@ -7598,7 +7598,9 @@ with other bytes has `needsApproval: true` and needs a new one even when it list
forgets the approval, and so does installing a module that lists no permissions under that id: the earlier bytes then
need a new approval. A refused install keeps the grant. A failed write of the module to the plug-in store leaves the
grant in the store. A stored module that is disabled, or not read in Safe Mode, keeps the grant for its id. When
`plugin.remove` can't remove the grant from the plug-in store, it returns an error and the plug-in stays installed. A
`plugin.remove` can't remove the grant from the plug-in store, it returns an error and the plug-in stays installed.
When the plug-in's settings and cache can't be moved back from their `.removed` folder then either, the error names
that folder, and the next load, install or `plugin.remove` of the plug-in moves them back. A
filter without `doc.write` or `state` that returns output needing them gets an error and the document is unchanged.
A plug-in of API level 2 (`api: 2` in its entry) that lists `progress` may report its progress, see Cancel and write
log lines: `notifications/cancelled` makes its `vc_cancelled` return 1, and its log lines go to the server's log
@@ -7608,8 +7610,8 @@ size as `cacheBytes`, `plugin.clearCache {id?}` empties the cache of one plug-in
`{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. 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
a storage folder for the id exists, and that deletion can fail too; for an id with only such a folder (marked by a
`.delete` file beside it) 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
+12 -5
View File
@@ -394,11 +394,18 @@ A live effect runs while the art draws, so keep the entries it uses small.
write leaves in the `cache` folder does not count toward the caps, and clearing the cache removes it.
- A plug-in reaches only the storage of its own id. Replacing it with another module of the same id
(`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. 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` first renames the folder, adding `.removed` to its name (or `.removing` while a `.removed` folder
holds settings an earlier removal kept). When that fails, `plugin.remove` fails and the plug-in stays installed with
its storage as it was. Before VectorCraft deletes a renamed folder, it writes an empty file beside it with `.delete`
added to the folder's name, and removes that file once the folder is gone. When that file can't be written, the
folder is not deleted and is treated like any renamed folder without a `.delete` file (see below). Files that can't be deleted stay in the
renamed folder with the `.delete` file beside it, 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 such a folder, `plugin.remove` does not delete it and fails as
not found. When `plugin.remove` can't remove the grant and can't rename the folder back either, its error names the
renamed folder. The next load, install or `plugin.remove` of the plug-in renames a renamed folder that has no
`.delete` file back while the plug-in has no folder of its own; when it has one, that folder is used and the renamed
folder stays until `plugin.remove` deletes both.
`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
+1
View File
@@ -1668,6 +1668,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.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 settings a failed removal can't move back:** when `plugin.remove` can't remove the grant and can't rename the plug-in's `.removed` folder back, its error names that folder, and the next load, install or removal of the plug-in renames it back instead of deleting it. 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.