Skip to content
Draft
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
23 changes: 0 additions & 23 deletions issues/a-read-and-a-write-of-a-paged-file-can-tear-across-pages.md

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
status: open
kind: defect
opened: 2026-11-01
---

# A carried file that is refused costs every other file's fsync, or leaks its blocks

An fsync on DATA commits its own file and every file the commit carries
(`Volume::carried`): one whose entry was written, or whose reserve took
blocks, since the last commit (`system/fileserver/src/pages.rs`,
`Files::fsync`; `system/fileserver/src/data.rs`, `DataVolume::commit`). A
refusal on a carried file is paid by the files beside it:

- **Its pages refused.** `Files::fsync` refuses the commit when a carried
file's write-back is refused, since its entry would name blocks without
its bytes. While that write-back stays refused, every fsync of every
file on the volume is refused with it.
- **Its entry refused.** `DataVolume::commit` says a carried entry whose
write was refused, and commits anyway. Where the file took blocks since
the last commit, the commit makes the allocator's take durable with no
entry naming them, and after a stop those blocks are leaked: not readable
by any file, and not free. This is not a disclosure.

A file carried only by blocks taken (its entry unwritten since the last
commit) could be left out of the commit at the cost of that leak, rather
than refusing everyone's fsync.

Found by review of #1141; no test drives either refusal into another file's
fsync or a stop.

**Exit**: a refusal on one carried file costs only that file: an fsync of
another lands whole, and after a stop the volume holds as free every block
no entry names. A host test refuses one carried file's write-back, and
another's entry, fsyncs a third, stops, and counts the free blocks.

**Owner**: the storage track (`issues/storage-is-layers-and-a-role-is-a-filesystem.md`).
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
status: open
kind: defect
opened: 2026-11-01
---

# A sync commits the entry of a file whose pages the volume refused

`Files::sync` (`system/fileserver/src/pages.rs`) writes every dirty file's
pages back and then calls the volume's `sync` even where a file's write-back
was refused. DATA's `sync` writes every open file's entry, that file's
among them, so the commit makes durable an entry whose new extents name
blocks that never received its pages. In the same boot the file still reads
right: its pages stay dirty in its object and the next sync tries them
again. After a stop before that, it reads what those blocks last held,
which may be a deleted file's bytes
(`issues/a-reallocated-extent-on-data-keeps-the-deleted-files-bytes.md`).

An fsync does not: a carried file whose pages are refused refuses the
commit (`Files::fsync`).

Found by reading, while making an fsync commit one file; no test drives a
refused write-back into a stop.

**Exit**: a sync's commit carries no entry of a file whose pages were
refused, with a host test that refuses one file's write-back, stops the
volume after the sync and reads that file as its last commit left it.

**Owner**: the storage track (`issues/storage-is-layers-and-a-role-is-a-filesystem.md`).
12 changes: 9 additions & 3 deletions issues/an-fsync-of-a-served-file-syncs-its-whole-volume.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ opened: 2026-09-27

# An fsync of a served file syncs its whole volume

The file server answers `FSYNC` by syncing the volume, every open file's
**Built, unmeasured.** `FSYNC` now commits its own file and what the
volume's commit carries (`Files::fsync`, `Volume::carried`), and no longer
syncs the volume. What follows is how it was.

