forked from 1b-agent/opencadstudio-upstream
web: harden drop-open and autosave after adversarial review
Review of the drop-open and autosave commits surfaced one blocker and
five majors; all are fixed here, and two claims in the autosave commit
message are corrected by this one: tab close does NOT clear a snapshot
(matches native .sv$ semantics — saving, restoring, discarding, exit,
and the capped boot scan do), and serialization runs on the main
thread between awaits, not off the paint path.
- The restore prompt can no longer appear with no entries (empty
"Recover Drawing" modal on every boot once the autosave dir exists).
- Restore no longer consumes the snapshot before the open succeeds:
the recovered drawing goes through the guarded drop queue, and the
snapshot is deleted only after the tab opens; cancel or parse
failure keeps the only copy.
- Saving during an autosave round defers its snapshot cleanup until
the round finishes, so a stale round can no longer resurrect a
snapshot newer than the save.
- The boot scan no longer deletes snapshots by mtime comparison
(re-opening a drawing refreshes the recent copy's mtime, which
silently destroyed wanted snapshots); GC is now a 16-entry cap with
oldest-first eviction.
- OPFS write failures abort the writable stream (a leaked lock could
stall the same-key writer and disable autosave for the session), and
DOMException errors now carry their name/message.
- Empty (crash-window) snapshots are never offered; an unreadable one
warns and stays discardable instead of looping forever.
- Drop-open polish: reads start inside the handler and complete in
drop order (legacy WebKit safety, deterministic multi-file order),
dragenter is canceled too, the install guard only latches on
success, non-drawing drops don't steal propagation, a lock-poison
panic can't cross into JS, and re-dropping an open drawing no
longer risks clobbering an in-flight open (it re-queues).
- The model-space sheet floor is per-axis (a thin 8x400 mm profile
keeps its length) and centers content on the enlarged page.
(cherry picked from commit 3edbc5b7c8)
This commit is contained in:
1 parent
12fd04790f
commit
3023cefea4
7 files changed
+360
-96
No files matched your search
+98
-11
@@ -8,11 +8,18 @@
|
||||
//! offered for restore at boot when newer than the drawing's last explicit
|
||||
//! save (the OPFS recent copy of the same name).
|
||||
//!
|
||||
//! Losing that comparison never deletes a snapshot: the recent copy is
|
||||
//! refreshed by every OPEN as well as every save, so a snapshot that just
|
||||
//! went stale may still be the only survivor of a crash right after a
|
||||
//! re-open. Accumulation is bounded instead — the boot scan keeps the
|
||||
//! newest [`SNAPSHOT_CAP`] snapshots and evicts only the oldest ones beyond
|
||||
//! that cap.
|
||||
//!
|
||||
//! OPFS entry names carry only the FNV hash of the drawing name (like
|
||||
//! `web_recent`), so a small JSON index maps hashes back to names. The index
|
||||
//! is written before its snapshot: a crash between the two writes leaves a
|
||||
//! name without bytes, which `list` filters out, rather than an orphan
|
||||
//! snapshot nobody can name.
|
||||
//! name without bytes — a missing file or a zero-byte one — which `list`
|
||||
//! filters out, rather than an orphan snapshot nobody can name.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen::JsCast;
|
||||
@@ -38,6 +45,9 @@ pub struct WebAutosaveEntry {
|
||||
/// A snapshot is worth offering when it is strictly newer than the last
|
||||
/// explicit save, or when no saved copy exists at all (the drawing was never
|
||||
/// saved — or its recent copy was evicted — so the snapshot is all there is).
|
||||
/// The comparison decides *offering* only, never survival: the recent copy
|
||||
/// is refreshed by every open, so "older than the recent copy" is not proof
|
||||
/// that a snapshot recovers nothing.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
|
||||
pub(crate) fn newer_than_saved(autosave_ms: f64, saved_ms: Option<f64>) -> bool {
|
||||
match saved_ms {
|
||||
@@ -46,6 +56,33 @@ pub(crate) fn newer_than_saved(autosave_ms: f64, saved_ms: Option<f64>) -> bool
|
||||
}
|
||||
}
|
||||
|
||||
/// Upper bound on stored snapshots; the boot scan evicts the oldest ones
|
||||
/// beyond it. Without a bound, snapshots superseded by a save (or by a
|
||||
/// mere re-open) would pile up forever, since age no longer deletes them.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
|
||||
pub(crate) const SNAPSHOT_CAP: usize = 16;
|
||||
|
||||
/// Split `entries` into `(kept, evicted)` so at most `cap` snapshots remain,
|
||||
/// both sides ordered by `modified_ms` (newest first within each side; the
|
||||
/// evicted names overall run oldest-first, so an interrupted scan deletes
|
||||
/// the least valuable snapshots first). This is the only deletion criterion
|
||||
/// the boot scan applies — being older than the recent copy is not one.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
|
||||
pub(crate) fn cap_evictions(
|
||||
mut entries: Vec<WebAutosaveEntry>,
|
||||
cap: usize,
|
||||
) -> (Vec<WebAutosaveEntry>, Vec<String>) {
|
||||
entries.sort_by(|a, b| b.modified_ms.total_cmp(&a.modified_ms));
|
||||
let overflow = entries.len().saturating_sub(cap);
|
||||
let mut evicted: Vec<String> = entries
|
||||
.split_off(entries.len() - overflow)
|
||||
.into_iter()
|
||||
.map(|entry| entry.name)
|
||||
.collect();
|
||||
evicted.reverse();
|
||||
(entries, evicted)
|
||||
}
|
||||
|
||||
/// The name a restored drawing carries as a tab and download: the drawing's
|
||||
/// stem plus " (recovered)" and its own extension (DWG when the stored name
|
||||
/// has none, matching how autosave serializes untitled tabs). The extension
|
||||
@@ -89,7 +126,10 @@ pub async fn snapshot(name: &str, bytes: &[u8], thumbnail: Option<&[u8]>) -> Res
|
||||
}
|
||||
|
||||
/// All live snapshots. Index rows whose file is gone (crash between writes,
|
||||
/// partial eviction) are skipped rather than reported.
|
||||
/// partial eviction) and zero-byte snapshots (the crash window after the
|
||||
/// index write but before the snapshot write) are skipped rather than
|
||||
/// reported — an empty file recovers nothing and would only route the user
|
||||
/// into the corrupt-file recovery flow.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn list() -> Result<Vec<WebAutosaveEntry>, String> {
|
||||
let directory = autosave_directory(false).await?;
|
||||
@@ -98,6 +138,9 @@ pub async fn list() -> Result<Vec<WebAutosaveEntry>, String> {
|
||||
let Ok(file) = super::web_recent::get_file(&directory, &snapshot_key(&name)).await else {
|
||||
continue;
|
||||
};
|
||||
if file.size() == 0.0 {
|
||||
continue;
|
||||
}
|
||||
entries.push(WebAutosaveEntry {
|
||||
name,
|
||||
size: file.size() as u64,
|
||||
@@ -130,22 +173,26 @@ pub async fn remove(name: &str) -> Result<(), String> {
|
||||
drawing.map(|_| ())
|
||||
}
|
||||
|
||||
/// Boot scan: keep the snapshots worth offering (newer than the drawing's
|
||||
/// last explicit save, newest first) and silently delete the stale ones — a
|
||||
/// snapshot older than the saved copy recovers nothing and would otherwise
|
||||
/// pile up across sessions.
|
||||
/// Boot scan: offer the snapshots worth recovering — strictly newer than the
|
||||
/// drawing's last explicit copy, newest first. Age never deletes: the recent
|
||||
/// copy is refreshed by every OPEN as well as every save, so a snapshot that
|
||||
/// lost the comparison may still be the only survivor of a crash right after
|
||||
/// a re-open. Accumulation is bounded instead — beyond [`SNAPSHOT_CAP`]
|
||||
/// snapshots the oldest are evicted, which is the only deletion here.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn scan() -> Result<Vec<WebAutosaveEntry>, String> {
|
||||
let (kept, evicted) = cap_evictions(list().await?, SNAPSHOT_CAP);
|
||||
for name in evicted {
|
||||
let _ = remove(&name).await;
|
||||
}
|
||||
let mut recoverable = Vec::new();
|
||||
for entry in list().await? {
|
||||
for entry in kept {
|
||||
let saved_ms = super::web_recent::modified_ms(&entry.name).await;
|
||||
if newer_than_saved(entry.modified_ms, saved_ms) {
|
||||
recoverable.push(entry);
|
||||
} else {
|
||||
let _ = remove(&entry.name).await;
|
||||
}
|
||||
}
|
||||
recoverable.sort_by(|a, b| b.modified_ms.total_cmp(&a.modified_ms));
|
||||
// `cap_evictions` already ordered the survivors newest-first.
|
||||
Ok(recoverable)
|
||||
}
|
||||
|
||||
@@ -219,4 +266,44 @@ mod tests {
|
||||
assert_eq!(recovered_name("a.b.dwg"), "a.b (recovered).dwg");
|
||||
assert_eq!(recovered_name(""), " (recovered).dwg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn under_the_cap_nothing_is_evicted_and_order_is_newest_first() {
|
||||
let (kept, evicted) = cap_evictions(
|
||||
vec![
|
||||
entry("old.dwg", 100.0),
|
||||
entry("new.dwg", 300.0),
|
||||
entry("mid.dwg", 200.0),
|
||||
],
|
||||
SNAPSHOT_CAP,
|
||||
);
|
||||
assert!(evicted.is_empty());
|
||||
assert_eq!(
|
||||
kept.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
|
||||
["new.dwg", "mid.dwg", "old.dwg"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_the_cap_exactly_the_oldest_overflow_is_evicted() {
|
||||
let entries = (0..SNAPSHOT_CAP + 4)
|
||||
.map(|i| entry(&format!("d{i}.dwg"), i as f64))
|
||||
.collect();
|
||||
let (kept, evicted) = cap_evictions(entries, SNAPSHOT_CAP);
|
||||
assert_eq!(kept.len(), SNAPSHOT_CAP);
|
||||
// Newest kept first, oldest evicted first.
|
||||
assert_eq!(kept.first().map(|e| e.name.as_str()), Some("d19.dwg"));
|
||||
assert_eq!(
|
||||
evicted.iter().map(|name| name.as_str()).collect::<Vec<_>>(),
|
||||
["d0.dwg", "d1.dwg", "d2.dwg", "d3.dwg"]
|
||||
);
|
||||
}
|
||||
|
||||
fn entry(name: &str, modified_ms: f64) -> WebAutosaveEntry {
|
||||
WebAutosaveEntry {
|
||||
name: name.to_string(),
|
||||
size: 16,
|
||||
modified_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user