web: restore deletes its snapshot by open identity, not name

Second review pass found the restore marker matched WebFileOpened by
display name: any same-named open finishing while the recovered one
was still queued deleted the only copy. The snapshot key now travels
with the queued file (tagged queue entries), the marker records the
open id assigned at dispatch, and success, failure, and cancel all
match that id. Cancelling an unrelated open no longer clears a
pending restore marker.

Also: the boot scan removes dead index rows (missing or zero-byte
snapshot files) that no other path could discard, and the drop
listener install only latches when the listeners really registered.

(cherry picked from commit c8777a857b)
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:04 +03:00
1 parent 3023cefea4
commit 7b467e16ea
5 files changed
+134 -67

No files matched your search

+9 -6
View File
@@ -927,11 +927,12 @@ pub(super) struct OpenCADStudio {
/// prompt still has to offer, one at a time.
#[cfg(target_arch = "wasm32")]
web_autosave_pending: Vec<crate::io::web_autosave::WebAutosaveEntry>,
/// The snapshot whose recovered open is queued: `(autosave key,
/// open name)`. The OPFS copy is the only one, so it is deleted only
/// once an open with exactly that name succeeds.
/// The snapshot whose recovered open is in flight: `(autosave key,
/// open id)`. The OPFS copy is the only one, so it is deleted only
/// once the open with exactly that id succeeds — identity, never the
/// display name (same-named files share one stored copy on web).
#[cfg(target_arch = "wasm32")]
web_autosave_consumed: Option<(String, String)>,
web_autosave_consumed: Option<(String, u64)>,
/// Autosave keys a web save wanted to remove while a snapshot round was
/// still writing (the round's late write would resurrect the snapshot
/// with a fresh mtime); the removes run when the round finishes.
@@ -2260,8 +2261,10 @@ pub enum Message {
FileDropped(PathBuf),
/// Web only: a drawing dragged onto the page — the DOM drop listener has
/// already read the bytes; this opens through the web-picker flow (#344).
/// The third field tags an autosave-restore file with its snapshot key so
/// the snapshot is deleted only when this exact open succeeds.
#[cfg(target_arch = "wasm32")]
WebFileDropped(String, Vec<u8>),
WebFileDropped(String, Vec<u8>, Option<String>),
/// Web only: Ctrl+V captured by a focused text field, whose iced-internal
/// paste is inert there — read the async clipboard instead (#346).
WebFieldPaste,
@@ -2337,7 +2340,7 @@ pub enum Message {
/// Web: the offered snapshot's bytes are in; queue them for the guarded
/// open poll. The OPFS copy is consumed only once that open succeeds.
#[cfg(target_arch = "wasm32")]
WebAutosaveBytes(String, Result<Vec<u8>, String>),
WebAutosaveBytes(String, String, Result<Vec<u8>, String>),
/// Web: one autosave round finished for all dirty tabs (serially).
#[cfg(target_arch = "wasm32")]
WebAutosaveSnapshotFinished(Result<(), String>),
+10 -15
View File
@@ -3684,29 +3684,24 @@ impl OpenCADStudio {
self.close_active_modal();
let open_name = crate::io::web_autosave::recovered_name(&entry.name);
let key = entry.name;
// Remember the pair: `web_autosave_opened` deletes the OPFS copy
// only when an open with exactly `open_name` succeeds. A failed
// read below clears the marker again (`on_web_autosave_bytes`).
self.web_autosave_consumed = Some((key.clone(), open_name.clone()));
// The snapshot key travels with the bytes: the marker (and the
// deletion it authorises) is recorded only when the poll actually
// starts this open, keyed by that open's id — never by display name.
let read_key = key.clone();
Task::perform(
async move { crate::io::web_autosave::read(&key).await },
move |result| Message::WebAutosaveBytes(open_name, result),
async move { crate::io::web_autosave::read(&read_key).await },
move |result| Message::WebAutosaveBytes(open_name, key, result),
)
}
/// The recovered drawing opened: its autosave snapshot — held back until
/// now — can finally be deleted, and the next queued snapshot can be
/// offered. A different name finishing first just means the recovered
/// open is still queued behind it; keep waiting.
/// offered. The caller already matched the marker to this open's id.
#[cfg(target_arch = "wasm32")]
pub(super) fn web_autosave_opened(&mut self, open_name: &str) {
let Some((key, recorded)) = self.web_autosave_consumed.clone() else {
let Some((key, _)) = self.web_autosave_consumed.take() else {
return;
};
if recorded != open_name {
return;
}
self.web_autosave_consumed = None;
self.command_line
.push_output(crate::tf!("Restored \"{}\" from the autosave copy", open_name).as_ref());
wasm_bindgen_futures::spawn_local(async move {
@@ -3741,6 +3736,7 @@ impl OpenCADStudio {
pub(super) fn on_web_autosave_bytes(
&mut self,
open_name: String,
key: String,
result: Result<Vec<u8>, String>,
) -> Task<Message> {
match result {
@@ -3749,7 +3745,7 @@ impl OpenCADStudio {
// (`web_autosave_opened` / the failure hook), not here — the
// poll has not started the open yet, so queueing now would
// show the modal before the recovered drawing loads.
crate::io::web_drop::enqueue_open(open_name, bytes);
crate::io::web_drop::enqueue_restore(open_name, bytes, key);
Task::none()
}
Err(error) => {
@@ -3757,7 +3753,6 @@ impl OpenCADStudio {
// re-offer it this session — it was already taken out of
// `web_autosave_pending`, so the cycle cannot loop on it.
// It stays discardable on the next boot.
self.web_autosave_consumed = None;
self.command_line
.push_warning(crate::tf!("Autosave copy could not be read: {error}").as_ref());
self.queue_next_web_autosave_prompt();
+37 -8
View File
@@ -675,7 +675,13 @@ impl OpenCADStudio {
&& self.active_modal != Some(super::ModalKind::Recovery)
{
crate::io::web_drop::take_dropped()
.map(|(name, bytes)| self.update(Message::WebFileDropped(name, bytes)))
.map(|file| {
self.update(Message::WebFileDropped(
file.name,
file.bytes,
file.restore_key,
))
})
.unwrap_or_else(Task::none)
} else {
Task::none()
@@ -1097,35 +1103,56 @@ impl OpenCADStudio {
}
Message::OpenCancel => {
#[cfg(target_arch = "wasm32")]
let cancelled_id = self.opening.as_ref().map(|opening| opening.id);
if let Some(p) = self.opening.take() {
self.command_line
.push_info(crate::tf!("Open cancelled: \"{}\"", p.name).as_ref());
}
// A cancelled recovered open keeps its snapshot (re-offered
// next boot — it is the only copy); the remaining queued
// snapshots can still be offered this session.
// snapshots can still be offered this session. Match on the
// open's id so cancelling an unrelated open leaves a pending
// restore marker alone.
#[cfg(target_arch = "wasm32")]
if self.web_autosave_consumed.take().is_some() {
if cancelled_id
.is_some_and(|id| {
self.web_autosave_consumed
.as_ref()
.is_some_and(|(_, recorded)| *recorded == id)
})
&& self.web_autosave_consumed.take().is_some()
{
self.queue_next_web_autosave_prompt();
}
self.drain_pending_open()
}
#[cfg(target_arch = "wasm32")]
Message::WebFileDropped(name, bytes) => {
Message::WebFileDropped(name, bytes, restore_key) => {
// Bytes are already in hand (no picker round-trip), so this is
// `on_open_file`'s web branch with the real name and size on
// the overlay from the start. An open already in flight must
// not be clobbered (its bytes are gone once dequeued), so the
// pair goes back to the queue for the next poll tick.
// entry goes back to the queue for the next poll tick.
if self.opening.is_some() {
crate::io::web_drop::enqueue_open(name, bytes);
match restore_key {
Some(key) => {
crate::io::web_drop::enqueue_restore(name, bytes, key)
}
None => crate::io::web_drop::enqueue_open(name, bytes),
}
return Task::none();
}
let state = std::sync::Arc::new(crate::io::OpenProgressState::new(
crate::app::OPEN_PHASE_READING,
));
let open_id = self.next_open_id();
// Identity, not display name: only this exact open may delete
// the snapshot the restore came from.
if let Some(key) = restore_key {
self.web_autosave_consumed = Some((key, open_id));
}
self.opening = Some(crate::app::OpenProgress {
id: open_id,
name: name.clone(),
@@ -1155,7 +1182,7 @@ impl OpenCADStudio {
if self
.web_autosave_consumed
.as_ref()
.is_some_and(|(_, recorded)| *recorded == outcome.name)
.is_some_and(|(_, recorded)| *recorded == open_id)
{
if outcome.result.is_ok() {
self.web_autosave_opened(&outcome.name);
@@ -1285,7 +1312,9 @@ impl OpenCADStudio {
#[cfg(target_arch = "wasm32")]
Message::WebAutosaveDiscard => self.on_web_autosave_discard(),
#[cfg(target_arch = "wasm32")]
Message::WebAutosaveBytes(name, result) => self.on_web_autosave_bytes(name, result),
Message::WebAutosaveBytes(name, key, result) => {
self.on_web_autosave_bytes(name, key, result)
}
#[cfg(target_arch = "wasm32")]
Message::WebAutosaveSnapshotFinished(result) => self.on_web_autosave_finished(result),
+30 -25
View File
@@ -125,30 +125,7 @@ pub async fn snapshot(name: &str, bytes: &[u8], thumbnail: Option<&[u8]>) -> Res
Ok(())
}
/// All live snapshots. Index rows whose file is gone (crash between writes,
/// 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?;
let mut entries = Vec::new();
for name in read_index(&directory).await {
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,
modified_ms: file.last_modified(),
});
}
Ok(entries)
}
#[cfg(target_arch = "wasm32")]
pub async fn read(name: &str) -> Result<Vec<u8>, String> {
@@ -181,7 +158,35 @@ pub async fn remove(name: &str) -> Result<(), String> {
/// 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);
let directory = autosave_directory(false).await?;
let mut entries = Vec::new();
let mut dead: Vec<String> = Vec::new();
for name in read_index(&directory).await {
let file = match super::web_recent::get_file(&directory, &snapshot_key(&name)).await {
Ok(file) => file,
// Crash between the index and snapshot writes: the row points
// at nothing recoverable.
Err(_) => {
dead.push(name);
continue;
}
};
if file.size() == 0.0 {
dead.push(name);
continue;
}
entries.push(WebAutosaveEntry {
name,
size: file.size() as u64,
modified_ms: file.last_modified(),
});
}
// Dead rows are junk no other path removes: they never appear in an
// offer, so the user cannot discard them and the cap cannot evict them.
for name in dead {
let _ = remove(&name).await;
}
let (kept, evicted) = cap_evictions(entries, SNAPSHOT_CAP);
for name in evicted {
let _ = remove(&name).await;
}
+48 -13
View File
@@ -14,7 +14,16 @@ use wasm_bindgen_futures::JsFuture;
/// rejected with a console warning instead of being silently dropped.
const QUEUE_CAP: usize = 8;
static DROPPED: Mutex<VecDeque<(String, Vec<u8>)>> = Mutex::new(VecDeque::new());
/// One queued open. `restore_key` tags an autosave-restore file so its
/// snapshot is deleted only when THIS open succeeds — matching by identity,
/// never by display name (same-named files are one OPFS copy on web).
pub struct QueuedOpen {
pub name: String,
pub bytes: Vec<u8>,
pub restore_key: Option<String>,
}
static DROPPED: Mutex<VecDeque<QueuedOpen>> = Mutex::new(VecDeque::new());
// `OnceLock::take` needs `&mut self`, which an immutable static cannot
// provide — an AtomicBool swap gives the same set-once marker and can still
// be reverted when no document exists.
@@ -24,7 +33,7 @@ fn warn(line: &str) {
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(line));
}
fn lock_queue() -> std::sync::MutexGuard<'static, VecDeque<(String, Vec<u8>)>> {
fn lock_queue() -> std::sync::MutexGuard<'static, VecDeque<QueuedOpen>> {
// The critical sections are push/pop only, so poisoning is unreachable;
// recover anyway rather than panic across the JS boundary.
DROPPED
@@ -32,7 +41,8 @@ fn lock_queue() -> std::sync::MutexGuard<'static, VecDeque<(String, Vec<u8>)>> {
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn enqueue(name: String, bytes: Vec<u8>) {
fn push(entry: QueuedOpen) {
let name = entry.name.clone();
let mut queue = lock_queue();
if queue.len() >= QUEUE_CAP {
drop(queue);
@@ -41,24 +51,37 @@ fn enqueue(name: String, bytes: Vec<u8>) {
));
return;
}
queue.push_back((name, bytes));
queue.push_back(entry);
}
/// Queue already-read drawing bytes (drag-and-drop, autosave restore) for the
/// 50 ms poll to open through the guarded web-picker flow.
/// Queue already-read drawing bytes (drag-and-drop) for the 50 ms poll to
/// open through the guarded web-picker flow.
pub fn enqueue_open(name: String, bytes: Vec<u8>) {
enqueue(name, bytes);
push(QueuedOpen {
name,
bytes,
restore_key: None,
});
}
/// Queue an autosave-restore file tagged with its snapshot key.
pub fn enqueue_restore(name: String, bytes: Vec<u8>, restore_key: String) {
push(QueuedOpen {
name,
bytes,
restore_key: Some(restore_key),
});
}
/// JS-callable entry (same shape as `ocs_control_submit`) for hosts that
/// forward drops themselves.
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn ocs_web_drop_file(name: String, bytes: js_sys::Uint8Array) {
enqueue(name, bytes.to_vec());
enqueue_open(name, bytes.to_vec());
}
/// Pop the oldest dropped file that the poll has not started opening yet.
pub fn take_dropped() -> Option<(String, Vec<u8>)> {
pub fn take_dropped() -> Option<QueuedOpen> {
lock_queue().pop_front()
}
@@ -109,8 +132,12 @@ pub fn install() {
);
// Canceling `dragenter` too is belt-and-braces some engines still want
// before treating the page as a drop target.
let _ = document.add_event_listener_with_callback("dragenter", cancel.as_ref().unchecked_ref());
let _ = document.add_event_listener_with_callback("dragover", cancel.as_ref().unchecked_ref());
let cancel_ok = document
.add_event_listener_with_callback("dragenter", cancel.as_ref().unchecked_ref())
.is_ok()
& document
.add_event_listener_with_callback("dragover", cancel.as_ref().unchecked_ref())
.is_ok();
cancel.forget();
let on_drop = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::Event)>::new(
@@ -151,7 +178,9 @@ pub fn install() {
wasm_bindgen_futures::spawn_local(async move {
for (name, read) in reads {
match JsFuture::from(read).await {
Ok(buffer) => enqueue(name, js_sys::Uint8Array::new(&buffer).to_vec()),
Ok(buffer) => {
enqueue_open(name, js_sys::Uint8Array::new(&buffer).to_vec())
}
Err(error) => {
web_sys::console::error_1(&wasm_bindgen::JsValue::from_str(&format!(
"ocs: failed to read dropped file \"{name}\": {}",
@@ -163,8 +192,14 @@ pub fn install() {
});
},
);
let _ = document.add_event_listener_with_callback("drop", on_drop.as_ref().unchecked_ref());
let drop_ok = document
.add_event_listener_with_callback("drop", on_drop.as_ref().unchecked_ref())
.is_ok();
on_drop.forget();
if !cancel_ok || !drop_ok {
// Unlatch so a later install can retry with fresh listeners.
INSTALLED.store(false, Ordering::SeqCst);
}
}
fn is_drawing_name(name: &str) -> bool {