From 7a8e0354f561af19fadd195bc96554ecd0eee4a9 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Thu, 16 Jul 2026 20:48:13 -0700 Subject: [PATCH] =?UTF-8?q?fix(lpa-fs-opfs):=20copy=20bytes=20before=20OPF?= =?UTF-8?q?S=20write=20=E2=80=94=20WebKit=20writes=20whole=20buffer=20for?= =?UTF-8?q?=20wasm=20views?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the iOS Safari studio crash ("A problem repeatedly occurred") and of every zero-filled library file: WebKit's FileSystemWritableFileStream.write() ignores a TypedArray view's offset/length and writes the view's ENTIRE underlying buffer (verified on WebKit 26.5; Chrome honors the view). web-sys write_with_u8_array passes exactly such a view over wasm linear memory, so on WebKit every flushed file received the whole wasm heap: contents start at heap byte 0 (zeros -> "expected value at line 1 column 1"), and seeding a fresh library serialized hundreds of MB per file -> memory spike -> iOS jetsam -> crash loop on a fresh visit. Reproduced against production with Playwright WebKit (iPhone profile) and pinned with a pure-JS round-trip probe (1000-byte wasm-memory view -> 65536-byte file even without memory growth). Fixes: - write_file copies into a JS-owned Uint8Array before write(); the copy's buffer is exactly the payload, so either read interpretation persists the right bytes. Verified byte-exact on WebKit end-to-end (seed -> flush -> read back, all files parse, project reopens). - load_tree skips files over 16 MiB (no legitimate package member gets close; the only known producer is the pre-fix heap dumps). Without this, libraries already poisoned by the old build OOM the tab again at mount. Verified: a store with 27 x 48 MiB dumps now mounts clean. Chrome OPFS suite: 20 tests green (2 new: exact-length round trip, oversized-file skip). Co-Authored-By: Claude Fable 5 --- lp-app/lpa-fs-opfs/src/opfs_read.rs | 28 +++++++++++++++++--- lp-app/lpa-fs-opfs/src/opfs_write.rs | 9 ++++++- lp-app/lpa-fs-opfs/tests/opfs_ops.rs | 39 ++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/lp-app/lpa-fs-opfs/src/opfs_read.rs b/lp-app/lpa-fs-opfs/src/opfs_read.rs index 59e1da03e..1c70e7d62 100644 --- a/lp-app/lpa-fs-opfs/src/opfs_read.rs +++ b/lp-app/lpa-fs-opfs/src/opfs_read.rs @@ -62,8 +62,13 @@ pub async fn load_tree_filtered( } FileSystemHandleKind::File => { let file_handle: FileSystemFileHandle = handle.unchecked_into(); - let bytes = read_file_bytes(&file_handle, &child_path).await?; - out.push((LpPathBuf::from(child_path.as_str()), bytes)); + match read_file_bytes(&file_handle, &child_path).await? { + Some(bytes) => out.push((LpPathBuf::from(child_path.as_str()), bytes)), + None => log::warn!( + "opfs: skipping implausibly large file at {child_path} \ + (> {MAX_FILE_BYTES} bytes; corrupt-store recovery)" + ), + } } _ => { log::warn!("opfs: unknown handle kind at {child_path}, skipping"); @@ -105,15 +110,30 @@ pub async fn list_child_dirs(dir: &FileSystemDirectoryHandle) -> Result Result, OpfsError> { +/// Refuse to load files no legitimate package member can reach. Library +/// content is device-bound (JSON, GLSL, SVG — kilobytes); the only known +/// way to exceed this is store corruption (pre-fix WebKit wrote the whole +/// wasm heap into every file — see `opfs_write::write_file`). Reading such +/// a file into the memory-primary tree would OOM the tab all over again, +/// so the mount skips it instead. +const MAX_FILE_BYTES: f64 = 16.0 * 1024.0 * 1024.0; + +/// `Ok(None)` when the file exceeds [`MAX_FILE_BYTES`]. +async fn read_file_bytes( + handle: &FileSystemFileHandle, + path: &str, +) -> Result>, OpfsError> { let file = JsFuture::from(handle.get_file()) .await .map_err(|e| OpfsError::new("get_file", path.to_string(), e))?; let file: File = file .dyn_into() .map_err(|e| OpfsError::new("get_file", path.to_string(), e))?; + if file.size() > MAX_FILE_BYTES { + return Ok(None); + } let buffer = JsFuture::from(file.array_buffer()) .await .map_err(|e| OpfsError::new("array_buffer", path.to_string(), e))?; - Ok(Uint8Array::new(&buffer).to_vec()) + Ok(Some(Uint8Array::new(&buffer).to_vec())) } diff --git a/lp-app/lpa-fs-opfs/src/opfs_write.rs b/lp-app/lpa-fs-opfs/src/opfs_write.rs index ac13898db..cececa2b6 100644 --- a/lp-app/lpa-fs-opfs/src/opfs_write.rs +++ b/lp-app/lpa-fs-opfs/src/opfs_write.rs @@ -71,8 +71,15 @@ async fn write_via_writable( .dyn_into() .map_err(|e| OpfsError::new("create_writable", path.as_str().to_string(), e))?; + // Copy into a JS-owned array before writing. `write_with_u8_array` + // passes a Uint8Array VIEW over wasm linear memory, and WebKit's + // `write()` ignores the view's offset/length and writes the entire + // underlying buffer (observed on WebKit 26.5; Chrome honors the view) + // — passing wasm memory directly dumps the whole wasm heap into every + // file. The copy's buffer is exactly `bytes`, so either reading works. + let copy = js_sys::Uint8Array::from(bytes); let write_promise = stream - .write_with_u8_array(bytes) + .write_with_buffer_source(©) .map_err(|e| OpfsError::new("write", path.as_str().to_string(), e))?; JsFuture::from(write_promise) .await diff --git a/lp-app/lpa-fs-opfs/tests/opfs_ops.rs b/lp-app/lpa-fs-opfs/tests/opfs_ops.rs index aefde3330..ee9da466c 100644 --- a/lp-app/lpa-fs-opfs/tests/opfs_ops.rs +++ b/lp-app/lpa-fs-opfs/tests/opfs_ops.rs @@ -114,3 +114,42 @@ async fn remove_missing_errors() { let result = remove_path(&dir, LpPath::new("/nope.txt")).await; assert!(result.is_err()); } + +/// The write must persist exactly the bytes passed — no view-length +/// confusion. Guards the WebKit whole-buffer bug (`write()` on a wasm +/// memory view wrote the entire heap): with the JS-owned copy in +/// `write_file` this size is exact on every engine. +#[wasm_bindgen_test] +async fn write_persists_exact_length() { + let dir = fresh_test_dir("t-exact-length").await; + let payload = vec![0x42u8; 4096]; + write_file(&dir, LpPath::new("/exact.bin"), &payload) + .await + .unwrap(); + let tree = tree_map(load_tree(&dir).await.unwrap()); + assert_eq!(tree["/exact.bin"].len(), payload.len()); + assert_eq!(tree["/exact.bin"], payload); +} + +/// Corrupt-store recovery: a file no legitimate package could contain +/// (pre-fix WebKit wrote the whole wasm heap into every file) is skipped +/// by the mount instead of being loaded into memory. +#[wasm_bindgen_test] +async fn load_tree_skips_implausibly_large_files() { + let dir = fresh_test_dir("t-oversize").await; + write_file(&dir, LpPath::new("/ok.json"), b"{}") + .await + .unwrap(); + // 17 MiB of zeros — just over the 16 MiB cap. + let huge = vec![0u8; 17 * 1024 * 1024]; + write_file(&dir, LpPath::new("/heap-dump.json"), &huge) + .await + .unwrap(); + + let tree = tree_map(load_tree(&dir).await.unwrap()); + assert!(tree.contains_key("/ok.json")); + assert!( + !tree.contains_key("/heap-dump.json"), + "oversized file must be skipped, not loaded" + ); +}