clawhdf5: FileEditor unmaps the file before an edit writes it

Each edit planned over the reader's memory map of the file and committed
while that File, and the Image's &[u8] over the mapping, were still alive,
writing the same file through the editor's descriptor. Nothing read the
mapping during the writes, but a shared slice whose memory changes
underneath it is undefined behaviour under Rust's aliasing rules.

Image::into_plan now detaches the edit's writes (patches, end of
allocation) into a Plan that owns all of its bytes and borrows nothing;
edit() takes the user-block size, drops the File — unmapping the file —
and only then commits the Plan. The invariant is documented in the image
module and the editor's module docs.

Test: edit::tests::file_is_not_mapped_while_an_edit_writes_it checks
/proc/self/maps at the moment each commit starts (write, resize, set_attr):
never mapped. With the commit moved back before the reader is dropped (the
previous order) it reports all three commits with the file mapped.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 14:14:58 -05:00
co-authored by Claude Opus 5.5
parent b668878129
commit fe377266e1
2 changed files with 109 additions and 5 deletions
+30 -2
View File
@@ -4,9 +4,17 @@
//! An edit never writes to the file while it is being planned. Every change
//! is recorded here first (reads see them), so an edit that fails half-way —
//! a filter that cannot encode, a chunk index this code does not handle —
//! leaves the file exactly as it was. [`Image::commit`] then writes the
//! changes in an order that keeps the old metadata valid for as long as
//! leaves the file exactly as it was. [`Image::into_plan`] then detaches the
//! changes from the bytes they were planned over, and [`Plan::commit`]
//! writes them in an order that keeps the old metadata valid for as long as
//! possible (see there).
//!
//! **Invariant:** the base bytes an image reads are the reader's view of the
//! file — a memory map when the `mmap` feature is on. Nothing may write the
//! file while that view is alive: a write through another descriptor would
//! change memory behind a live `&[u8]`, which Rust's aliasing rules forbid.
//! So a [`Plan`] owns everything it writes and borrows nothing, and the
//! editor drops the reader (unmapping the file) before it commits.
use std::collections::BTreeMap;
use std::io::{Seek, SeekFrom, Write};
@@ -178,6 +186,26 @@ impl<'a> Image<'a> {
Ok(())
}
/// The edit's writes, detached from the base bytes (see the module's
/// invariant: the reader that owns them can then be dropped before
/// anything is written).
pub(crate) fn into_plan(self) -> Plan {
Plan {
patches: self.patches,
eoa: self.eoa,
old_eoa: self.old_eoa,
}
}
}
/// The writes of a planned edit, owning all of their bytes.
pub(crate) struct Plan {
patches: BTreeMap<u64, Vec<u8>>,
eoa: u64,
old_eoa: u64,
}
impl Plan {
/// Write the edit to `file`, whose superblock is at `user_block`.
///
/// Order: first everything in newly allocated space (new chunks, new
+79 -3
View File
@@ -10,7 +10,10 @@
//! Each operation is planned in memory first ([`image::Image`]): if any part
//! of it is unsupported, nothing is written. The plan is then committed with
//! the new space (new chunks, new index blocks) written and synced before
//! the existing bytes that link it in, then synced again.
//! the existing bytes that link it in, then synced again. The plan owns the
//! bytes it writes; the reader it was planned with (a memory map of the
//! file) is dropped before the first write, so no `&[u8]` over the mapping
//! is alive while the file changes (see `image`).
mod btree1;
mod earray;
@@ -614,6 +617,12 @@ impl FileEditor {
&self.path
}
/// Plan an edit over the file's current bytes, then commit it.
///
/// The reader (a memory map of the file, with the `mmap` feature) is
/// dropped before anything is written: the plan owns every byte it
/// writes, so no slice over the mapping is alive while the file changes
/// underneath it (see `image`'s invariant).
fn edit<R>(
&mut self,
op: impl FnOnce(&File, &mut Image<'_>) -> Result<R, Error>,
@@ -621,13 +630,22 @@ impl FileEditor {
let f = File::open(&self.path)?;
check_editable(&f)?;
let sb = f.superblock().clone();
let user_block = f.user_block_size();
let mut img = Image::new(f.as_bytes(), sb.offset_size, sb.length_size);
let r = op(&f, &mut img).map_err(unsupported_filter)?;
if img.is_dirty() {
let plan = if img.is_dirty() {
if img.eoa() != img.old_eoa() {
set_superblock_eof(&mut img, &sb)?;
}
img.commit(&mut self.file, f.user_block_size())?;
Some(img.into_plan())
} else {
None
};
drop(f);
if let Some(plan) = plan {
#[cfg(test)]
tests::note_commit(&self.path);
plan.commit(&mut self.file, user_block)?;
}
Ok(r)
}
@@ -1263,3 +1281,61 @@ fn decode_chunk(
}
Ok(out)
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use std::path::Path;
use super::FileEditor;
use crate::{AttrValue, FileBuilder};
thread_local! {
/// Commits seen on this thread, and how many found the file mapped.
static COMMITS: Cell<(usize, usize)> = const { Cell::new((0, 0)) };
}
/// Called just before an edit writes the file: whether this process
/// still maps it (`/proc/self/maps` lists every mapping by path).
pub(super) fn note_commit(path: &Path) {
let path = std::fs::canonicalize(path).unwrap();
let maps = std::fs::read_to_string("/proc/self/maps").unwrap_or_default();
let mapped = maps
.lines()
.any(|l| l.ends_with(&format!(" {}", path.display())));
COMMITS.with(|c| {
let (n, m) = c.get();
c.set((n + 1, m + usize::from(mapped)));
});
}
/// No edit writes the file while the reader's memory map of it (and so
/// a `&[u8]` over it) is alive.
#[test]
#[cfg(all(target_os = "linux", feature = "mmap"))]
fn file_is_not_mapped_while_an_edit_writes_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mapped.h5");
let mut b = FileBuilder::new();
b.create_dataset("x")
.with_i32_data(&[1, 2, 3, 4])
.with_shape(&[4])
.with_maxshape(&[u64::MAX])
.with_chunks(&[2])
.with_deflate(4);
b.write(&path).unwrap();
let mut ed = FileEditor::open(&path).unwrap();
ed.write_values("x", &crate::Selection::All, &[5i32, 6, 7, 8])
.unwrap();
ed.resize("x", &[6]).unwrap();
ed.set_attr("x", "a", &AttrValue::I64(1)).unwrap();
drop(ed);
let (commits, mapped) = COMMITS.with(Cell::get);
assert_eq!((commits, mapped), (3, 0));
let f = crate::File::open(&path).unwrap();
assert_eq!(
f.dataset("x").unwrap().read_i32().unwrap(),
[5, 6, 7, 8, 0, 0]
);
}
}