fix: close the review findings on the last web wave

Full verification of the XREF/data/ARCHIVE/worker wave (three
reviewers, one forensic) found no blockers and no crossfire damage;
five majors and several minors are fixed here.

- ARCHIVE now packages XREFs: the bytes registry added by the same
  commit is consulted (the in-code 'no xref store exists' comment was
  false within the commit), and the drawing is packaged only after
  the full save preparation — without it kernel solids shipped
  without their ACIS data.
- PendingClose stores the stable tab id instead of a vector index:
  the web save now completes asynchronously, and closing a lower tab
  during the serialization window shifted indices — closing the WRONG
  tab, or panicking on a stale one. All producers and consumers
  resolve the id at use.
- The xref bytes registry is capped (6 entries, oldest evicted): every
  open, drop, and pick registered full drawing bytes with no bound —
  an OOM waiting to happen. Reopened recents register too, so any
  entry path can serve as another drawing's xref.
- A re-picked data-link source with the same name but different bytes
  no longer silently replaces the first (the browser hands back bare
  names, so two same-named files collided and every reference read
  the newer pick): the pick is refused and the user is told why.
- The unresolved-xref warning lists file names (what to drop on the
  page), not internal block names; the ZIP writer refused the ZIP64
  sentinel values (0xFFFF counts, 0xFFFFFFFF sizes/offsets) instead
  of emitting archives that send strict readers hunting for a ZIP64
  EOCD that is absent; docs note the worker-side saves.

Known follow-ups documented, not fixed here: the serde-skipped
ExtendedEntity/CadDocument raw bytes still drop on worker crossings
(needs codec accessors — upstream), and WBLOCK/SAVEALL/ARCHIVE
still serialize inline (the worker route is a small follow-up).

(cherry picked from commit 6f429f24)

Ported onto main (#1717 review): main already keys PendingClose by tab
id, so those hunks stay as on main; the ZIP64 guard is moot with the zip
crate; the unresolved-xref names split on both path separators, since
Path does not split Windows paths in the browser build.
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:04 +03:00
1 parent 61c32c7f74
commit 8968e79942
7 files changed
+115 -17

No files matched your search

+4
View File
@@ -693,6 +693,10 @@ async fn open_recent_web_attempt(
bytes.len(),
);
let result = load_web_bytes(&name, &bytes, progress, recovery_mode, &initial_error, None).await;
// A recent reopened from OPFS registers like a picked or dropped file,
// so it can serve as another drawing's xref in this session.
#[cfg(target_arch = "wasm32")]
xref::register_source(&name, Arc::new(bytes.to_vec()));
let keep_for_recovery = result
.as_ref()
.err()
+40 -8
View File
@@ -6,6 +6,7 @@ use codec::tables::TableEntry;
use codec::types::{Handle, Vector3};
use codec::{CadDocument, EntityType};
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
@@ -51,7 +52,18 @@ pub fn unresolved_xref_names(infos: &[XrefInfo]) -> Vec<String> {
infos
.iter()
.filter(|info| info.status == XrefStatus::NotFound)
.map(|info| info.name.clone())
// The file name is what the user must drop on the page; the block
// name may be a renamed `A$C$...` alias that matches no file.
// Split on both separators: drawings carry Windows paths, which
// `Path` does not split off Windows.
.map(|info| {
info.path
.rsplit(['/', '\\'])
.next()
.map(str::trim)
.filter(|n| !n.is_empty())
.map_or_else(|| info.name.clone(), str::to_string)
})
.collect()
}
@@ -89,9 +101,16 @@ pub(crate) fn same_file_name(a: &str, b: &str) -> bool {
!a.is_empty() && a == b
}
fn source_cache() -> &'static Mutex<HashMap<String, Arc<Vec<u8>>>> {
static SOURCES: OnceLock<Mutex<HashMap<String, Arc<Vec<u8>>>>> = OnceLock::new();
SOURCES.get_or_init(|| Mutex::new(HashMap::default()))
/// Upper bound on registered drawings. Every open, drop, and XATTACH pick
/// registers its bytes; without a cap a day of opening large drawings grows
/// the wasm heap until the tab dies. Oldest registrations leave first —
/// enough for a parent drawing plus its usual handful of references.
const SOURCE_CAP: usize = 6;
fn source_cache() -> &'static Mutex<(HashMap<String, Arc<Vec<u8>>>, VecDeque<String>)> {
static SOURCES: OnceLock<Mutex<(HashMap<String, Arc<Vec<u8>>>, VecDeque<String>)>> =
OnceLock::new();
SOURCES.get_or_init(|| Mutex::new((HashMap::default(), VecDeque::new())))
}
fn parsed_cache() -> &'static Mutex<HashMap<String, codec::ReadOutcome>> {
@@ -105,10 +124,21 @@ fn parsed_cache() -> &'static Mutex<HashMap<String, codec::ReadOutcome>> {
/// finally supplies a missing reference) reloads from the new bytes.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // mirrors image_model::register_source
pub fn register_source(name: &str, bytes: Arc<Vec<u8>>) {
source_cache()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(name.to_string(), bytes);
{
let mut cache = source_cache()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let (map, order) = &mut *cache;
if !map.contains_key(name) {
order.push_back(name.to_string());
while order.len() > SOURCE_CAP {
if let Some(evicted) = order.pop_front() {
map.remove(&evicted);
}
}
}
map.insert(name.to_string(), bytes);
}
parsed_cache()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
@@ -133,6 +163,7 @@ pub(crate) fn find_registered_source(raw: &str) -> Option<String> {
source_cache()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.0
.keys()
.find(|key| same_file_name(key, raw))
.cloned()
@@ -144,6 +175,7 @@ pub(crate) fn registered_bytes(key: &str) -> Option<Arc<Vec<u8>>> {
source_cache()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.0
.get(key)
.cloned()
}