From d7b5ac509a519c4cb6d8a01cf9e2ba8c25543248 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Thu, 16 Jul 2026 19:56:25 -0700 Subject: [PATCH] fix(studio): survive damaged package meta; clean up OPFS write husks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The studio bricked on load in iOS Safari with "missing session: library: meta: parse meta: expected value at line 1 column 1": a damaged (zero- filled) /.lp/meta.json. The history event log tolerates such damage (it reads as empty), which routes PackageHandle::load into the strict read_meta origin fallback — one unparseable provenance sidecar then permanently fails every project open. Two fixes: - read_meta parses leniently: damage logs a warning and reads as absent; origin_event_for(None) already falls back to a Created origin. - opfs write_file removes the empty husk it created when the stage-and-swap fails on a file that did not previously exist. The createWritable atomic swap only protects content — getFileHandle (create: true) makes the entry immediately, so a tab jetsammed mid-flush (common on iOS) persisted a lie. Verified: lpa-studio-core suite (453 tests), lpa-fs-opfs real-browser OPFS suite (18 tests via chromedriver), wasm32 check, just check. Co-Authored-By: Claude Fable 5 --- lp-app/lpa-fs-opfs/src/opfs_write.rs | 22 +++++++++++++++ .../src/app/library/package_meta.rs | 27 ++++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/lp-app/lpa-fs-opfs/src/opfs_write.rs b/lp-app/lpa-fs-opfs/src/opfs_write.rs index 8a537ce1f..ac13898db 100644 --- a/lp-app/lpa-fs-opfs/src/opfs_write.rs +++ b/lp-app/lpa-fs-opfs/src/opfs_write.rs @@ -25,6 +25,12 @@ fn split_parent(path: &LpPath) -> (String, String) { /// Write `bytes` to `path` (absolute lp-style) below `root`, creating parent /// directories as needed. Atomic per file (commit happens at close). +/// +/// The atomic swap only protects *content* — `getFileHandle(create)` makes +/// the entry itself immediately. A write that fails after creation would +/// otherwise leave a persistent empty husk (seen on iOS Safari, where a +/// jetsammed tab can abandon a flush mid-file), so a failed write to a file +/// that did not previously exist removes the husk best-effort. pub async fn write_file( root: &FileSystemDirectoryHandle, path: &LpPath, @@ -33,6 +39,8 @@ pub async fn write_file( let (parent_path, name) = split_parent(path); let parent = open_dir(root, &parent_path, true).await?; + let existed = JsFuture::from(parent.get_file_handle(&name)).await.is_ok(); + let options = FileSystemGetFileOptions::new(); options.set_create(true); let handle = JsFuture::from(parent.get_file_handle_with_options(&name, &options)) @@ -42,6 +50,20 @@ pub async fn write_file( .dyn_into() .map_err(|e| OpfsError::new("get_file_handle", path.as_str().to_string(), e))?; + let result = write_via_writable(&handle, path, bytes).await; + if result.is_err() && !existed { + let _ = JsFuture::from(parent.remove_entry(&name)).await; + } + result +} + +/// The stage-and-swap half of [`write_file`]: create a writable, write, +/// close (the commit point). +async fn write_via_writable( + handle: &FileSystemFileHandle, + path: &LpPath, + bytes: &[u8], +) -> Result<(), OpfsError> { let stream = JsFuture::from(handle.create_writable()) .await .map_err(|e| OpfsError::new("create_writable", path.as_str().to_string(), e))?; diff --git a/lp-app/lpa-studio-core/src/app/library/package_meta.rs b/lp-app/lpa-studio-core/src/app/library/package_meta.rs index 83331ac1a..62e9bfa2f 100644 --- a/lp-app/lpa-studio-core/src/app/library/package_meta.rs +++ b/lp-app/lpa-studio-core/src/app/library/package_meta.rs @@ -54,9 +54,17 @@ pub fn read_meta(fs: &dyn LpFs) -> Result, LibraryError> { let bytes = fs .read_file(META_PATH.as_path()) .map_err(|e| LibraryError::Meta(format!("read meta: {e}")))?; - serde_json::from_slice(&bytes) - .map(Some) - .map_err(|e| LibraryError::Meta(format!("parse meta: {e}"))) + // Lenient parse: provenance is a best-effort sidecar, and browser + // storage can leave it torn or zero-filled (an iOS Safari tab killed + // mid-flush). Treating damage as "absent" keeps it from bricking + // project open — the origin event falls back to `Created`. + match serde_json::from_slice(&bytes) { + Ok(meta) => Ok(Some(meta)), + Err(e) => { + log::warn!("package meta unreadable (treating as absent): parse meta: {e}"); + Ok(None) + } + } } pub fn write_meta(fs: &dyn LpFs, meta: &PackageMeta) -> Result<(), LibraryError> { @@ -84,4 +92,17 @@ mod tests { write_meta(&fs, &meta).unwrap(); assert_eq!(read_meta(&fs).unwrap().unwrap(), meta); } + + #[test] + fn damaged_meta_reads_as_absent() { + use lpc_model::AsLpPath; + let fs = LpFsMemory::new(); + // Zero-filled sidecar: the signature of a browser tab killed + // mid-flush (size persisted, content lost). + fs.write_file(META_PATH.as_path(), &[0u8; 64]).unwrap(); + assert!(read_meta(&fs).unwrap().is_none()); + // Empty husk (created, never committed). + fs.write_file(META_PATH.as_path(), &[]).unwrap(); + assert!(read_meta(&fs).unwrap().is_none()); + } }