mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 17:44:02 +00:00
Merge pull request #6584 from dotCooCoo/fix/web-plugin-budget-clock
Plug-ins: the web build times the first-view budget and each run's limit
This commit is contained in:
Generated
+1
@@ -6687,6 +6687,7 @@ dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"wasmi",
|
||||
"wat",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -22,6 +22,10 @@ craft-vectorcraft-format = { workspace = true }
|
||||
sha2 = { version = "0.10", default-features = false }
|
||||
thiserror = { workspace = true }
|
||||
wasmi = { workspace = true }
|
||||
# The monotonic clock of run deadlines and the first-view budget: `std::time::Instant` on the
|
||||
# desktop, the page's `performance.now()` in the web build. It is MIT OR Apache-2.0 and already in
|
||||
# the build through eframe.
|
||||
web-time = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
wat = { workspace = true }
|
||||
|
||||
@@ -86,20 +86,15 @@ pub struct Refreshed {
|
||||
pub failed: usize,
|
||||
}
|
||||
|
||||
/// Milliseconds on a monotonic clock, `None` where there is none (the web build).
|
||||
/// Milliseconds on a monotonic clock, `None` from a clock that cannot be read.
|
||||
pub type Clock = Arc<dyn Fn() -> Option<u64> + Send + Sync>;
|
||||
|
||||
/// Milliseconds since the first reading in this process (native builds); `None` on the web build,
|
||||
/// whose `std` has no monotonic clock.
|
||||
/// Milliseconds since the first reading in this process, from `web_time::Instant`: the system's
|
||||
/// monotonic clock on the desktop and the page's `performance.now()` in the web build.
|
||||
fn monotonic() -> Option<u64> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||||
let start = START.get_or_init(std::time::Instant::now);
|
||||
Some(u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
None
|
||||
static START: std::sync::OnceLock<web_time::Instant> = std::sync::OnceLock::new();
|
||||
let start = START.get_or_init(web_time::Instant::now);
|
||||
Some(u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -579,6 +574,14 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// The default clock, which the web build also reads, gives the first view its budget.
|
||||
#[test]
|
||||
fn the_default_clock_gives_the_first_view_a_budget() {
|
||||
let d = Deferrals::new();
|
||||
assert!(matches!(d.allowance(), Allowance::Until { ms: OPENING_BUDGET_MS, .. }));
|
||||
assert!(!d.passed(u64::MAX));
|
||||
}
|
||||
|
||||
/// A stage drawn with several inputs (the paths of a group) holds one record for each, and is
|
||||
/// listed and counted once.
|
||||
#[test]
|
||||
|
||||
@@ -24,8 +24,7 @@ use crate::{Error, Kind, Limits, Plugin, Result, registry};
|
||||
/// document: 200 times a filter's base budget.
|
||||
pub const FUEL: u64 = 10_000_000_000;
|
||||
|
||||
/// Wall-clock budget of one export, in milliseconds (native builds; the web build has no clock,
|
||||
/// and the instruction budget alone bounds it there).
|
||||
/// Wall-clock budget of one export, in milliseconds.
|
||||
pub const WALL_TIME_MS: u64 = 300_000;
|
||||
|
||||
/// Most warnings an export reports.
|
||||
|
||||
+14
-14
@@ -321,29 +321,20 @@ pub struct Report {
|
||||
/// The limit a run past its wall-clock deadline exceeded ([`Error::Limit`]).
|
||||
pub(crate) const TIME_BUDGET: &str = "time budget";
|
||||
|
||||
/// When a run must stop (native: a wall-clock deadline).
|
||||
/// When a run must stop: a deadline on the monotonic clock (`web_time::Instant`, the page's
|
||||
/// `performance.now()` in the web build).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Deadline {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
at: std::time::Instant,
|
||||
at: web_time::Instant,
|
||||
}
|
||||
|
||||
impl Deadline {
|
||||
pub(crate) fn new(wall_time_ms: u64) -> Self {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = wall_time_ms;
|
||||
Self {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
at: std::time::Instant::now() + std::time::Duration::from_millis(wall_time_ms),
|
||||
}
|
||||
Self { at: web_time::Instant::now() + std::time::Duration::from_millis(wall_time_ms) }
|
||||
}
|
||||
|
||||
pub(crate) fn passed(&self) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if std::time::Instant::now() >= self.at {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
web_time::Instant::now() >= self.at
|
||||
}
|
||||
|
||||
pub(crate) fn check(&self) -> Result<()> {
|
||||
@@ -816,6 +807,15 @@ mod tests {
|
||||
Run::new("test", true, false, Deadline::new(60_000))
|
||||
}
|
||||
|
||||
/// A deadline reads the monotonic clock that the web build also reads: one of 0 ms has
|
||||
/// passed, and one a day away has not.
|
||||
#[test]
|
||||
fn a_deadline_passes_on_the_monotonic_clock() {
|
||||
assert!(Deadline::new(0).passed());
|
||||
assert!(matches!(Deadline::new(0).check(), Err(Error::Limit(l)) if l == TIME_BUDGET));
|
||||
assert!(!Deadline::new(86_400_000).passed());
|
||||
}
|
||||
|
||||
/// Progress is clamped: negative counts are 0 and `done` never passes a known total.
|
||||
#[test]
|
||||
fn progress_is_clamped() {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! permission): no WASI, file system, network, clock or randomness. A module that
|
||||
//! imports anything else is rejected;
|
||||
//! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is
|
||||
//! also enforced (native builds), so infinite loops end with an error. A filter may ask for a
|
||||
//! also enforced, so infinite loops end with an error. A filter may ask for a
|
||||
//! larger one ([`Budget::Heavy`]), which the host lets the user allow before installing it;
|
||||
//! - a **linear-memory cap**, a **recursion-depth cap** and wasmi's strict module limits;
|
||||
//! - a **fresh instance per run**, so no state leaks between calls; what a plug-in keeps is only
|
||||
|
||||
@@ -29,7 +29,7 @@ const FUEL_COSTS: CustomFuelCosts = CustomFuelCosts { bytes_copied_per_fuel: 64,
|
||||
/// the host learns that only once the run has ended.
|
||||
pub const HEAVY_FUEL: u64 = 1_000_000_000;
|
||||
|
||||
/// Wall-clock budget of a heavy filter's run, in milliseconds (native builds).
|
||||
/// Wall-clock budget of a heavy filter's run, in milliseconds.
|
||||
pub const HEAVY_WALL_TIME_MS: u64 = 300_000;
|
||||
|
||||
/// The limit a run reports when its input and parameters together are past half its memory.
|
||||
@@ -56,8 +56,8 @@ pub struct Limits {
|
||||
pub fuel_per_byte: u64,
|
||||
/// Deepest call nesting.
|
||||
pub max_recursion_depth: usize,
|
||||
/// Wall-clock budget for one run, in milliseconds. Native builds only: the web build has no
|
||||
/// monotonic clock in `std`, so the fuel budget alone bounds it there.
|
||||
/// Wall-clock budget for one run, in milliseconds. The web build measures it with the page's
|
||||
/// `performance.now()`.
|
||||
pub wall_time_ms: u64,
|
||||
/// The most a heavy filter ([`crate::Budget::Heavy`]) is given instead of `fuel_base` and
|
||||
/// `wall_time_ms`: what the host allows, which the module's manifest can ask for but never
|
||||
|
||||
+2
-2
@@ -7333,8 +7333,8 @@ and the stages with a part of their input too small for the drawing the refresh
|
||||
timed out, failed or have no plug-in. `id` names one object in place of
|
||||
`ids`, and an entry that is not an object id of the document is an error. Each document has its own budget and its own deferred stages. Exports, Expand
|
||||
Appearance and commands run every stage under the per-call limit, so an export never leaves out a stage that was only
|
||||
deferred for time, and the canvas then draws the result. The web build has no clock for the budget: there the first
|
||||
view runs no plug-in effect, and Refresh Deferred Effects runs them. An exporter adds a write-only file format `plugin.<id>` (listed by `document.formats`):
|
||||
deferred for time, and the canvas then draws the result. The web build measures the budget and the per-call
|
||||
limit with the page's `performance.now()`. An exporter adds a write-only file format `plugin.<id>` (listed by `document.formats`):
|
||||
`document.export {format: "plugin.<id>", params?, path?}` writes it, as does a `path` ending in one of its extensions
|
||||
that no built-in format has. An importer adds a read-only file format `plugin.<id>` (listed by `document.formats`
|
||||
with `read: true`, and `openExtensions` holds the extensions File › Open opens with it, without those a built-in format reads or that File › Open installs, imports or loads as something else, such as `.wasm` or `.ase`): `document.open {path | name, dataBase64, importer?:
|
||||
|
||||
+3
-3
@@ -128,7 +128,7 @@ The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssem
|
||||
| Linear memory | 512 MiB per instance (input and parameters: at most half) | `memory.grow` returns -1; oversized input or initial memory fails |
|
||||
| Output | 64 MiB of JSON | `Limit("output size")` |
|
||||
| Instructions (fuel) | 50 M per call + 4,000 per input byte for `vc_run`; a [heavy filter](#heavy-filters) 1 G + 4,000 per byte; an export or import 10 G + 4,000 per byte | `Limit("instruction budget")` |
|
||||
| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run, export or import (desktop; checked every 20 M instructions) | `Limit("time budget")` |
|
||||
| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run, export or import (checked every 20 M instructions; the web build reads the page's `performance.now()`) | `Limit("time budget")` |
|
||||
| Import input | 128 MiB of the file (`vectorcraft_plugins::import::MAX_INPUT_BYTES`) | the file doesn't open; a larger file named by `path` is not read past that |
|
||||
| Call depth | 1,024 frames | trap |
|
||||
| Module shape | wasmi's strict limits (≤ 10,000 functions, 1 memory, …) | rejected |
|
||||
@@ -147,8 +147,8 @@ refuses or has no plug-in, draws its input art unchanged and is deferred. The st
|
||||
are deferred, and later drawings leave them as they are until their input or parameters change. Effect › Refresh
|
||||
Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget, and `effect.deferredStatus` lists
|
||||
them with the reason (`budget`, `timeout`, `missing`, `failed`, `safeMode`). Each document has its own budget.
|
||||
Exports, Expand Appearance and commands run every stage under the 5 s limit. The web build has no clock for the
|
||||
budget, so there the first view runs no live effect and Refresh Deferred Effects runs them.
|
||||
Exports, Expand Appearance and commands run every stage under the 5 s limit. The web build has the same budget,
|
||||
measured with the page's `performance.now()`.
|
||||
|
||||
### Heavy filters
|
||||
|
||||
|
||||
@@ -1651,7 +1651,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.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.
|
||||
- **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. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.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.
|
||||
- **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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user