The file server answered `FSYNC` by syncing the volume, every open file's
entry and every dirty block of the cache (`system/fileserver/src/main.rs`,
`FSYNC`, which calls `sync` with the file's node and syncs the whole volume). So an
`fsync` or `File::sync_all` of one file on `/home` syncs all of DATA, and a
`FSYNC`, which called `sync` with the file's node and synced the whole volume). So an
`fsync` or `File::sync_all` of one file on `/home` synced all of DATA, and a
4 KiB create and fsync cost 3.5–3.6 ms p50 on QEMU TCG against the kernel's
1.0–1.3 ms, measured on the branch that moved DATA to the file server.

Expand All @@ -20,3 +24,5 @@ for no durability.

**Exit**: `FSYNC` makes durable the file it names and what that file's entry
depends on, with a guest measurement of one file's fsync beside the volume's.
What is owed is that measurement: osbench's
`persist.write_4k_fsync_beside_1m`, base against head on metal.
99 changes: 97 additions & 2 deletions kernel/pure/paged.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
//! it in the same step, so the size never covers bytes not yet in the object;
//! a shrink removes every page past it, dirty or not.
//!
//! **A holder's read is atomic against every write** (POSIX XSH 2.9.7): it
//! begins only while no write is under way ([`Object::read_begin`]), and its
//! bytes count only where no write has begun since ([`Object::unchanged`]).
//! A write is one call, or, where a call does not take all it was given, every
//! call until [`Object::end_write`]: the pager's whole write.
//!
//! What a removed frame owes (the last pin's free, a zeroed tail) is the
//! caller's: every removal answers its frames.

Expand Down Expand Up @@ -204,15 +210,25 @@ struct Slot<F> {
}

/// One object's part of the table: its size, whether its pager ended, the
/// list of its slots.
/// list of its slots, and its writes under way.
pub struct Object {
id: u32,
size: u64,
gone: bool,
head: u32,
pages: usize,
/// Moved at every write's begin and end, and at every resize.
version: u64,
/// Writes under way: each call's, and the held one.
writing: u32,
/// A call that did not take all it was given left its write under way.
held: bool,
}

/// What a holder's read began at ([`Object::read_begin`]).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Version(u64);

impl Object {
pub fn size(&self) -> u64 {
self.size
Expand All @@ -230,6 +246,43 @@ impl Object {
fn page_end(&self) -> u64 {
self.size.div_ceil(PAGE)
}

/// Where a holder's read begins: `None` while a write is under way.
pub fn read_begin(&self) -> Option<Version> {
(self.writing == 0).then_some(Version(self.version))
}

/// Whether no write or resize has begun since the read began at `v`.
pub fn unchanged(&self, v: Version) -> bool {
self.version == v.0
}

/// A write call begins, before its first byte lands.
pub fn write_begin(&mut self) {
self.writing = self.writing.checked_add(1).expect("an object's writes overflowed");
self.version += 1;
}

/// A write call ends, after its last byte landed. One that did not take
/// all it was given leaves the write under way until
/// [`end_write`](Self::end_write), unless an earlier call's already is.
pub fn write_end(&mut self, whole: bool) {
if !whole && !self.held {
self.held = true;
return;
}
self.writing = self.writing.checked_sub(1).expect("a write ended that never began");
self.version += 1;
}

/// The pager ends the write a short call left under way.
pub fn end_write(&mut self) -> Result<(), Refused> {
if !core::mem::take(&mut self.held) {
return Err(Refused::InvalidArgument);
}
self.write_end(true);
Ok(())
}
}

/// A pager's table: `capacity` slots and a hash of them, both made here.
Expand Down Expand Up @@ -307,7 +360,7 @@ impl<F: Copy> Pages<F> {
/// [`release`](Self::release) takes every page it held.
pub fn object(&mut self, id: u32, size: u64) -> Object {
assert!(size <= LAST_END, "an object of {size} bytes past the last page");
Object { id, size, gone: false, head: NONE, pages: 0 }
Object { id, size, gone: false, head: NONE, pages: 0, version: 0, writing: 0, held: false }
}

fn hash(&self, object: u32, page: u64) -> usize {
Expand Down Expand Up @@ -617,6 +670,7 @@ impl<F: Copy> Pages<F> {
/// answers the frame of a partial last page, whose tail past the size the
/// caller zeroes.
pub fn resize(&mut self, o: &mut Object, size: u64, out: &mut impl FnMut(F)) -> Option<F> {
o.version += 1;
let keep = size.div_ceil(PAGE);
if size < o.size {
let mut at = o.head;
Expand Down Expand Up @@ -677,6 +731,47 @@ mod tests {
|f| freed.push(f)
}

/// XSH 2.9.7: a read that began before a write, or while one was under
/// way, never counts; one that began after it does.
#[test]
fn a_read_counts_only_where_no_write_began_or_was_under_way() {
let mut p = Pages::<u64>::new(8, 4).unwrap();
let mut o = p.object(1, PAGE);
let before = o.read_begin().expect("nothing writes yet");
o.write_begin();
assert_eq!(o.read_begin(), None, "a read meets the write in flight");
assert!(!o.unchanged(before), "a read that began before it meets it");
o.write_end(true);
assert!(!o.unchanged(before), "and still does once it ended");
let after = o.read_begin().expect("the write ended");
assert!(o.unchanged(after));
p.resize(&mut o, 0, &mut none);
assert!(!o.unchanged(after), "a shrink is a change a read meets");
}

/// A pager's write that takes several calls is one write: the first short
/// call leaves it under way, later ones nest in it, and only its end lets
/// a read begin.
#[test]
fn a_short_call_holds_the_write_until_its_end() {
let mut p = Pages::<u64>::new(8, 4).unwrap();
let mut o = p.object(2, PAGE);
assert_eq!(o.end_write(), Err(Refused::InvalidArgument), "nothing is held");
o.write_begin();
o.write_end(false);
assert_eq!(o.read_begin(), None, "a short call's write is under way");
o.write_begin();
o.write_end(false);
o.write_begin();
o.write_end(true);
assert_eq!(o.read_begin(), None, "a later call, short or whole, ends no held write");
let held = o.version;
assert_eq!(o.end_write(), Ok(()));
let v = o.read_begin().expect("the end lets a read begin");
assert!(o.unchanged(v) && v != Version(held), "the end moved the version");
assert_eq!(o.end_write(), Err(Refused::InvalidArgument), "a write ends once");
}

#[test]
fn a_supplied_page_is_found_and_a_second_supply_changes_nothing() {
let mut p = Pages::new(8, 4).unwrap();
Expand Down
Loading