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:
@@ -4,9 +4,17 @@
|
|||||||
//! An edit never writes to the file while it is being planned. Every change
|
//! 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 —
|
//! 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 —
|
//! 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
|
//! leaves the file exactly as it was. [`Image::into_plan`] then detaches the
|
||||||
//! changes in an order that keeps the old metadata valid for as long as
|
//! 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).
|
//! 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::collections::BTreeMap;
|
||||||
use std::io::{Seek, SeekFrom, Write};
|
use std::io::{Seek, SeekFrom, Write};
|
||||||
@@ -178,6 +186,26 @@ impl<'a> Image<'a> {
|
|||||||
Ok(())
|
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`.
|
/// Write the edit to `file`, whose superblock is at `user_block`.
|
||||||
///
|
///
|
||||||
/// Order: first everything in newly allocated space (new chunks, new
|
/// Order: first everything in newly allocated space (new chunks, new
|
||||||
|
|||||||
@@ -10,7 +10,10 @@
|
|||||||
//! Each operation is planned in memory first ([`image::Image`]): if any part
|
//! 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
|
//! 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 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 btree1;
|
||||||
mod earray;
|
mod earray;
|
||||||
@@ -614,6 +617,12 @@ impl FileEditor {
|
|||||||
&self.path
|
&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>(
|
fn edit<R>(
|
||||||
&mut self,
|
&mut self,
|
||||||
op: impl FnOnce(&File, &mut Image<'_>) -> Result<R, Error>,
|
op: impl FnOnce(&File, &mut Image<'_>) -> Result<R, Error>,
|
||||||
@@ -621,13 +630,22 @@ impl FileEditor {
|
|||||||
let f = File::open(&self.path)?;
|
let f = File::open(&self.path)?;
|
||||||
check_editable(&f)?;
|
check_editable(&f)?;
|
||||||
let sb = f.superblock().clone();
|
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 mut img = Image::new(f.as_bytes(), sb.offset_size, sb.length_size);
|
||||||
let r = op(&f, &mut img).map_err(unsupported_filter)?;
|
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() {
|
if img.eoa() != img.old_eoa() {
|
||||||
set_superblock_eof(&mut img, &sb)?;
|
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)
|
Ok(r)
|
||||||
}
|
}
|
||||||
@@ -1263,3 +1281,61 @@ fn decode_chunk(
|
|||||||
}
|
}
|
||||||
Ok(out)
|
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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user