From 590d626ea339117578f8a2f143c34172e88ba76c Mon Sep 17 00:00:00 2001 From: suhasagg Date: Sat, 10 Oct 2026 21:25:41 +0530 Subject: [PATCH] fix(snapshot): serialize concurrent OCI index updates Signed-off-by: suhasagg --- Cargo.lock | 11 ++++ src/hyperlight_host/Cargo.toml | 1 + .../src/sandbox/snapshot/file/mod.rs | 26 +++++++-- .../src/sandbox/snapshot/file_tests.rs | 56 +++++++++++++++++++ 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7b78932f0..f5ebb9b4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1143,6 +1143,16 @@ dependencies = [ "pe-unwind-info", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -1765,6 +1775,7 @@ dependencies = [ "fallible-iterator", "flatbuffers", "framehop", + "fs2", "gdbstub", "gdbstub_arch", "goblin", diff --git a/src/hyperlight_host/Cargo.toml b/src/hyperlight_host/Cargo.toml index 0d990979e..cc82dfed3 100644 --- a/src/hyperlight_host/Cargo.toml +++ b/src/hyperlight_host/Cargo.toml @@ -21,6 +21,7 @@ bench = false # see https://bheisler.github.io/criterion.rs/book/faq.html#cargo- workspace = true [dependencies] +fs2 = "0.4" gdbstub = { version = "0.7.10", optional = true } gdbstub_arch = { version = "0.3.3", optional = true } goblin = { version = "0.10", default-features = false, features = ["std", "elf32", "elf64", "endian_fd"] } diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs b/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs index b336907f5..533b42203 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs @@ -11,8 +11,11 @@ mod media_types; pub(crate) mod reference; mod transport; +use std::fs::OpenOptions; use std::path::{Path, PathBuf}; +use fs2::FileExt; + use hyperlight_common::flatbuffer_wrappers::host_function_details::HostFunctionDetails; use hyperlight_common::vmem::PAGE_SIZE; use oci_spec::image::{ @@ -397,10 +400,25 @@ impl Snapshot { } } - // Validate any pre-existing `oci-layout` marker before - // touching anything else, so a foreign layout (future - // version, hand-edited file) is reported without altering - // the directory. + // Serialize layout validation and the index read-modify-write + // transaction across cooperating processes. + // Use a stable lock file, not index.json, which is atomically replaced. + let lock_path = path.join(".hyperlight-index.lock"); + let lock_file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(&lock_path) + .map_err(|e| { + crate::new_error!("save: failed to open index lock {:?}: {}", lock_path, e) + })?; + lock_file.lock_exclusive().map_err(|e| { + crate::new_error!("save: failed to acquire index lock {:?}: {}", lock_path, e) + })?; + // File lock is released when lock_file is dropped on all exit paths. + + // Validate the existing marker while holding the lock. + // Invalid layouts are rejected before writing snapshot data. let layout_marker = path.join("oci-layout"); let marker_existed = layout_marker .try_exists() diff --git a/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs index d36b4b277..c4174e95c 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs @@ -189,6 +189,62 @@ fn snapshot_metadata_namespaces_are_independent() { // Round-trip via OCI layout on disk. +#[test] +fn concurrent_snapshot_saves_preserve_all_tags() { + use std::collections::HashSet; + use std::sync::Barrier; + use std::thread; + + const WRITERS: usize = 8; + + let snapshot = create_snapshot(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("concurrent"); + let barrier = Arc::new(Barrier::new(WRITERS)); + + let handles: Vec<_> = (0..WRITERS) + .map(|i| { + let snapshot = Arc::clone(&snapshot); + let barrier = Arc::clone(&barrier); + let path = path.clone(); + + thread::spawn(move || { + let tag = format!("writer-{i}"); + barrier.wait(); + snapshot.save(&path, &OciTag::new(&tag).unwrap()).unwrap(); + }) + }) + .collect(); + + for handle in handles { + handle.join().unwrap(); + } + + let index: Value = + serde_json::from_slice(&std::fs::read(path.join("index.json")).unwrap()).unwrap(); + + let tags: HashSet = index["manifests"] + .as_array() + .unwrap() + .iter() + .map(|manifest| { + manifest["annotations"]["org.opencontainers.image.ref.name"] + .as_str() + .unwrap() + .to_owned() + }) + .collect(); + + assert_eq!(tags.len(), WRITERS); + + for i in 0..WRITERS { + assert!( + tags.contains(&format!("writer-{i}")), + "missing snapshot tag writer-{i}" + ); + } +} + #[test] fn round_trip_save_load_call() { let snapshot = create_snapshot();