forked from 1b-agent/opencadstudio-upstream
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:
1 parent
61c32c7f74
commit
8968e79942
7 files changed
+115
-17
No files matched your search
@@ -15,7 +15,7 @@ browser can't provide. This page lists the differences.
|
|||||||
| Fonts | Embedded stroke fonts + system TrueType + shaping + fallback | Fetched Noto subsets (shaping, fallback, and family aliases) + embedded stroke fonts |
|
| Fonts | Embedded stroke fonts + system TrueType + shaping + fallback | Fetched Noto subsets (shaping, fallback, and family aliases) + embedded stroke fonts |
|
||||||
| File open | Native file dialog → path | Browser picker → bytes |
|
| File open | Native file dialog → path | Browser picker → bytes |
|
||||||
| File save | Path / Save dialog → write to disk | Save dialog → direct browser download |
|
| File save | Path / Save dialog → write to disk | Save dialog → direct browser download |
|
||||||
| Parallelism | Multi-threaded (rayon) | Main thread + one Web Worker (DWG/DXF parse, hashing) |
|
| Parallelism | Multi-threaded (rayon) | Main thread + one Web Worker (DWG/DXF parse, hashing, save/autosave serialization) |
|
||||||
| GPU backend | Vulkan / DX12 / Metal (wgpu) | WebGL2 (the backend is pinned to OpenGL) |
|
| GPU backend | Vulkan / DX12 / Metal (wgpu) | WebGL2 (the backend is pinned to OpenGL) |
|
||||||
| Update check / external links | `ureq`, `open` | skipped / `window.open` |
|
| Update check / external links | `ureq`, `open` | skipped / `window.open` |
|
||||||
| External plugins | Native packages + Plugin Manager | **No** — native libraries cannot run in a browser |
|
| External plugins | Native packages + Plugin Manager | **No** — native libraries cannot run in a browser |
|
||||||
|
|||||||
@@ -71,11 +71,42 @@ fn same_source_name(a: &str, b: &str) -> bool {
|
|||||||
// Only the web registers sources (native re-reads the real file, so a file
|
// Only the web registers sources (native re-reads the real file, so a file
|
||||||
// edited after picking is picked up); tests exercise the registry directly.
|
// edited after picking is picked up); tests exercise the registry directly.
|
||||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||||
pub(crate) fn register_picked_source(path: &str, bytes: Arc<Vec<u8>>) {
|
/// Returns false when the registration REPLACED different bytes under the
|
||||||
picked_sources()
|
/// same key — the browser hands back bare file names, so two distinct files
|
||||||
|
/// named alike collide and every reference to that name would silently read
|
||||||
|
/// the newer pick. Callers surface that to the user.
|
||||||
|
pub(crate) fn register_picked_source(path: &str, bytes: Arc<Vec<u8>>) -> bool {
|
||||||
|
let mut cache = picked_sources()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
let replaced = cache
|
||||||
|
.get(path)
|
||||||
|
.is_some_and(|old| old.as_slice() != bytes.as_slice());
|
||||||
|
if replaced {
|
||||||
|
take_last_collision().replace(file_name_of(path));
|
||||||
|
}
|
||||||
|
cache.insert(path.to_string(), bytes);
|
||||||
|
!replaced
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name whose re-pick was refused because different bytes are already
|
||||||
|
/// registered under it — the browse handler reports and clears it.
|
||||||
|
fn last_collision() -> &'static Mutex<Option<String>> {
|
||||||
|
static LAST: OnceLock<Mutex<Option<String>>> = OnceLock::new();
|
||||||
|
LAST.get_or_init(|| Mutex::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_last_collision() -> std::sync::MutexGuard<'static, Option<String>> {
|
||||||
|
last_collision()
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|error| error.into_inner())
|
.unwrap_or_else(|error| error.into_inner())
|
||||||
.insert(path.to_string(), bytes);
|
}
|
||||||
|
|
||||||
|
fn file_name_of(path: &str) -> String {
|
||||||
|
std::path::Path::new(path)
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| path.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bytes registered for this reference — exact key first, then any entry that
|
/// Bytes registered for this reference — exact key first, then any entry that
|
||||||
@@ -1283,7 +1314,15 @@ impl OpenCADStudio {
|
|||||||
// keep its bytes so preview, save, and later re-opens read
|
// keep its bytes so preview, save, and later re-opens read
|
||||||
// them instead of asking for the picker again.
|
// them instead of asking for the picker again.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
register_picked_source(&path.to_string_lossy(), Arc::new(handle.read().await));
|
{
|
||||||
|
let fresh = register_picked_source(
|
||||||
|
&path.to_string_lossy(),
|
||||||
|
Arc::new(handle.read().await),
|
||||||
|
);
|
||||||
|
if !fresh {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(path)
|
Some(path)
|
||||||
},
|
},
|
||||||
Message::DataLinkBrowseResult,
|
Message::DataLinkBrowseResult,
|
||||||
@@ -1291,6 +1330,15 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn on_data_link_browse_result(&mut self, path: Option<PathBuf>) -> Task<Message> {
|
pub(super) fn on_data_link_browse_result(&mut self, path: Option<PathBuf>) -> Task<Message> {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
if let Some(name) = take_last_collision().take() {
|
||||||
|
self.command_line.push_warning(
|
||||||
|
crate::tf!(
|
||||||
|
"\"{name}\" is already loaded from another folder with different contents; re-picking it was ignored to keep the existing link."
|
||||||
|
)
|
||||||
|
.as_ref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
if let Some(path) = path {
|
if let Some(path) = path {
|
||||||
self.data_link_manager.path = path.to_string_lossy().into_owned();
|
self.data_link_manager.path = path.to_string_lossy().into_owned();
|
||||||
let (sheets, named_ranges) = workbook_metadata(&path).unwrap_or_default();
|
let (sheets, named_ranges) = workbook_metadata(&path).unwrap_or_default();
|
||||||
@@ -1780,7 +1828,13 @@ impl OpenCADStudio {
|
|||||||
// that follows this message.
|
// that follows this message.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
if !save {
|
if !save {
|
||||||
register_picked_source(&path.to_string_lossy(), Arc::new(handle.read().await));
|
let fresh = register_picked_source(
|
||||||
|
&path.to_string_lossy(),
|
||||||
|
Arc::new(handle.read().await),
|
||||||
|
);
|
||||||
|
if !fresh {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some(path)
|
Some(path)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -585,6 +585,14 @@ impl OpenCADStudio {
|
|||||||
.unwrap_or_else(|| format!("{stem}.{ext}"));
|
.unwrap_or_else(|| format!("{stem}.{ext}"));
|
||||||
let version = self.tabs[i].scene.document.version;
|
let version = self.tabs[i].scene.document.version;
|
||||||
|
|
||||||
|
// The same save preparation a web Save performs — without it the
|
||||||
|
// archived copy ships kernel solids without their ACIS data and a
|
||||||
|
// staler view/sysvar header than a real save would produce.
|
||||||
|
self.sync_view_state_for_save(i);
|
||||||
|
crate::app::update::util::sync_annotation_scale_header(&mut self.tabs[i].scene);
|
||||||
|
self.stamp_header_sysvars(i);
|
||||||
|
self.sync_solid_models_for_save(i);
|
||||||
|
self.tabs[i].scene.sync_native_parametric_graph();
|
||||||
// The drawing itself: the same serialization a web Save performs
|
// The drawing itself: the same serialization a web Save performs
|
||||||
// (native copies the last-saved file; the web build has none, so the
|
// (native copies the last-saved file; the web build has none, so the
|
||||||
// current document is re-serialized).
|
// current document is re-serialized).
|
||||||
|
|||||||
@@ -671,7 +671,7 @@ impl OpenCADStudio {
|
|||||||
/// have no ACIS data before saving. A solid that has ACIS data keeps its
|
/// have no ACIS data before saving. A solid that has ACIS data keeps its
|
||||||
/// own wireframe cache: display edges are not written into a solid OCS
|
/// own wireframe cache: display edges are not written into a solid OCS
|
||||||
/// did not create or edit (those get their wires when they are built).
|
/// did not create or edit (those get their wires when they are built).
|
||||||
fn sync_solid_models_for_save(&mut self, i: usize) {
|
pub(in crate::app) fn sync_solid_models_for_save(&mut self, i: usize) {
|
||||||
use codec::EntityType;
|
use codec::EntityType;
|
||||||
let scene = &mut self.tabs[i].scene;
|
let scene = &mut self.tabs[i].scene;
|
||||||
let targets: Vec<(codec::Handle, bool)> = scene
|
let targets: Vec<(codec::Handle, bool)> = scene
|
||||||
@@ -2460,7 +2460,7 @@ impl OpenCADStudio {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_view_state_for_save(&mut self, i: usize) {
|
pub(in crate::app) fn sync_view_state_for_save(&mut self, i: usize) {
|
||||||
self.sync_vport_display(i);
|
self.sync_vport_display(i);
|
||||||
if self.tabs[i].active_block_edit.is_none() {
|
if self.tabs[i].active_block_edit.is_none() {
|
||||||
self.tabs[i].scene.sync_camera_to_document();
|
self.tabs[i].scene.sync_camera_to_document();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::scene::Scene;
|
|||||||
/// Returns (1.0, 1.0) for "Fit" or unknown formats.
|
/// Returns (1.0, 1.0) for "Fit" or unknown formats.
|
||||||
/// Sync the model-space annotation scale into its named drawing variable and
|
/// Sync the model-space annotation scale into its named drawing variable and
|
||||||
/// numeric header mirror before a save.
|
/// numeric header mirror before a save.
|
||||||
pub(super) fn sync_annotation_scale_header(scene: &mut Scene) {
|
pub(in crate::app) fn sync_annotation_scale_header(scene: &mut Scene) {
|
||||||
let anno = scene.annotation_scale;
|
let anno = scene.annotation_scale;
|
||||||
let value = if anno.abs() > 1e-9 {
|
let value = if anno.abs() > 1e-9 {
|
||||||
1.0 / anno as f64
|
1.0 / anno as f64
|
||||||
|
|||||||
@@ -693,6 +693,10 @@ async fn open_recent_web_attempt(
|
|||||||
bytes.len(),
|
bytes.len(),
|
||||||
);
|
);
|
||||||
let result = load_web_bytes(&name, &bytes, progress, recovery_mode, &initial_error, None).await;
|
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
|
let keep_for_recovery = result
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.err()
|
.err()
|
||||||
|
|||||||
+40
-8
@@ -6,6 +6,7 @@ use codec::tables::TableEntry;
|
|||||||
use codec::types::{Handle, Vector3};
|
use codec::types::{Handle, Vector3};
|
||||||
use codec::{CadDocument, EntityType};
|
use codec::{CadDocument, EntityType};
|
||||||
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
|
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
|
||||||
|
use std::collections::VecDeque;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
@@ -51,7 +52,18 @@ pub fn unresolved_xref_names(infos: &[XrefInfo]) -> Vec<String> {
|
|||||||
infos
|
infos
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|info| info.status == XrefStatus::NotFound)
|
.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()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,9 +101,16 @@ pub(crate) fn same_file_name(a: &str, b: &str) -> bool {
|
|||||||
!a.is_empty() && a == b
|
!a.is_empty() && a == b
|
||||||
}
|
}
|
||||||
|
|
||||||
fn source_cache() -> &'static Mutex<HashMap<String, Arc<Vec<u8>>>> {
|
/// Upper bound on registered drawings. Every open, drop, and XATTACH pick
|
||||||
static SOURCES: OnceLock<Mutex<HashMap<String, Arc<Vec<u8>>>>> = OnceLock::new();
|
/// registers its bytes; without a cap a day of opening large drawings grows
|
||||||
SOURCES.get_or_init(|| Mutex::new(HashMap::default()))
|
/// 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>> {
|
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.
|
/// finally supplies a missing reference) reloads from the new bytes.
|
||||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // mirrors image_model::register_source
|
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // mirrors image_model::register_source
|
||||||
pub fn register_source(name: &str, bytes: Arc<Vec<u8>>) {
|
pub fn register_source(name: &str, bytes: Arc<Vec<u8>>) {
|
||||||
source_cache()
|
{
|
||||||
.lock()
|
let mut cache = source_cache()
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
.lock()
|
||||||
.insert(name.to_string(), bytes);
|
.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()
|
parsed_cache()
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
@@ -133,6 +163,7 @@ pub(crate) fn find_registered_source(raw: &str) -> Option<String> {
|
|||||||
source_cache()
|
source_cache()
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.0
|
||||||
.keys()
|
.keys()
|
||||||
.find(|key| same_file_name(key, raw))
|
.find(|key| same_file_name(key, raw))
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -144,6 +175,7 @@ pub(crate) fn registered_bytes(key: &str) -> Option<Arc<Vec<u8>>> {
|
|||||||
source_cache()
|
source_cache()
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.0
|
||||||
.get(key)
|
.get(key)
|
||||||
.cloned()
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user