Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions lp-app/lpa-fs-opfs/src/opfs_write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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))
Expand All @@ -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))?;
Expand Down
27 changes: 24 additions & 3 deletions lp-app/lpa-studio-core/src/app/library/package_meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,17 @@ pub fn read_meta(fs: &dyn LpFs) -> Result<Option<PackageMeta>, 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> {
Expand Down Expand Up @@ -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());
}
}
Loading