Plug-ins: refreshing deferred effects no longer takes time in the square of the number of stages

Effect > Refresh Deferred Effects looked up the objects asked for, the
stages reached, the objects drawn whole and the inputs that ran with
Vec::contains, and counted each target stage by scanning every deferred
record. Asking whether a stage stays deferred and replacing a stage's
records when it runs also scanned every record.

Deferrals::refresh now keeps the objects asked for and the objects drawn
whole in sets, and Refresh's ids, retried, visited and touched and
Pass.ran and seen are sets. The deferred records are kept in a BTreeMap
by the order they were made in, with an index by stage and input key, so
holds, State::remove, settle and the refresh's reconciliation and count
look at the records of the stages concerned only. The targets, counts,
records kept and their order, marks and revision are unchanged.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-11 23:56:57 -08:00
parent 48e066f475
commit 94c8e472c1
2 changed files with 269 additions and 60 deletions
+268 -60
View File
@@ -19,7 +19,7 @@
//! [`revision`] changes, so that drawings made with the deferred stage are drawn again.
use std::cell::RefCell;
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
@@ -106,9 +106,108 @@ enum Phase {
Open,
}
/// The deferred records in the order they were made, indexed by stage and cache key. A stage holds
/// at most one record for each key.
#[derive(Default)]
struct Records {
/// The order number of the next record.
next: u64,
by_order: BTreeMap<u64, Deferred>,
/// For each stage with records, the order number of its record for each key.
by_stage: HashMap<Stage, HashMap<u64, u64>>,
}
impl Records {
/// Every record, oldest first.
fn iter(&self) -> impl Iterator<Item = &Deferred> {
self.by_order.values().inspect(|_| looked_at(1))
}
/// The stages with records, in no particular order.
fn stages(&self) -> impl Iterator<Item = Stage> + '_ {
self.by_stage.keys().copied()
}
/// The records of `stage`, in no particular order.
fn of(&self, stage: Stage) -> impl Iterator<Item = &Deferred> {
self.by_stage.get(&stage).into_iter().flat_map(|keys| keys.values()).filter_map(|n| {
looked_at(1);
self.by_order.get(n)
})
}
/// Is `stage` recorded with cache key `key`?
fn contains(&self, stage: Stage, key: u64) -> bool {
looked_at(1);
self.by_stage.get(&stage).is_some_and(|keys| keys.contains_key(&key))
}
/// Adds `d` as the newest record, in place of the record of its stage and key.
fn push(&mut self, d: Deferred) {
let order = self.next;
self.next = self.next.wrapping_add(1);
if let Some(old) = self.by_stage.entry(d.stage).or_default().insert(d.key, order)
&& let Some(old) = self.by_order.remove(&old)
{
unmark(old.key);
}
self.by_order.insert(order, d);
}
/// Drops the records of `stage` whose key `drop` gives true for.
fn drop_where(&mut self, stage: Stage, drop: impl Fn(u64) -> bool) {
let Some(keys) = self.by_stage.get_mut(&stage) else { return };
looked_at(keys.len());
let by_order = &mut self.by_order;
keys.retain(|&key, order| {
let keep = !drop(key);
if !keep && let Some(d) = by_order.remove(order) {
unmark(d.key);
}
keep
});
if keys.is_empty() {
self.by_stage.remove(&stage);
}
}
/// Drops the records for which `keep` is false.
fn retain(&mut self, mut keep: impl FnMut(&Deferred) -> bool) {
let by_stage = &mut self.by_stage;
self.by_order.retain(|_, d| {
looked_at(1);
let k = keep(d);
if !k {
if let Some(keys) = by_stage.get_mut(&d.stage) {
keys.remove(&d.key);
if keys.is_empty() {
by_stage.remove(&d.stage);
}
}
unmark(d.key);
}
k
});
}
}
#[cfg(test)]
thread_local! {
/// How many records the lookups on this thread have looked at.
static LOOKED_AT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Counts `n` records looked at, in tests.
fn looked_at(n: usize) {
#[cfg(test)]
LOOKED_AT.with(|c| c.set(c.get() + n));
#[cfg(not(test))]
let _ = n;
}
struct State {
phase: Phase,
deferred: Vec<Deferred>,
deferred: Records,
/// How many stages the first view left deferred, until [`Deferrals::take_report`] takes it.
report: Option<usize>,
/// The refresh running, if one is ([`Deferrals::refresh`]).
@@ -123,13 +222,13 @@ struct Refresh {
/// When its budget started (`None`: no run yet).
started: Option<u64>,
/// The objects whose stages run again; the others' stay deferred.
ids: Vec<u64>,
ids: HashSet<u64>,
/// The cache keys whose cached failures have been run again.
retried: Vec<u64>,
retried: HashSet<u64>,
/// The stages of `ids` that were drawn.
visited: Vec<Stage>,
visited: HashSet<Stage>,
/// The stage inputs of `ids` that ran, or were taken from the cache, or were deferred again.
touched: Vec<(Stage, u64)>,
touched: HashSet<(Stage, u64)>,
}
impl State {
@@ -155,7 +254,7 @@ impl State {
impl Drop for State {
fn drop(&mut self) {
for d in &self.deferred {
for d in self.deferred.iter() {
unmark(d.key);
}
}
@@ -197,7 +296,7 @@ impl Deferrals {
pub fn with_clock(clock: Clock) -> Self {
Self(Arc::new(Mutex::new(State {
phase: Phase::Budgeted { started: None, first: true },
deferred: Vec::new(),
deferred: Records::default(),
report: None,
refresh: None,
clock,
@@ -211,7 +310,7 @@ impl Deferrals {
/// The deferred stages, by object and effect, each once (a stage drawn with several inputs,
/// such as the paths of a group, is listed with the first one's reason).
pub fn list(&self) -> Vec<Deferred> {
let mut out = self.state().deferred.clone();
let mut out: Vec<Deferred> = self.state().deferred.iter().cloned().collect();
out.sort_by_key(|d| (d.stage.object, d.stage.item, d.stage.effect));
out.dedup_by_key(|d| d.stage);
out
@@ -238,13 +337,10 @@ impl Deferrals {
if pass.ran.is_empty() {
return;
}
self.state().deferred.retain(|d| {
let keep = !pass.ran.contains(&d.stage) || pass.seen.contains(&(d.stage, d.key));
if !keep {
unmark(d.key);
}
keep
});
let mut s = self.state();
for &stage in &pass.ran {
s.deferred.drop_where(stage, |key| !pass.seen.contains(&(stage, key)));
}
}
/// The first view has been drawn: later drawings leave deferred stages deferred, and
@@ -271,14 +367,14 @@ impl Deferrals {
pub fn refresh(&self, objects: Option<&[u64]>, run: impl FnOnce(&[u64]) -> Vec<u64>) -> Refreshed {
let (targets, ids) = {
let mut s = self.state();
let mut targets: Vec<Stage> = s.deferred.iter().map(|d| d.stage).filter(|t| objects.is_none_or(|o| o.contains(&t.object))).collect();
let asked: Option<HashSet<u64>> = objects.map(|o| o.iter().copied().collect());
let mut targets: Vec<Stage> = s.deferred.stages().filter(|t| asked.as_ref().is_none_or(|o| o.contains(&t.object))).collect();
targets.sort_by_key(|t| (t.object, t.item, t.effect));
targets.dedup();
let mut ids: Vec<u64> = targets.iter().map(|t| t.object).collect();
ids.dedup();
let thread = std::thread::current().id();
let (retried, visited, touched) = (Vec::new(), Vec::new(), Vec::new());
s.refresh = Some(Refresh { thread, started: None, ids: ids.clone(), retried, visited, touched });
let (retried, visited, touched) = (HashSet::new(), HashSet::new(), HashSet::new());
s.refresh = Some(Refresh { thread, started: None, ids: ids.iter().copied().collect(), retried, visited, touched });
(targets, ids)
};
// The refresh ends when `run` returns, also when it unwinds.
@@ -289,20 +385,16 @@ impl Deferrals {
}
}
let reopen = Reopen(self);
let whole = run(&ids);
let whole: HashSet<u64> = run(&ids).into_iter().collect();
let mut s = self.state();
let touched = match s.refresh.take() {
Some(r) => {
s.deferred.retain(|d| {
let gone = r.visited.contains(&d.stage) && whole.contains(&d.stage.object) && !r.touched.contains(&(d.stage, d.key));
if gone {
unmark(d.key);
}
!gone
});
for &stage in r.visited.iter().filter(|t| whole.contains(&t.object)) {
s.deferred.drop_where(stage, |key| !r.touched.contains(&(stage, key)));
}
r.touched
}
None => Vec::new(),
None => HashSet::new(),
};
drop(s);
drop(reopen);
@@ -310,11 +402,11 @@ impl Deferrals {
// than time; one with records left that this refresh did not run is still deferred.
let s = self.state();
let mut out = Refreshed::default();
for t in &targets {
let mut left = s.deferred.iter().filter(|d| d.stage == *t).peekable();
for &t in &targets {
let mut left = s.deferred.of(t).peekable();
if left.peek().is_none() {
out.refreshed += 1;
} else if left.any(|d| d.reason != Reason::Budget && touched.contains(&(d.stage, d.key))) {
} else if left.any(|d| d.reason != Reason::Budget && touched.contains(&(t, d.key))) {
out.failed += 1;
} else {
out.still_deferred += 1;
@@ -355,7 +447,7 @@ impl Deferrals {
pub(crate) fn holds(&self, stage: Stage, key: u64) -> bool {
let mut s = self.state();
let settled = s.phase_here() == Phase::Open || s.refreshes(stage.object) == Some(false);
let held = settled && s.deferred.iter().any(|d| d.stage == stage && d.key == key);
let held = settled && s.deferred.contains(stage, key);
drop(s);
if held {
seen(stage, key);
@@ -367,30 +459,22 @@ impl Deferrals {
/// reached the stage.
pub(crate) fn visit(&self, stage: Stage) {
let mut s = self.state();
if let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.visited.contains(&stage)) {
r.visited.push(stage);
if let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object)) {
r.visited.insert(stage);
}
}
/// Drops the records of the stages for which `keep` (given the stage and the plug-in id) is
/// false, such as those of effects or objects removed from the document.
pub fn retain(&self, keep: impl Fn(Stage, &str) -> bool) {
self.state().deferred.retain(|d| {
let k = keep(d.stage, &d.plugin);
if !k {
unmark(d.key);
}
k
});
self.state().deferred.retain(|d| keep(d.stage, &d.plugin));
}
/// Should the failure cached for `stage` with cache key `key` be run again? Yes once per key
/// while a refresh running here runs the stages of `stage`'s object.
pub(crate) fn retries(&self, stage: Stage, key: u64) -> bool {
let mut s = self.state();
let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.retried.contains(&key)) else { return false };
r.retried.push(key);
true
s.refresh_here().filter(|r| r.ids.contains(&stage.object)).is_some_and(|r| r.retried.insert(key))
}
/// What a run starting now may take of the budget: the budget starts with the first run.
@@ -425,13 +509,7 @@ impl State {
/// plug-in was not looked at) stands for any input, so it goes whatever `key` is, and `key` 0
/// removes every record of the stage.
fn remove(&mut self, stage: Stage, key: u64) {
self.deferred.retain(|d| {
let keep = d.stage != stage || (d.key != key && d.key != 0 && key != 0);
if !keep {
unmark(d.key);
}
keep
});
self.deferred.drop_where(stage, |k| k == key || k == 0 || key == 0);
}
/// `stage` was run with cache key `key`, or taken from the cache, by the drawing on this
@@ -439,12 +517,12 @@ impl State {
/// view [`Deferrals::settle`] looks at the stage when that drawing ends.
fn ran_here(&mut self, stage: Stage, key: u64) {
if let Some(r) = self.refresh_here().filter(|r| r.ids.contains(&stage.object)) {
r.touched.push((stage, key));
r.touched.insert((stage, key));
}
if self.phase_here() == Phase::Open {
let _ = PASSES.try_with(|p| {
if let Some(pass) = p.borrow_mut().last_mut().filter(|pass| !pass.ran.contains(&stage)) {
pass.ran.push(stage);
if let Some(pass) = p.borrow_mut().last_mut() {
pass.ran.insert(stage);
}
});
}
@@ -452,9 +530,8 @@ impl State {
/// The deferred stages, each once.
fn stages(&self) -> Vec<Stage> {
let mut out: Vec<Stage> = self.deferred.iter().map(|d| d.stage).collect();
let mut out: Vec<Stage> = self.deferred.stages().collect();
out.sort_by_key(|t| (t.object, t.item, t.effect));
out.dedup();
out
}
}
@@ -469,15 +546,15 @@ thread_local! {
/// deferred again), and the stage inputs it left deferred.
#[derive(Default)]
struct Pass {
ran: Vec<Stage>,
seen: Vec<(Stage, u64)>,
ran: HashSet<Stage>,
seen: HashSet<(Stage, u64)>,
}
/// The drawing on this thread left `stage` deferred with cache key `key`.
fn seen(stage: Stage, key: u64) {
let _ = PASSES.try_with(|p| {
if let Some(pass) = p.borrow_mut().last_mut() {
pass.seen.push((stage, key));
pass.seen.insert((stage, key));
}
});
}
@@ -676,6 +753,79 @@ mod tests {
assert_eq!(d.list().iter().map(|x| x.stage).collect::<Vec<_>>(), [st(2, 0), st(3, 0)]);
}
/// Objects asked for or drawn whole more than once count once; a stage with several inputs
/// gives a result when its object is drawn whole, fails when an input it ran with fails again
/// (even beside an older failure it did not run), and stays deferred when it is reached but
/// not drawn whole or not reached at all.
#[test]
fn a_refresh_with_repeated_objects_and_several_inputs_per_stage() {
const K: u64 = 0xdef1_0000;
let marked = |key| MARKED.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|m| m.contains_key(&key));
let d = Deferrals::with_clock(stepping(1));
d.first_view_done();
let st = |object| Stage { object, effect: 0, item: None };
for (object, reason, key) in [
(10, Reason::Budget, K + 1),
(10, Reason::Budget, K + 2),
(11, Reason::Timeout, K + 3),
(11, Reason::Budget, K + 4),
(12, Reason::Timeout, K + 5),
(13, Reason::Budget, K + 6),
(14, Reason::Budget, K + 7),
] {
d.defer(st(object), "a", reason, key);
}
let before = revision();
let seen = std::cell::RefCell::new(vec![]);
let counts = d.refresh(Some(&[10, 11, 12, 13, 10, 11, 99]), |ids| {
seen.borrow_mut().extend_from_slice(ids);
d.visit(st(10));
d.visit(st(10));
d.ran(st(10), K + 1);
d.visit(st(11));
d.defer(st(11), "a", Reason::Failed, K + 8);
d.visit(st(12));
vec![10, 13, 10, 13]
});
assert_eq!(*seen.borrow(), [10, 11, 12, 13]);
assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 2, failed: 1 });
assert!(revision() > before);
let keys = || d.state().deferred.iter().map(|x| (x.stage.object, x.key)).collect::<Vec<_>>();
assert_eq!(keys(), [(11, K + 3), (11, K + 4), (12, K + 5), (13, K + 6), (14, K + 7), (11, K + 8)]);
assert!(!marked(K + 1) && !marked(K + 2), "the records of the stage that gave a result");
assert!(marked(K + 3) && marked(K + 8));
// Again, with nothing reached: every stage asked for stays as it was.
let counts = d.refresh(Some(&[11, 12, 12]), |_| vec![11, 12]);
assert_eq!(counts, Refreshed { refreshed: 0, still_deferred: 2, failed: 0 });
assert_eq!(keys(), [(11, K + 3), (11, K + 4), (12, K + 5), (13, K + 6), (14, K + 7), (11, K + 8)]);
}
/// A refresh whose drawing unwinds ends: later drawings on this thread are no longer part of
/// it, and the next refresh has a budget of its own.
#[test]
fn a_refresh_that_unwinds_ends() {
let d = Deferrals::with_clock(stepping(1));
d.first_view_done();
let s = Stage { object: 5, effect: 0, item: None };
d.defer(s, "a", Reason::Budget, 0xdef2_0001);
let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
d.refresh(None, |_| {
assert!(!d.holds(s, 0xdef2_0001));
panic!("the drawing stopped")
})
}));
assert!(unwound.is_err());
assert!(matches!(d.allowance(), Allowance::Unlimited));
assert!(d.holds(s, 0xdef2_0001), "the stage stays deferred");
let counts = d.refresh(Some(&[5]), |_| {
assert!(matches!(d.allowance(), Allowance::Until { .. }));
d.visit(s);
vec![5]
});
assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 0, failed: 0 });
}
/// After the first view, a stage drawn with several inputs (the paths of a group) whose one
/// changed input runs keeps the records of the inputs the same drawing left deferred, and drops
/// the record of the input it no longer has.
@@ -724,4 +874,62 @@ mod tests {
drop((d, other));
assert!(!marked(), "the document closed");
}
/// Deferrals after the first view with `n` stages, one input each, keyed from `base`.
fn many(n: u64, base: u64) -> Deferrals {
let d = Deferrals::with_clock(stepping(1));
for object in 0..n {
d.defer(Stage { object, effect: 0, item: None }, "a", Reason::Budget, base + object);
}
d.first_view_done();
d
}
/// How many records `f` looks at on this thread.
fn looked_at_by(f: impl FnOnce()) -> usize {
let before = LOOKED_AT.with(std::cell::Cell::get);
f();
LOOKED_AT.with(std::cell::Cell::get) - before
}
/// Asking whether a stage input stays deferred looks at that stage's records only, however
/// many other stages are deferred.
#[test]
fn holding_a_stage_looks_at_its_own_records_only() {
const K: u64 = 0xdef3_0000;
let d = many(2_000, K);
let s = Stage { object: 1_000, effect: 0, item: None };
let n = looked_at_by(|| {
assert!(d.holds(s, K + 1_000));
assert!(!d.holds(s, K + 1_001), "another stage's input");
assert!(!d.holds(Stage { effect: 1, ..s }, K + 1_000), "another stage");
});
assert!(n <= 3, "looked at {n} records");
}
/// A stage that gives a result, or is deferred again, replaces its own records only, and so
/// does the end of a drawing after the first view: the other stages' records are not looked at.
#[test]
fn replacing_a_stage_record_looks_at_its_own_records_only() {
const K: u64 = 0xdef4_0000;
let d = many(2_000, K);
let st = |object| Stage { object, effect: 0, item: None };
let n = looked_at_by(|| {
d.ran(st(1_000), K + 1_000);
d.defer(st(1_001), "a", Reason::Failed, K + 9_999);
d.defer(st(1_002), "a", Reason::Missing, 0);
d.ran(st(1_002), 7);
});
assert!(n <= 6, "looked at {n} records");
let n = looked_at_by(|| {
d.view(|| {
assert!(!d.holds(st(1_003), K + 9_998));
d.ran(st(1_003), K + 9_998);
});
});
assert!(n <= 4, "looked at {n} records");
let keys = d.state().deferred.iter().filter(|x| (1_000..1_004).contains(&x.stage.object)).map(|x| x.key).collect::<Vec<_>>();
assert_eq!(keys, [K + 1_001, K + 9_999]);
assert_eq!(d.list().len(), 1_997);
}
}
+1
View File
@@ -1714,6 +1714,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **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`, a light filter with `progress` and no `commandAllowlist` runs in the background with the same progress window when it runs at once or OK runs it with Preview off, 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.
- **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.
- **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.
- **Refreshing many deferred effects (G17):** Refresh Deferred Effects (`effect.refreshDeferred`) looks up the objects asked for, the stages reached, the objects drawn whole and the inputs that ran in hash sets, so its time no longer grows with the square of the number of deferred stages. The deferred records are indexed by stage and input, so asking whether a stage stays deferred, replacing a stage's records when it runs, and ending a drawing after the first view look at that stage's records only, and the records keep the order they were made in. With 40,000 deferred stages, one refresh in a release build took 38 ms, down from 1,036 ms, and asking about and running each stage once took 16 ms, down from 2,287 ms (wall-clock time on a computer busy with other builds, one run each). Regressions: `crates/plugins/src/deferred.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.