Skip to content

DATA's filesystem commits atomically, a directory rename moves whole or not at all, and a full volume still shrinks - #816

Draft
Japabu wants to merge 8 commits into
mainfrom
wt/toyos-dirrename
Draft

Japabu wants to merge 8 commits into
mainfrom
wt/toyos-dirrename

Conversation

@Japabu

@Japabu Japabu commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

DATA's filesystem commits atomically: every change is one operation that never writes over a node the last commit reaches, a sync is the commit, and a directory rename moves whole or not at all. A full volume still shrinks.

Issue files and #808

issues/a-directory-rename-on-data-is-not-atomic.md is carried verbatim from wt/toyos-pkgrepo (d760336) in the first commit and deleted in the second, so over the branch it is a net no-op on main. Both landing orders need one act on the branch that lands second:

#808 is in the merge queue and had not landed when this round was pushed (gh pr view 808: OPEN, mergedAt null, checked after b028cc8; this round merged main at 8e3a813). The second order is the one expected to apply. What the later merge must do: once #808 is on main, merge main here. #808's copy of the issue matches the one this branch carries, so the merge keeps it without a conflict. That merge must then git rm issues/a-directory-rename-on-data-is-not-atomic.md and delete its citation in issues/a-package-is-a-directory-under-apps-and-the-installer-is-a-program.md: stage 4's "and the commit by rename, which waits on issues/a-directory-rename-on-data-is-not-atomic.md." becomes "and the commit by rename." It must also search the bare name a-directory-rename-on-data-is-not-atomic across the tree and find nothing left.

Landing order (orchestrator): this branch is not enqueued while #808 is in the merge queue. After #808 lands, this branch merges main; that merge runs git rm issues/a-directory-rename-on-data-is-not-atomic.md, deletes the citation in stage 4 of issues/a-package-is-a-directory-under-apps-and-the-installer-is-a-program.md, leaves a search for the bare name empty, and gets host and guest / suite green and a review of the merge alone. If #808 leaves the queue instead and this branch lands first, the deletion is pushed to #808's branch before #808 is enqueued again.

What the defect was, measured first

The issue named fileserver's per-entry rename. That was half of it. A host test (a_directory_rename_is_whole_under_one_name_wherever_the_server_is_killed, below) keeps the first k block writes of a directory rename and its sync, for every k. Run against main's code, 39 of 44 stops tore the volume: 2 left the directory half under each name, and 37 left DATA unmountable. The cause was BadMagic on a root the superblock named that the disk did not hold yet: the cache flushes lowest block first, so block 0's superblock went out before the nodes it names. The format had no commit a kill could not tear, so the fix is in both owners.

What changed, per decision

bcachefs (the interim format): a sync is a shadow-paging commit, and no byte of the layout changes.

  • No node the last commit's tree reaches is written over. btree::own writes a node in place only if the operation in progress took its block. Otherwise it moves the node to a new block and gives the old one up. Insert and delete (which now returns the root) both go through it.
  • Every read-write call is one operation (Mounted::atomic). If it is refused anywhere, the root goes back and every block it took is freed. If it succeeds, what it gave up is given up. split_node's and write_data's own give-backs are deleted, because the rollback covers them.
  • Mounted::rename_all renames a list of names as one operation.
  • The commit: flush everything the new root reaches, write both superblock copies, flush, then free what only the older tree reached. A superblock's bytes lie in its first 512-byte sector. A device that writes a sector whole therefore lands each copy as old or new, and either one names a whole tree. Round 2 deleted the round-1 flush between the two copies (the review's mutation): under the crash model the review asked for, it changed no outcome, and it could not (measured below). Superblock::write is main's again.
  • Frees wait for the commit unless no commit has named the block since it was taken. From the first superblock write on, everything taken counts as named (seal). A commit that is refused after its superblock reached the device therefore keeps both trees until one lands.
  • NODE_RESERVE (16), round 2. The reserve is held against every allocation (Reserve::Keep), a file's data grown outside any operation included (alloc_up_to clamps a run to what the volume spares; tested in round 3), except those of an operation that leaves the tree no larger (Reserve::Draw): a delete, and an update_metadata whose entry grows no longer, which splits no node. A commit gives back whatever such an operation drew. The reserve is therefore whole after every sync, and the round-1 wedge cannot be reached by changes: there, names with no data took the reserve and no delete ever succeeded again. Round 1 held the reserve only against a file's data.
  • A read-write mount counts the bitmap's free bits (BitmapAllocator::count_free, called only by Mounted::<_, ReadWrite>::open). Added in round 2; narrowed to read-write in round 3. It no longer trusts the superblock's count, which after a stop between commits names blocks the bitmap holds taken. This was the review's second road to the wedge. open is now one function per mode, and the read-only one takes the superblock's count, as on main. So the kernel's ROOT mount (kernel/src/rootfs.rs), which never reads the count, reads no bitmap block and gains no refusal.
  • BitmapAllocator::give keeps giving up a run past a bit the device would not clear, round 2. Before, it stopped there and left the rest of the run neither freed nor pending.
  • Formatted is a Mounted with shadowing off, and its own create, symlink and sync are deleted. mkfs bytes are unchanged (the differential is below).

fileserver: a directory rename persists the open files beneath it, hands every entry beneath it and the directory's own marker to rename_all, and moves its name index only once that answers.

The contract, narrowed in round 2 (bcachefs/src/fs.rs's read-write header, data.rs's module header): a kill leaves every name, and each entry's length and extents, as one sync or the other. A file's bytes are not held that way, because fileserver writes over a page the file already holds in place.

Kept, filed:

  • issues/a-write-over-a-files-committed-page-is-not-shadowed.md (new). The in-place page overwrite. Exit: such a write lands in a block no committed entry names, and a stop test over it finds each file's bytes as one sync or the other.
  • issues/a-crash-leaks-the-blocks-of-its-last-commit.md. A stop leaves blocks marked used that no tree names. Round 3 retook the measurement with the run crash.rs makes at this head (the rename, 40 one-block files, the commit, stopped before the first superblock write): 284 used where 190 were. Round 2's number came from an older run that wrote one 9000-byte file. Round 2 adds the frees give, succeed and fail drop when the device refuses a bit, and the exit covers refused writes too.
  • issues/a-full-data-volume-frees-space-only-at-a-commit.md. Round 2 adds the reserve's rule and its limits: at most 16 committed nodes copied by the deletes and shrinks between two commits on a full volume, and a tree deeper than 16 cannot delete there at all. Nothing reads DATA's depth, so 16 is a bound picked, not one measured.
  • issues/a-btree-split-leaves-nodes-holding-a-few-entries.md (new, found on the way, not fixed). On main's crate, 300 names with no data, synced one by one, took 97 blocks where a packed tree needs five. pack splits a full leaf into a full node plus a remainder.

Tests

  • bcachefs/tests/crash.rs is the crash-point model on the crate, with no cache between it and the device. A directory of 62 names is renamed, 40 one-block files are written after it, then a commit. Every block write is a stop. Each stop is tried with the open flush epoch's earlier writes landed and with none of them. The stop's own write lands whole, or, where it hits a block the layout writes in place (a superblock copy or the bitmap), torn at each 512-byte boundary, first k sectors new or last k, skipping tears that leave the block equal to the old or new bytes. Every state is checked twice: as the device holds it, and with block 0 lost, mounting from the backup (round 2). 589 writes and 2356 states: each must mount, keep the 80 names beside the directory byte for byte, and hold the directory whole under exactly one name. A second test refuses each write in turn with the filesystem alive. The answer must equal what it serves, the device must be whole past the refused sync and past 40 changes after it, and the next sync must make the answer durable.
  • bcachefs/tests/integration.rs, round 2:
    • a_volume_full_of_empty_names_still_deletes_and_frees is the review's test, taken further. It fills a 128-block volume with names enough to span leaves, then one-block files until refused, then empty names until a create right after a sync is refused. It then shrinks every file to nothing, deletes every file with no sync between, syncs, and asserts a one-block create is accepted.
    • a_mount_after_a_stop_counts_the_blocks_its_bitmap_holds stops a volume after a 20-block create it never committed, remounts the image, then does the same fill, shrinks and deletes.
    • a_file_grown_past_what_the_volume_spares_leaves_the_reserve (round 3, the review's BLOCKER) is mkfs's one-block f00 on a 128-block volume. Every free block lies in one run straight after it, so a run that grows f00 is contiguous and the entry recording it does not grow. The test grows f00 with resolve_or_alloc_block to page 128, past every block of the volume, and asserts NoSpace. It asserts the extent list is still one extent (the test's premise), records the run with update_metadata, and syncs. It then asserts the superblock's free count is at least NODE_RESERVE and runs shrinks_and_deletes_and_takes_again. Under the review's patch (m12) the grow takes the reserve too, and the update is refused NoSpace { requested: 1, available: 0 }: the round-1 wedge, reached through data. The defect the review described is real in the sense that only the clamp stops it. The clamp was already in the code, so no source changes for it.
    • The review's literal form (delete one one-block file, sync, write one block) cannot hold under any copy-on-write reserve. A create costs its data plus a copy of its path before the sync, while one deleted one-block file gives back one block. The tests assert what the fix promises instead: on a full volume every shrink and delete is accepted, and what they free is taken again after the next sync.
  • userland/fileserver/src/data.rs runs the same through DataVolume and its cache. It kills after every write of a rename plus sync. It refuses every write, checking the disk right after the refused sync and again after the retry. It also covers the issue's own measurement: EntryTooLarge on a 300-byte target now moves nothing.
  • Fixtures that make one-block holes take blocks from the allocator and assert their premise on the bitmap. ONE_BLOCK_FILES_64 is 42 (60 − 16 − 2), because a create's leaf copies are now held to the reserve too. a_renamed_file_keeps_every_extent_it_had fragments a 128-block volume, since 64 left its rename no blocks under the reserve.
  • No new guest test: the host tests reach DataVolume::rename, which is fileserver's whole part. main.rs only forwards the call, and the disks' flush paths are unchanged.

Gates at b028cc8

gate exit
cargo run -- --ci host 0
cargo run -- --build-only 0
cargo test (whole guest suite) 0: 41 passed, 41 total, 76.8 s. uptime load averages 41.33 34.53 30.51 at the start and 28.96 33.03 30.42 at the end
cargo test -p bcachefs --no-fail-fast 0 (lib 72, crash 2, integration 57, upstream_fixture 2, doc 2)
cargo test -p fileserver --lib 0 (36 passed)

b028cc8 is this round's commit on a merge of main at 8e3a813 (#806). The merge touched tests/common/compile.rs and one issue file, and no crate or fileserver byte. main has since taken #807. This branch has not merged it, and the merge queue tests the two together.

Checks of high-risk code (filesystem crash consistency)

Every patch was re-run in round 3 at b028cc8, in one script (mutations.log). Each was checked with git apply --check, applied, built, run with cargo test -p bcachefs --no-fail-fast and then cargo test -p fileserver --lib, and reverted. The tree's status was empty after. Every exit below is that run's. m1 to m8, m10 and m11 applied unchanged from round 2. m9 is rewritten for the moved count. m12 is new. The negative control is regenerated against the merge base 8e3a813. Patches, runner and output are in the round-3 evidence comment. The "red" column's counts are round 2's readings; round 3 re-measured the exits.

patch bcachefs fileserver red
negative control: bcachefs/src at origin/main, fileserver's rename at main's, this branch's tests 101 101 crash.rs does not compile (rename_all is new); fileserver's three a_directory_rename_* tests are red (39 of 43 refusals tear)
m1 every node written in place 101 101 672 of 1236 states tear; 153 of 156 and 39 of 43 refusals tear; both full-volume tests are red
m2 no flush before the superblock 101 101 2 of 2356 states; 41 of 43 refusals
m3 a committed block free at once 101 0 779 of 2356 states; 32 of 247 refusals
m4 a refused operation keeps its root 101 101 210 of 247 refusals; …_the_format_refuses_part_way_moves_nothing
m5 no seal 101 0 32 of 247 refusals; both full-volume tests
m6 fileserver renames entry by entry 0 101 …_the_format_refuses_part_way_moves_nothing
m7 the root's number written past the first sector (round 2) 101 0 in crash.rs, 4 of 2372 states, only torn backup writes with block 0 lost
m8 the backup written before the flush of the nodes it names (round 2) 101 0 1 of 2360 states, only with block 0 lost
m9 a mount takes the superblock's free count (round 2) 101 0 a_mount_after_a_stop_counts_the_blocks_its_bitmap_holds
m10 a delete keeps the reserve (round 2) 101 0 both full-volume tests
m11 a shrink keeps the reserve (round 2) 101 0 both full-volume tests
m12 alloc_up_to does not clamp a run to what the volume spares (round 3, the review's) 101 0 a_file_grown_past_what_the_volume_spares_leaves_the_reserve: the update is refused NoSpace, available 0

The review's flush mutation was measured on the round-1 sync with this round's crash model, before the flush between the copies was deleted. The mutation is one flush after both superblock copies instead of one between them. crash exited 0 and fileserver exited 0 (mutations-r2a.log, in the comment). It stays green because the model cannot fail on it: a superblock's bytes past its first 122 are zero in every copy, so a sector-aligned tear of one leaves the old copy or the new one. In the same run, m7 and m8 went red only through the new shapes, so the shapes have teeth. The flush between the copies was then deleted rather than kept untested.

The full-volume tests on the round-1 source (bcachefs/src at 3adcf42, this round's tests) exit 101. a_volume_full_of_empty_names_still_deletes_and_frees fails on the shrink of f00 (NoSpace, available 0). a_mount_after_a_stop_counts_the_blocks_its_bitmap_holds fails on the same shrink (NoSpace, available 21: the superblock's count against an empty bitmap). Both are green at the head.

Independent oracle for the commit's premise, and for deleting the flush between the copies. The commit rests on a device writing a superblock's first 512-byte sector whole. The NVMe base specification guarantees that. Identify Controller's AWUPF (Atomic Write Unit Power Fail) is a 0's based count of logical blocks the controller writes atomically across a power failure, so every controller guarantees at least one LBA. An LBA is at least 512 bytes. DATA's only device is NVMe: diskserver drives one NVMe controller, under QEMU and on the T14. So a superblock copy lands old or new, and it does so without a flush between the two copies. The crash model's sector tears are that guarantee taken as the failure boundary. Neither the crash model nor AWUPF bounds a device that breaks the specification.

Independent oracle for the rest. The format has no specification and no second implementation (issues/bcachefs-crate-is-not-bcachefs.md), so the oracle for crash consistency is the crash-point model itself: every write point, out-of-order landing inside a flush epoch, sector tears of in-place blocks, and a mount from the backup, each judged by a fresh mount. For mkfs there is a differential (comment). One scratch binary was built against origin/main's crate (198a9d3) and against this branch's (0c9c05c, whose crate 3b43b83 carries unchanged), over kernel/src (231 files, 24 symlinks) and main's bcachefs/ (21 files, 3 symlinks) on 65536 blocks. sha256 3383376928e3…cea80039 both, cmp exit 0; b4692a62ea0a…592b480cad both, cmp exit 0.

T14

The orchestrator read boot:testcases at 3b43b83: judge EXIT=0, 249 passed, 0 failed, 2 boots. Round 3 changes which mounts read the bitmap, and every boot mounts, so it owes a new reading. It is staged at b028cc8 with cargo test --test toyos-build -- --metal --metal-readback <dir> boot:testcases. The command exited 2: the images are staged and the machine was not touched. request.txt carries each image's sha256 and the judge's command: testcases 76ba2071f6cc…2519de2ca, testcases-watchdog 8ff7ab7411e7…a9d086c98a29.

Unsure

  • NODE_RESERVE = 16 is picked, not measured against DATA's depth (recorded in the full-volume issue).
  • Each operation copies the path it touches unless it took the block, which means extra bitmap and node writes on each close or create on a read-write DATA volume. The T14's test images carry no DATA partition (fileserver: this machine has no DATA partition; /apps, /config, /home and /state are in memory), so the T14 readings ran only boot and ROOT's read-only mount; the guest suite under QEMU is the only device-level run of DATA's read-write path. No timing claim is made.
  • The commit relies on the device honouring a flush and writing a 512-byte sector whole. A device that garbles a sector in flight is outside the model. On such a device the in-place bitmap could be garbled as well.
  • A read-write mount reads every bitmap block, one per 128 MiB of volume, to count free blocks. Nothing measured its cost. The read-only ROOT mount reads none.

Net lines (git diff --shortstat origin/main...HEAD, at b028cc8 against 8e3a813): 10 files, +1303 −362. By path: bcachefs/src +495 −279 (a few of them btree.rs tests), bcachefs/tests +412 −45, userland/fileserver/src/data.rs +280 −38 (mostly its tests), issues/ +116.

🤖 Generated with Claude Code

https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C

Japabu and others added 2 commits October 9, 2026 18:37
Verbatim from wt/toyos-pkgrepo (d760336), so the branch that fixes it
closes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
… commit a kill cannot tear

The issue named fileserver's per-entry rename, and that was half of it. The
other half was the format's: its btree was updated in place and a sync wrote
the superblock (block 0, so first in the cache's lowest-first flush) in the
same flush as the nodes it names. A host test that stops the disk after each
block write of a directory rename and its sync, run on main, tore 39 of 44
stops: 2 left the directory in two halves, 37 left a volume that would not
mount at all (BadMagic on a root the superblock named and the disk did not
yet hold). So the fix is at both owners.

bcachefs (the interim format) now commits by shadow paging, with no change to
a byte of its layout:

- No node the last commit's tree reaches is written over. A node is written
  in place only when the operation in progress took its block; otherwise
  `btree::own` moves it to a new block and gives the old one up, up to a new
  root. Insert and delete both go through it; delete now returns the root.
- Every read-write call is one operation (`Mounted::atomic`): refused
  anywhere, the root goes back and every block it took is freed; succeeded,
  what it gave up is given up. `split_node`'s and `write_data`'s own
  give-backs go, the operation's rollback covers both.
- `Mounted::rename_all` renames a list of names as one operation.
- A sync flushes everything first, then writes the primary superblock and
  flushes, then the backup and flushes: the primary landing is the commit,
  and a torn primary fails its CRC for the backup, still the last commit.
- A block given up is free at once only if no commit has named it since it
  was taken; otherwise it waits for the next commit. From the first
  superblock write on, everything taken counts as named (`seal`), so a
  commit refused after its superblock reached the device keeps both trees
  until one lands.
- A file's data leaves `NODE_RESERVE` (16) blocks free, so a volume its
  files filled can still copy the nodes a delete changes.
- `Formatted` is a `Mounted` with shadowing off, its own create, symlink
  and sync paths gone; mkfs writes the same bytes as before: one image of
  kernel/src (232 files, 24 symlinks) built with main's crate and with
  this one is byte-identical.

fileserver's directory rename persists the open files beneath it, then
hands every entry beneath and the directory's own to `rename_all`, and
moves its index only once that answers.

What this keeps, filed: a crash leaks the blocks taken since the last commit
(issues/a-crash-leaks-the-blocks-of-its-last-commit.md), and a full volume
frees what an unlink gave up only at the next commit
(issues/a-full-data-volume-frees-space-only-at-a-commit.md).

Closes issues/a-directory-rename-on-data-is-not-atomic.md: its exit, a test
that refuses every write of the rename in turn and kills the server at
every one, is fileserver's two new tests and bcachefs/tests/crash.rs.

The test fixtures that made a volume of one-block holes by filling it with
files now take blocks from the allocator directly and assert their premise
on the bitmap: filled with files, the 16 reserved blocks were one free run
and the premise was silently false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Mutation and negative-control patches for PR #816, each applied with git apply at 3adcf42, run, and reverted with git apply -R in the same loop (tree clean after). Exits in the PR body.

negative-control-whole-change-reverted

diff --git b/bcachefs/src/alloc_bitmap.rs a/bcachefs/src/alloc_bitmap.rs
index 844e9b324..c6a5db186 100644
--- b/bcachefs/src/alloc_bitmap.rs
+++ a/bcachefs/src/alloc_bitmap.rs
@@ -1,9 +1,5 @@
-use alloc::collections::BTreeMap;
-use alloc::vec::Vec;
-
 use crate::block_io::{BlockBuf, BlockNum, BlockIO, BlockIOExt, BLOCK_SIZE};
 use crate::fs::FsError;
-use crate::superblock::Superblock;
 
 const BITS_PER_BLOCK: u64 = (BLOCK_SIZE * 8) as u64;
 
@@ -26,162 +22,15 @@ pub struct Run {
 ///
 /// The bitmap is stored on disk starting at `bitmap_start` and spanning
 /// `bitmap_blocks` blocks. Each bit represents one block: 1 = used, 0 = free.
-///
-/// **With `shadow` on, no block the last committed tree reaches is written
-/// over or handed out again before the next commit lands** (`fs::commit`): a
-/// btree node is written in place only when the operation in progress took
-/// its block ([`Self::in_place`]), and a block given up is free at once only
-/// if no commit has named it since it was taken (`fresh`); any other waits in
-/// `pending` for the commit. An operation ([`Self::begin`]) gives up its
-/// blocks only when it succeeds, and refused gives back every one it took.
-/// mkfs runs with `shadow` off: nothing it writes is committed until it ends.
 pub struct BitmapAllocator {
     pub bitmap_start: BlockNum,
     pub bitmap_blocks: u64,
     pub total_blocks: u64,
     pub free_blocks: u64,
     pub next_alloc: u64, // cursor — scan starts here, wraps once
-    pub shadow: bool,
-    /// Taken since the last commit's superblock was handed to the device.
-    fresh: Runs,
-    /// Given up, and reached by a tree a mount may still find.
-    pending: Vec<(u64, u32)>,
-    op: Option<Op>,
-}
-
-/// Blocks a file's data never takes while `shadow` is on, so a volume its
-/// files filled can still copy the nodes a delete changes: a rename in a tree
-/// four levels deep copies four, splits four and adds a root for its insert,
-/// and copies four more for its delete.
-pub const NODE_RESERVE: u64 = 16;
-
-/// The operation in progress: what it took, and what it gives up if it succeeds.
-#[derive(Default)]
-struct Op {
-    taken: Runs,
-    given: Vec<(u64, u32)>,
-}
-
-/// Disjoint runs of blocks, by first block, to one past the last.
-#[derive(Default)]
-struct Runs(BTreeMap<u64, u64>);
-
-impl Runs {
-    fn insert(&mut self, start: u64, len: u64) {
-        self.0.insert(start, start + len);
-    }
-
-    fn contains(&self, block: u64) -> bool {
-        self.0.range(..=block).next_back().is_some_and(|(_, &end)| block < end)
-    }
-
-    /// Take `block` out, answering whether it was in.
-    fn remove(&mut self, block: u64) -> bool {
-        let Some((&start, &end)) = self.0.range(..=block).next_back() else { return false };
-        if block >= end {
-            return false;
-        }
-        self.0.remove(&start);
-        if start < block {
-            self.0.insert(start, block);
-        }
-        if block + 1 < end {
-            self.0.insert(block + 1, end);
-        }
-        true
-    }
 }
 
 impl BitmapAllocator {
-    /// The allocator of a mounted volume, as its superblock left it.
-    pub fn open(sb: &Superblock) -> Self {
-        Self {
-            bitmap_start: sb.bitmap_start,
-            bitmap_blocks: sb.bitmap_blocks,
-            total_blocks: sb.block_count,
-            free_blocks: sb.free_blocks,
-            next_alloc: sb.next_alloc,
-            shadow: true,
-            fresh: Runs::default(),
-            pending: Vec::new(),
-            op: None,
-        }
-    }
-
-    /// Begin one operation on the tree, which [`Self::succeed`] or
-    /// [`Self::fail`] ends.
-    pub fn begin(&mut self) {
-        assert!(self.op.replace(Op::default()).is_none(), "an operation began inside another");
-    }
-
-    /// Whether a node at `block` may be written where it is.
-    pub fn in_place(&self, block: BlockNum) -> bool {
-        !self.shadow || self.op.as_ref().is_some_and(|op| op.taken.contains(block.raw()))
-    }
-
-    /// The operation took effect: what it gave up is given up now. A bit the
-    /// device would not clear is a leaked block, and no reason to call an
-    /// operation that took effect refused.
-    pub fn succeed(&mut self, io: &dyn BlockIO) {
-        let op = self.op.take().expect("an operation in progress");
-        for (start, count) in op.given {
-            let _ = self.give(io, start, count);
-        }
-    }
-
-    /// The operation was refused: every block it took is free again, and what
-    /// it would have given up stays its owner's. A bit the device would not
-    /// clear is a leaked block, and the refusal already in hand is the answer.
-    pub fn fail(&mut self, io: &dyn BlockIO) {
-        let op = self.op.take().expect("an operation in progress");
-        for (start, end) in op.taken.0 {
-            for block in start..end {
-                self.fresh.remove(block);
-                let _ = self.set_free(io, BlockNum::new(block));
-            }
-        }
-    }
-
-    /// A superblock naming the tree as it stands may land from here on, so
-    /// nothing taken so far is free at once when given up.
-    pub fn seal(&mut self) {
-        self.fresh = Runs::default();
-    }
-
-    /// Blocks given up that a commit landing makes free.
-    pub fn pending(&self) -> u64 {
-        self.pending.iter().map(|&(_, count)| count as u64).sum()
-    }
-
-    /// A commit landed: the blocks only an older tree reached are free.
-    pub fn committed(&mut self, io: &dyn BlockIO) -> Result<(), FsError> {
-        while let Some((start, count)) = self.pending.pop() {
-            for i in 0..count {
-                if let Err(e) = self.set_free(io, BlockNum::new(start + i as u64)) {
-                    self.pending.push((start + i as u64, count - i));
-                    return Err(e);
-                }
-            }
-        }
-        Ok(())
-    }
-
-    /// Give up `count` blocks from `start`: free now if no commit has named
-    /// them, and otherwise once the next one lands.
-    fn give(&mut self, io: &dyn BlockIO, start: u64, count: u32) -> Result<(), FsError> {
-        for block in start..start + count as u64 {
-            if !self.shadow || self.fresh.remove(block) {
-                self.set_free(io, BlockNum::new(block))?;
-            } else {
-                match self.pending.last_mut() {
-                    Some((at, n)) if *at + *n as u64 == block => *n += 1,
-                    _ => self.pending.push((block, 1)),
-                }
-            }
-        }
-        Ok(())
-    }
-
     /// Where a block's bit lives, and which bit of that byte it is.
     fn bit_of(&self, block: BlockNum) -> (BlockNum, usize, u8) {
         let byte_idx = block.raw() / 8;
@@ -242,20 +91,13 @@ impl BitmapAllocator {
     }
 
     /// Reserve as much of `wanted` as one contiguous run can cover, scanning
-    /// from block `from`: a file's data, which leaves [`NODE_RESERVE`] free.
+    /// from block `from`.
     ///
     /// The run is never empty and may be shorter than asked for, so every
     /// caller has to loop or has to be wrong.
     pub fn alloc_up_to(&mut self, io: &dyn BlockIO, from: u64, wanted: u32) -> Result<Run, FsError> {
-        let spare = match self.shadow {
-            true => self.free_blocks.saturating_sub(NODE_RESERVE),
-            false => self.free_blocks,
-        };
-        if spare == 0 {
-            return Err(FsError::NoSpace { requested: wanted, available: 0 });
-        }
         // A zero-length run would let a caller's loop spin without progress.
-        let wanted = wanted.max(1).min(u32::try_from(spare).unwrap_or(u32::MAX));
+        let wanted = wanted.max(1);
         let (start, len) = self.longest_free_run(io, from % self.total_blocks, wanted)?;
         self.reserve(io, start, len.min(wanted))
     }
@@ -281,10 +123,6 @@ impl BitmapAllocator {
     fn reserve(&mut self, io: &dyn BlockIO, start: u64, len: u32) -> Result<Run, FsError> {
         let start_block = BlockNum::new(start);
         self.set_range_used(io, start_block, len as u64)?;
-        self.fresh.insert(start, len as u64);
-        if let Some(op) = &mut self.op {
-            op.taken.insert(start, len as u64);
-        }
         self.free_blocks -= len as u64;
         self.next_alloc = start + len as u64;
         if self.next_alloc >= self.total_blocks {
@@ -378,21 +216,17 @@ impl BitmapAllocator {
         Ok((start, best_count))
     }
 
-    /// Give up a contiguous range of blocks: inside an operation, once it
-    /// succeeds.
+    /// Free a contiguous range of blocks.
     pub fn free_range(
         &mut self,
         io: &dyn BlockIO,
         start: BlockNum,
         count: u32,
     ) -> Result<(), FsError> {
-        match &mut self.op {
-            Some(op) => {
-                op.given.push((start.raw(), count));
-                Ok(())
-            }
-            None => self.give(io, start.raw(), count),
+        for i in 0..count as u64 {
+            self.set_free(io, BlockNum::new(start.raw() + i))?;
         }
+        Ok(())
     }
 
     /// Initialize bitmap on disk: zero all bitmap blocks, then mark metadata blocks as used.
@@ -414,10 +248,6 @@ impl BitmapAllocator {
             total_blocks,
             free_blocks: total_blocks - metadata_blocks,
             next_alloc: metadata_blocks,
-            shadow: false,
-            fresh: Runs::default(),
-            pending: Vec::new(),
-            op: None,
         };
 
         // Metadata blocks (superblock, bitmap, journal area), and the backup
diff --git b/bcachefs/src/btree.rs a/bcachefs/src/btree.rs
index bafb13be0..59e6ba3d8 100644
--- b/bcachefs/src/btree.rs
+++ a/bcachefs/src/btree.rs
@@ -365,24 +365,15 @@ fn write_entry(b: &mut [u8; BLOCK_SIZE], offset: usize, key: &Key, value: &[u8])
 /// child's subtree, so the answer is the last child whose key is `<= key`,
 /// defaulting to the first — which covers everything below the second key.
 fn find_child(children: &[Child], key: &Key) -> Option<BlockNum> {
-    children.get(child_index(children, key)).map(|c| c.block)
-}
-
-/// [`find_child`]'s answer as an index into `children`.
-fn child_index(children: &[Child], key: &Key) -> usize {
-    children.iter().take_while(|c| c.key <= *key).count().saturating_sub(1)
-}
-
-/// The block a node the operation in progress changed is written to: its
-/// own where [`BitmapAllocator::in_place`] allows, and otherwise a new one,
-/// the old given up.
-fn own(io: &dyn BlockIO, alloc: &mut BitmapAllocator, block: BlockNum) -> Result<BlockNum, FsError> {
-    if alloc.in_place(block) {
-        return Ok(block);
+    let mut chosen = children.first()?.block;
+    for child in children {
+        if child.key <= *key {
+            chosen = child.block;
+        } else {
+            break;
+        }
     }
-    let moved = alloc.alloc_block(io)?;
-    alloc.free_range(io, block, 1)?;
-    Ok(moved)
+    Some(chosen)
 }
 
 /// Search the B+ tree for an exact key match. Returns the leaf entry's value.
@@ -443,48 +434,27 @@ pub fn search_by_hash(
     }
 }
 
-/// Delete an exact key from the B+ tree: the root after it and the old value,
-/// or `None` with nothing written when no entry has the key.
+/// Delete an exact key from the B+ tree. Returns the old value if found.
 /// Does not merge underflowing nodes — just removes the entry from the leaf.
-pub fn delete(
-    io: &dyn BlockIO,
-    alloc: &mut BitmapAllocator,
-    root: BlockNum,
-    key: &Key,
-) -> Result<Option<(BlockNum, Vec<u8>)>, FsError> {
-    delete_recursive(io, alloc, root, Depth::ROOT, key)
-}
+pub fn delete(io: &dyn BlockIO, root: BlockNum, key: &Key) -> Result<Option<Vec<u8>>, FsError> {
+    let mut block = root;
+    let mut depth = Depth::ROOT;
 
-fn delete_recursive(
-    io: &dyn BlockIO,
-    alloc: &mut BitmapAllocator,
-    block: BlockNum,
-    depth: Depth,
-    key: &Key,
-) -> Result<Option<(BlockNum, Vec<u8>)>, FsError> {
-    match Node::read(io, block)? {
-        Node::Leaf(mut entries) => {
-            let Some(pos) = entries.iter().position(|e| e.key == *key) else {
-                return Ok(None);
-            };
-            let old = entries.remove(pos);
-            let at = own(io, alloc, block)?;
-            Node::Leaf(entries).write(io, at)?;
-            Ok(Some((at, old.value)))
-        }
-        Node::Interior { level, mut children } => {
-            let idx = child_index(&children, key);
-            let child = children.get(idx).ok_or(FsError::CorruptedNode(block))?.block;
-            let Some((moved, old)) = delete_recursive(io, alloc, child, depth.descend(block)?, key)? else {
-                return Ok(None);
-            };
-            if moved == child {
-                return Ok(Some((block, old)));
+    loop {
+        match Node::read(io, block)? {
+            Node::Leaf(mut entries) => {
+                let Some(pos) = entries.iter().position(|e| e.key == *key) else {
+                    return Ok(None);
+                };
+                let old = entries.remove(pos);
+                Node::Leaf(entries).write(io, block)?;
+                return Ok(Some(old.value));
+            }
+            Node::Interior { children, .. } => {
+                let next = find_child(&children, key).ok_or(FsError::CorruptedNode(block))?;
+                depth = depth.descend(block)?;
+                block = next;
             }
-            children[idx].block = moved;
-            let at = own(io, alloc, block)?;
-            Node::Interior { level, children }.write(io, at)?;
-            Ok(Some((at, old)))
         }
     }
 }
@@ -529,8 +499,7 @@ fn walk_recursive(
 
 /// Insert a key-value pair into the B+ tree.
 ///
-/// Returns the root block, which changes when the old root was split or
-/// written somewhere new.
+/// Returns the root block, which changes when the old root was split.
 pub fn insert(
     io: &dyn BlockIO,
     alloc: &mut BitmapAllocator,
@@ -539,29 +508,29 @@ pub fn insert(
 ) -> Result<BlockNum, FsError> {
     check_entry_fits(&entry)?;
 
-    let Placed { at, split } = insert_recursive(io, alloc, root, Depth::ROOT, entry)?;
-    if split.is_empty() {
-        return Ok(at);
+    match insert_recursive(io, alloc, root, Depth::ROOT, entry)? {
+        InsertResult::Done => Ok(root),
+        InsertResult::Split(siblings) => {
+            let level = Node::read(io, root)?
+                .level()
+                .checked_add(1)
+                .ok_or(FsError::CorruptedNode(root))?;
+            let old_min_key = min_key(io, root, Depth::ROOT)?;
+            let new_root_block = alloc.alloc_block(io)?;
+
+            let mut children = alloc::vec![Child { key: old_min_key, block: root }];
+            children.extend(siblings);
+            Node::Interior { level, children }.write(io, new_root_block)?;
+
+            Ok(new_root_block)
+        }
     }
-    let level = Node::read(io, at)?
-        .level()
-        .checked_add(1)
-        .ok_or(FsError::CorruptedNode(at))?;
-    let old_min_key = min_key(io, at, Depth::ROOT)?;
-    let new_root_block = alloc.alloc_block(io)?;
-
-    let mut children = alloc::vec![Child { key: old_min_key, block: at }];
-    children.extend(split);
-    Node::Interior { level, children }.write(io, new_root_block)?;
-
-    Ok(new_root_block)
 }
 
-/// Where an insert left a node: its block, and the siblings it split off, in
-/// key order.
-struct Placed {
-    at: BlockNum,
-    split: Vec<Child>,
+enum InsertResult {
+    Done,
+    /// The node split: these follow it, in key order.
+    Split(Vec<Child>),
 }
 
 fn insert_recursive(
@@ -570,7 +539,7 @@ fn insert_recursive(
     block: BlockNum,
     depth: Depth,
     entry: Entry,
-) -> Result<Placed, FsError> {
+) -> Result<InsertResult, FsError> {
     match Node::read(io, block)? {
         Node::Leaf(mut entries) => {
             match entries.binary_search_by(|e| e.key.cmp(&entry.key)) {
@@ -579,24 +548,32 @@ fn insert_recursive(
             }
             write_or_split(io, alloc, block, Node::Leaf(entries))
         }
-        Node::Interior { level, mut children } => {
-            let idx = child_index(&children, &entry.key);
+        Node::Interior { level, children } => {
+            let mut idx = 0;
+            for (i, child) in children.iter().enumerate() {
+                if child.key <= entry.key {
+                    idx = i;
+                } else {
+                    break;
+                }
+            }
             let child_block = children.get(idx).ok_or(FsError::CorruptedNode(block))?.block;
             let deeper = depth.descend(block)?;
 
-            let Placed { at, split } = insert_recursive(io, alloc, child_block, deeper, entry)?;
-            if at == child_block && split.is_empty() {
-                return Ok(Placed { at: block, split });
-            }
-            children[idx].block = at;
-            for sibling in split {
-                let pos = match children.binary_search_by(|c| c.key.cmp(&sibling.key)) {
-                    Ok(i) => i + 1,
-                    Err(i) => i,
-                };
-                children.insert(pos, sibling);
+            match insert_recursive(io, alloc, child_block, deeper, entry)? {
+                InsertResult::Done => Ok(InsertResult::Done),
+                InsertResult::Split(siblings) => {
+                    let mut children = children;
+                    for sibling in siblings {
+                        let pos = match children.binary_search_by(|c| c.key.cmp(&sibling.key)) {
+                            Ok(i) => i + 1,
+                            Err(i) => i,
+                        };
+                        children.insert(pos, sibling);
+                    }
+                    write_or_split(io, alloc, block, Node::Interior { level, children })
+                }
             }
-            write_or_split(io, alloc, block, Node::Interior { level, children })
         }
     }
 }
@@ -606,23 +583,20 @@ fn write_or_split(
     alloc: &mut BitmapAllocator,
     block: BlockNum,
     node: Node,
-) -> Result<Placed, FsError> {
-    let at = own(io, alloc, block)?;
+) -> Result<InsertResult, FsError> {
     if NODE_HEADER_SIZE + node.payload_size() <= BLOCK_SIZE {
-        node.write(io, at)?;
-        return Ok(Placed { at, split: Vec::new() });
+        node.write(io, block)?;
+        return Ok(InsertResult::Done);
     }
-    Ok(Placed { at, split: split_node(io, alloc, at, node)? })
+    split_node(io, alloc, block, node)
 }
 
-/// Split `node` across `block` and new siblings, answering the siblings. The
-/// blocks it took are the operation's, which gives them back if it fails.
 fn split_node(
     io: &dyn BlockIO,
     alloc: &mut BitmapAllocator,
     block: BlockNum,
     node: Node,
-) -> Result<Vec<Child>, FsError> {
+) -> Result<InsertResult, FsError> {
     match node {
         Node::Leaf(entries) => {
             // One entry is not a split problem. Halving by *count* used to
@@ -636,15 +610,28 @@ fn split_node(
 
             let mut nodes = pack(entries);
             let first = nodes.remove(0);
+            let mut blocks = Vec::with_capacity(nodes.len());
+            for _ in &nodes {
+                match alloc.alloc_block(io) {
+                    Ok(sibling) => blocks.push(sibling),
+                    Err(e) => {
+                        for taken in blocks {
+                            alloc.free_range(io, taken, 1)?;
+                        }
+                        return Err(e);
+                    }
+                }
+            }
+            // The siblings first: a failure before `block` is replaced leaves
+            // the tree as it was and blocks nothing names.
             let mut children = Vec::with_capacity(nodes.len());
-            for node in nodes {
-                let sibling = alloc.alloc_block(io)?;
+            for (node, sibling) in nodes.into_iter().zip(blocks) {
                 children.push(Child { key: node[0].key, block: sibling });
                 Node::Leaf(node).write(io, sibling)?;
             }
             Node::Leaf(first).write(io, block)?;
 
-            Ok(children)
+            Ok(InsertResult::Split(children))
         }
         Node::Interior { level, mut children } => {
             if children.len() < 2 {
@@ -662,7 +649,7 @@ fn split_node(
             Node::Interior { level, children }.write(io, block)?;
             Node::Interior { level, children: right }.write(io, right_block)?;
 
-            Ok(alloc::vec![Child { key: split_key, block: right_block }])
+            Ok(InsertResult::Split(alloc::vec![Child { key: split_key, block: right_block }]))
         }
     }
 }
@@ -819,8 +806,9 @@ mod tests {
         );
     }
 
-    /// A split that finds no block for a later sibling fails its operation,
-    /// which gives back the block it took for the earlier: nothing names it.
+    /// A split that finds no block for a later sibling gives back the ones it
+    /// took for the earlier: nothing names them yet, so nothing else frees
+    /// them.
     #[test]
     fn a_split_short_of_a_sibling_gives_back_the_ones_it_took() {
         let io = crate::block_io::VecBlockIO::new(16);
@@ -833,13 +821,10 @@ mod tests {
         let mut middle: Vec<Entry> = (0..40).map(|_| entry(72)).collect();
         middle.insert(20, entry(MAX_ENTRY_SIZE - KEY_HEADER_SIZE));
         assert_eq!(pack(middle.clone()).len(), 3, "two siblings, and a block for one");
-        alloc.begin();
         assert!(matches!(
             split_node(&io, &mut alloc, leaf, Node::Leaf(middle)),
             Err(FsError::NoSpace { .. }),
         ));
-        assert_eq!(alloc.free_blocks, 0, "the first sibling took the last block");
-        alloc.fail(&io);
         assert_eq!(alloc.free_blocks, 1, "the first sibling's block went back");
     }
 
diff --git b/bcachefs/src/fs.rs a/bcachefs/src/fs.rs
index 161112f88..e83a49d17 100644
--- b/bcachefs/src/fs.rs
+++ a/bcachefs/src/fs.rs
@@ -139,10 +139,12 @@ impl FsError {
 pub struct ReadOnly;
 pub struct ReadWrite;
 
-/// A formatted but not yet mounted filesystem. Used for building images
-/// (mkfs): written in place, since nothing it holds is committed before
-/// [`Formatted::into_io`].
-pub struct Formatted<IO: BlockIO>(Mounted<IO, ReadWrite>);
+/// A formatted but not yet mounted filesystem. Used for building images (mkfs).
+pub struct Formatted<IO: BlockIO> {
+    io: IO,
+    sb: Superblock,
+    alloc: BitmapAllocator,
+}
 
 /// A mounted filesystem. Mode is ReadOnly or ReadWrite.
 pub struct Mounted<IO: BlockIO, Mode = ReadWrite> {
@@ -410,8 +412,9 @@ fn decode_leaf_value(value: &[u8], volume_blocks: u64) -> Result<LeafValue, FsEr
 /// Allocate blocks and write `data` into them, returning the extent list.
 ///
 /// The allocator answers with a run that may be shorter than the request, so
-/// covering `data` takes a loop. A run reserved by an earlier turn of that loop
-/// is the operation's, which gives it back if a later turn fails.
+/// covering `data` takes a loop — and a run reserved by an earlier turn of that
+/// loop is a block the bitmap calls taken that no entry names, once a later
+/// turn fails. Every run goes back before the error does.
 fn write_data(
     io: &dyn BlockIO,
     alloc: &mut BitmapAllocator,
@@ -427,7 +430,10 @@ fn write_data(
     let mut data_offset = 0usize;
 
     while remaining > 0 {
-        let run = alloc.alloc_up_to(io, alloc.next_alloc, remaining)?;
+        let run = match alloc.alloc_up_to(io, alloc.next_alloc, remaining) {
+            Ok(run) => run,
+            Err(err) => return Err(give_back(io, alloc, &extents, err)),
+        };
         push_extent(&mut extents, run.start.raw(), run.len);
 
         let mut buf = BlockBuf::zeroed();
@@ -438,7 +444,9 @@ fn write_data(
                 let len = chunk_end - data_offset;
                 buf.0[..len].copy_from_slice(&data[data_offset..chunk_end]);
             }
-            io.write(BlockNum::new(run.start.raw() + i), &buf)?;
+            if let Err(err) = io.write(BlockNum::new(run.start.raw() + i), &buf) {
+                return Err(give_back(io, alloc, &extents, err));
+            }
             data_offset += BLOCK_SIZE;
         }
 
@@ -448,6 +456,24 @@ fn write_data(
     Ok(extents)
 }
 
+/// Hand back the runs a failed [`write_data`] had already reserved, and return
+/// the failure that stopped it.
+///
+/// Best effort by construction: this runs because something has already gone
+/// wrong, and a bitmap write that also fails has no better answer to give than
+/// the error already in hand.
+fn give_back(
+    io: &dyn BlockIO,
+    alloc: &mut BitmapAllocator,
+    extents: &[Extent],
+    err: FsError,
+) -> FsError {
+    for ext in extents {
+        let _ = alloc.free_range(io, BlockNum::new(ext.start_block), ext.block_count);
+    }
+    err
+}
+
 /// Read file data from a list of extents.
 ///
 /// **`size` sizes the `Vec` and `size` is a number off the disk**, so it is
@@ -549,44 +575,84 @@ impl<IO: BlockIO> Formatted<IO> {
 
         sb.write(&io)?;
 
-        Ok(Self(Mounted { io, sb, alloc, _mode: PhantomData }))
+        Ok(Self { io, sb, alloc })
     }
 
     /// Name this filesystem, so a role's kernel argument can select it.
     ///
     /// A separate act from formatting: a volume nothing names is legal, and
-    /// nothing here invents a name for one. Persisted by [`Self::into_io`].
+    /// nothing here invents a name for one. Persisted by [`Self::sync`], which
+    /// [`Self::into_io`] runs.
     pub fn set_uuid(&mut self, uuid: FsUuid) {
-        self.0.sb.uuid = uuid;
+        self.sb.uuid = uuid;
     }
 
     /// Create a file on the formatted filesystem (used during mkfs).
     pub fn create(&mut self, name: &str, data: &[u8], mtime: u64) -> Result<(), FsError> {
-        self.0.create(name, data, mtime)
+        if name.is_empty() || name.len() > MAX_NAME_LEN {
+            return Err(FsError::NameTooLong { len: name.len(), max: MAX_NAME_LEN });
+        }
+
+        let extents = write_data(&self.io, &mut self.alloc, data)?;
+        let value = encode_leaf_value(KeyType::File, name, data.len() as u64, mtime, &extents);
+        let key = make_key(&self.sb.hash_seed, name, KeyType::File);
+        let entry = Entry { key, value };
+
+        self.sb.root_node = btree::insert(&self.io, &mut self.alloc, self.sb.root_node, entry)?;
+
+        Ok(())
     }
 
     /// Create a symlink on the formatted filesystem.
     pub fn create_symlink(&mut self, name: &str, target: &str, mtime: u64) -> Result<(), FsError> {
-        self.0.put(name, KeyType::Symlink, target.as_bytes(), mtime)
+        if name.is_empty() || name.len() > MAX_NAME_LEN {
+            return Err(FsError::NameTooLong { len: name.len(), max: MAX_NAME_LEN });
+        }
+
+        let target_bytes = target.as_bytes();
+        let extents = write_data(&self.io, &mut self.alloc, target_bytes)?;
+        let value = encode_leaf_value(KeyType::Symlink, name, target_bytes.len() as u64, mtime, &extents);
+        let key = make_key(&self.sb.hash_seed, name, KeyType::Symlink);
+        let entry = Entry { key, value };
+
+        self.sb.root_node = btree::insert(&self.io, &mut self.alloc, self.sb.root_node, entry)?;
+
+        Ok(())
+    }
+
+    /// Finalize the filesystem: write superblock with clean flag.
+    pub fn sync(&mut self) -> Result<(), FsError> {
+        self.sb.free_blocks = self.alloc.free_blocks;
+        self.sb.next_alloc = self.alloc.next_alloc;
+        self.sb.set_clean(true);
+        self.sb.write(&self.io)?;
+        self.io.flush()
     }
 
     /// Mount this formatted filesystem for read-write access.
     pub fn mount(self) -> Mounted<IO, ReadWrite> {
-        let mut fs = self.0;
-        fs.alloc.shadow = true;
-        fs
+        Mounted {
+            io: self.io,
+            sb: self.sb,
+            alloc: self.alloc,
+            _mode: PhantomData,
+        }
     }
 
     /// Mount this formatted filesystem for read-only access.
     pub fn mount_readonly(self) -> Mounted<IO, ReadOnly> {
-        let Mounted { io, sb, alloc, .. } = self.0;
-        Mounted { io, sb, alloc, _mode: PhantomData }
+        Mounted {
+            io: self.io,
+            sb: self.sb,
+            alloc: self.alloc,
+            _mode: PhantomData,
+        }
     }
 
     /// Consume and return the underlying IO (for extracting the image bytes).
     pub fn into_io(mut self) -> Result<IO, FsError> {
-        self.0.sync()?;
-        Ok(self.0.io)
+        self.sync()?;
+        Ok(self.io)
     }
 }
 
@@ -596,7 +662,13 @@ impl<IO: BlockIO, Mode> Mounted<IO, Mode> {
     /// Open an existing filesystem from disk.
     pub fn open(io: IO) -> Result<Mounted<IO, Mode>, FsError> {
         let sb = Superblock::read(&io)?;
-        let alloc = BitmapAllocator::open(&sb);
+        let alloc = BitmapAllocator {
+            bitmap_start: sb.bitmap_start,
+            bitmap_blocks: sb.bitmap_blocks,
+            total_blocks: sb.block_count,
+            free_blocks: sb.free_blocks,
+            next_alloc: sb.next_alloc,
+        };
         Ok(Mounted {
             io,
             sb,
@@ -731,9 +803,11 @@ impl<IO: BlockIO, Mode> Mounted<IO, Mode> {
 
     /// Convert back to Formatted state (for testing — insert more files after reading).
     pub fn into_formatted(self) -> Formatted<IO> {
-        let Self { io, sb, mut alloc, .. } = self;
-        alloc.shadow = false;
-        Formatted(Mounted { io, sb, alloc, _mode: PhantomData })
+        Formatted {
+            io: self.io,
+            sb: self.sb,
+            alloc: self.alloc,
+        }
     }
 
     /// Return the extents and file size for a file.
@@ -751,17 +825,6 @@ impl<IO: BlockIO, Mode> Mounted<IO, Mode> {
 
 // --- ReadWrite-only operations ---
 
-/// **Every change is one operation and every sync one commit.** An operation
-/// ([`Mounted::atomic`]) takes effect whole or, refused anywhere, leaves the
-/// tree and the allocator as they were; it never writes over a block the last
-/// commit's tree reaches, but to new blocks up to a new root. A commit
-/// ([`Mounted::sync`]) is the primary superblock naming that root landing on
-/// the device, after everything the root reaches, and before the backup copy:
-/// killed anywhere, the device holds the last commit's tree whole or the new
-/// one whole, a torn primary failing its checksum for the backup, which is the
-/// last commit still. What a kill costs is the blocks taken since the last
-/// commit and those only the old tree reached: marked used, and named by no
-/// tree, until a sweep (`issues/a-crash-leaks-the-blocks-of-its-last-commit.md`).
 impl<IO: BlockIO> Mounted<IO, ReadWrite> {
     /// Create a file, replacing whatever answered to `name`.
     pub fn create(&mut self, name: &str, data: &[u8], mtime: u64) -> Result<(), FsError> {
@@ -773,24 +836,6 @@ impl<IO: BlockIO> Mounted<IO, ReadWrite> {
         self.put(name, KeyType::Symlink, target.as_bytes(), 0)
     }
 
-    /// Run `change` as one operation: whole, or refused with the root and
-    /// every block as they were.
-    fn atomic<T>(&mut self, change: impl FnOnce(&mut Self) -> Result<T, FsError>) -> Result<T, FsError> {
-        let root = self.sb.root_node;
-        self.alloc.begin();
-        match change(self) {
-            Ok(done) => {
-                self.alloc.succeed(&self.io);
-                Ok(done)
-            }
-            Err(e) => {
-                self.sb.root_node = root;
-                self.alloc.fail(&self.io);
-                Err(e)
-            }
-        }
-    }
-
     /// Put `name` on the volume, displacing whatever answered to it.
     ///
     /// The new entry goes in before the old one comes out, for the reason
@@ -815,28 +860,21 @@ impl<IO: BlockIO> Mounted<IO, ReadWrite> {
             return Err(FsError::NameTooLong { len: name.len(), max: MAX_NAME_LEN });
         }
 
-        self.atomic(|fs| {
-            let displaced = match fs.find_by_name(name)? {
-                Some((key, value)) => Some((key, fs.decode(&value)?.extents().to_vec())),
-                None => None,
-            };
-
-            let extents = write_data(&fs.io, &mut fs.alloc, data)?;
-            let value = encode_leaf_value(key_type, name, data.len() as u64, mtime, &extents);
-            let key = make_key(&fs.sb.hash_seed, name, key_type);
-            fs.sb.root_node = btree::insert(&fs.io, &mut fs.alloc, fs.sb.root_node, Entry { key, value })?;
+        let displaced = match self.find_by_name(name)? {
+            Some((key, value)) => Some((key, self.decode(&value)?.extents().to_vec())),
+            None => None,
+        };
 
-            fs.retire_displaced(displaced, key)
-        })
-    }
+        let extents = write_data(&self.io, &mut self.alloc, data)?;
+        let value = encode_leaf_value(key_type, name, data.len() as u64, mtime, &extents);
+        let key = make_key(&self.sb.hash_seed, name, key_type);
+        self.sb.root_node = btree::insert(
+            &self.io, &mut self.alloc,
+            self.sb.root_node,
+            Entry { key, value },
+        )?;
 
-    /// Remove `key`'s entry, if the tree has one.
-    fn remove(&mut self, key: &Key) -> Result<bool, FsError> {
-        let Some((root, _)) = btree::delete(&self.io, &mut self.alloc, self.sb.root_node, key)? else {
-            return Ok(false);
-        };
-        self.sb.root_node = root;
-        Ok(true)
+        self.retire_displaced(displaced, key)
     }
 
     /// Remove the entry the insert of `new_key` did not replace, and free the
@@ -852,9 +890,26 @@ impl<IO: BlockIO> Mounted<IO, ReadWrite> {
     ) -> Result<(), FsError> {
         let Some((old_key, old_extents)) = displaced else { return Ok(()) };
         if old_key != new_key {
-            self.remove(&old_key)?;
+            btree::delete(&self.io, self.sb.root_node, &old_key)?;
+        }
+        for ext in &old_extents {
+            self.alloc.free_range(&self.io, BlockNum::new(ext.start_block), ext.block_count)?;
         }
-        self.free_extents(&old_extents)
+        Ok(())
+    }
+
+    /// Delete a file or symlink by name. Returns true if found and deleted.
+    pub fn delete(&mut self, name: &str) -> Result<bool, FsError> {
+        self.delete_by_name(name)
+    }
+
+    /// Sync filesystem state to disk.
+    pub fn sync(&mut self) -> Result<(), FsError> {
+        self.sb.free_blocks = self.alloc.free_blocks;
+        self.sb.next_alloc = self.alloc.next_alloc;
+        self.sb.set_clean(true);
+        self.sb.write(&self.io)?;
+        self.io.flush()
     }
 
     /// Delete a file/symlink by name, freeing its data blocks. Returns true if found.
@@ -866,57 +921,32 @@ impl<IO: BlockIO> Mounted<IO, ReadWrite> {
     /// caller nothing had happened. It also answers it once for both key
     /// types, where the old shape fell through from File to Symlink after a
     /// non-matching removal and could take two entries out in one call.
-    pub fn delete(&mut self, name: &str) -> Result<bool, FsError> {
-        self.atomic(|fs| {
-            let Some((key, value)) = fs.find_by_name(name)? else { return Ok(false) };
-            let extents = fs.decode(&value)?.extents().to_vec();
-
-            // `find_by_name` reached this key by the descent `btree::delete` is
-            // about to repeat, so an empty removal is not "no such file" — it is a
-            // tree that answers two ways.
-            if !fs.remove(&key)? {
-                return Err(FsError::CorruptedNode(fs.sb.root_node));
-            }
-            fs.free_extents(&extents)?;
-            Ok(true)
-        })
-    }
-
-    /// Commit the tree as it stands: see this block's header.
-    pub fn sync(&mut self) -> Result<(), FsError> {
-        self.io.flush()?;
-        // What the volume holds free once this commit's own frees are made.
-        self.sb.free_blocks = self.alloc.free_blocks + self.alloc.pending();
-        self.sb.next_alloc = self.alloc.next_alloc;
-        self.sb.set_clean(true);
-        // From the first superblock write on, this commit may be what a mount
-        // finds, even where the write is refused.
-        self.alloc.seal();
-        for copy in self.sb.copies() {
-            self.sb.write_at(&self.io, copy)?;
-            self.io.flush()?;
+    fn delete_by_name(&mut self, name: &str) -> Result<bool, FsError> {
+        let Some((key, value)) = self.find_by_name(name)? else { return Ok(false) };
+        let extents = self.decode(&value)?.extents().to_vec();
+
+        // `find_by_name` reached this key by the descent `btree::delete` is
+        // about to repeat, so an empty removal is not "no such file" — it is a
+        // tree that answers two ways.
+        if btree::delete(&self.io, self.sb.root_node, &key)?.is_none() {
+            return Err(FsError::CorruptedNode(self.sb.root_node));
         }
-        self.alloc.committed(&self.io)
+        for ext in &extents {
+            self.alloc.free_range(&self.io, BlockNum::new(ext.start_block), ext.block_count)?;
+        }
+        Ok(true)
     }
 
     /// Rename a file or symlink.
+    ///
+    /// The new entry goes in before the old one comes out, so a crash between
+    /// the two leaves the file under both names rather than under neither. What
+    /// that ordering costs is that the insert *is* the removal of whatever
+    /// `new_name` named — same name and same type is the same key, and
+    /// `btree::insert` replaces on an equal key — so the displaced entry has to
+    /// be read out of the tree before the insert. Asking for it afterwards, by
+    /// name, answers with the file that was just renamed and frees its extents.
     pub fn rename(&mut self, old_name: &str, new_name: &str) -> Result<(), FsError> {
-        self.rename_all(&[(old_name, new_name)])
-    }
-
-    /// Rename every `(old, new)` pair in turn, as one operation: a directory
-    /// is the names beneath it, and moves whole or not at all.
-    pub fn rename_all(&mut self, renames: &[(&str, &str)]) -> Result<(), FsError> {
-        self.atomic(|fs| renames.iter().try_for_each(|&(old, new)| fs.rename_one(old, new)))
-    }
-
-    /// The new entry goes in before the old one comes out. What that ordering
-    /// costs is that the insert *is* the removal of whatever `new_name` named —
-    /// same name and same type is the same key, and `btree::insert` replaces on
-    /// an equal key — so the displaced entry has to be read out of the tree
-    /// before the insert. Asking for it afterwards, by name, answers with the
-    /// file that was just renamed and frees its extents.
-    fn rename_one(&mut self, old_name: &str, new_name: &str) -> Result<(), FsError> {
         // Every other name-taking entry point bounds its name; this one did
         // not, and `user_ptr::MAX_USER_STR` lets 64 KiB of it through.
         if new_name.is_empty() || new_name.len() > MAX_NAME_LEN {
@@ -952,7 +982,7 @@ impl<IO: BlockIO> Mounted<IO, ReadWrite> {
         // extent list. Nothing to delete when the two names share a key — the
         // entry under it is the one the insert just wrote.
         if new_key != old_key {
-            self.remove(&old_key)?;
+            btree::delete(&self.io, self.sb.root_node, &old_key)?;
         }
 
         Ok(())
@@ -966,24 +996,29 @@ impl<IO: BlockIO> Mounted<IO, ReadWrite> {
         size: u64,
         mtime: u64,
     ) -> Result<(), FsError> {
-        self.atomic(|fs| {
-            let (old_key, old_value) = fs.find_by_name(name)?
-                .ok_or(FsError::NotFound)?;
-            let leaf = fs.decode(&old_value)?;
-
-            let new_value = encode_leaf_value(old_key.key_type, leaf.name(), size, mtime, new_extents);
-            let new_entry = Entry { key: old_key, value: new_value };
-
-            // No delete first: the key is unchanged and `btree::insert` replaces
-            // on an equal key. Blocks the caller drops from the extent list are
-            // the caller's to free, through [`Self::free_extents`], after this
-            // records the shortened list.
-            fs.sb.root_node = btree::insert(&fs.io, &mut fs.alloc, fs.sb.root_node, new_entry)?;
-            Ok(())
-        })
+        let (old_key, old_value) = self.find_by_name(name)?
+            .ok_or(FsError::NotFound)?;
+        let leaf = self.decode(&old_value)?;
+
+        let new_value = encode_leaf_value(old_key.key_type, leaf.name(), size, mtime, new_extents);
+        let new_entry = Entry { key: old_key, value: new_value };
+
+        // No delete first. The key is unchanged and `btree::insert` replaces on
+        // an equal key, so the delete bought nothing and cost the file: a
+        // pre-check for `EntryTooLarge` does not cover `insert`'s other
+        // rejection, a split with no free block to split into, and that one
+        // left the entry deleted and never put back. Blocks the caller drops
+        // from the extent list are the caller's to free, through
+        // [`Self::free_extents`], after this records the shortened list.
+        self.sb.root_node = btree::insert(
+            &self.io, &mut self.alloc,
+            self.sb.root_node,
+            new_entry,
+        )?;
+        Ok(())
     }
 
-    /// Give `extents`' blocks back to the allocator. Record first, free second:
+    /// Return `extents`' blocks to the allocator. Record first, free second:
     /// the caller shortens the entry's list before calling this, so a failure
     /// between the two leaks blocks rather than leaving an entry naming freed
     /// ones.
diff --git b/bcachefs/src/superblock.rs a/bcachefs/src/superblock.rs
index d24ec660b..5d78b8e32 100644
--- b/bcachefs/src/superblock.rs
+++ a/bcachefs/src/superblock.rs
@@ -266,19 +266,10 @@ impl Superblock {
 
     /// Write superblock to both block 0 and the backup at the last block.
     pub fn write(&self, io: &dyn BlockIO) -> Result<(), FsError> {
-        self.copies().try_for_each(|copy| self.write_at(io, copy))
-    }
-
-    /// Block 0, which [`Self::read`] tries first, then the backup.
-    pub fn copies(&self) -> impl Iterator<Item = BlockNum> {
-        [BlockNum::new(0), BlockNum::new(self.block_count - 1)].into_iter()
-    }
-
-    /// Write this superblock to one of its [`Self::copies`].
-    pub fn write_at(&self, io: &dyn BlockIO, copy: BlockNum) -> Result<(), FsError> {
         let mut buf = BlockBuf::zeroed();
         self.write_to(&mut buf);
-        io.write(copy, &buf)
+        io.write(BlockNum::new(0), &buf)?;
+        io.write(BlockNum::new(self.block_count - 1), &buf)
     }
 }
 
--- a/userland/fileserver/src/data.rs	2026-10-09 18:00:09
+++ b/userland/fileserver/src/data.rs	2026-10-09 18:00:09
@@ -687,42 +687,38 @@
                 if to.starts_with(&format!("{from}/")) {
                     return Err(SyscallError::InvalidArgument);
                 }
-                // Every entry beneath, and the directory's own, in one
-                // operation of the format's: the directory moves whole or not at all.
+                // One entry at a time: the format has no rename of a prefix,
+                // so a kill in the middle leaves the directory in two halves,
+                // every entry under exactly one of its names.
                 let prefix = format!("{from}/");
                 let moving: Vec<(String, Kind)> = self
                     .names
-                    .get_key_value(from)
-                    .into_iter()
-                    .chain(self.names.range(prefix.clone()..).take_while(|(n, _)| n.starts_with(&prefix)))
+                    .range(prefix.clone()..)
+                    .take_while(|(n, _)| n.starts_with(&prefix))
                     .map(|(n, k)| (n.clone(), *k))
                     .collect();
-                for (name, _) in &moving {
+                for (name, kind) in &moving {
+                    let moved = format!("{to}/{}", &name[prefix.len()..]);
                     if let Some(node) = self.by_path.get(name).copied() {
                         self.persist(node)?;
                     }
-                }
-                let renames: Vec<(String, String)> = moving
-                    .iter()
-                    .map(|(name, kind)| {
-                        let moved = format!("{to}{}", &name[from.len()..]);
-                        match kind {
-                            Kind::Dir => (format!("{name}/"), format!("{moved}/")),
-                            _ => (name.clone(), moved),
-                        }
-                    })
-                    .collect();
-                let pairs: Vec<(&str, &str)> = renames.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect();
-                mapped("rename", from, self.fs.rename_all(&pairs))?;
-                for (name, kind) in moving {
-                    let moved = format!("{to}{}", &name[from.len()..]);
-                    self.names.remove(&name);
-                    self.names.insert(moved.clone(), kind);
-                    if let Some(node) = self.by_path.remove(&name) {
+                    let (on_disk_from, on_disk_to) = match kind {
+                        Kind::Dir => (format!("{name}/"), format!("{moved}/")),
+                        _ => (name.clone(), moved.clone()),
+                    };
+                    mapped("rename", name, self.fs.rename(&on_disk_from, &on_disk_to))?;
+                    self.names.remove(name);
+                    self.names.insert(moved.clone(), *kind);
+                    if let Some(node) = self.by_path.remove(name) {
                         self.open.get_mut(&node).expect("indexed").path = moved.clone();
                         self.by_path.insert(moved, node);
                     }
                 }
+                if self.names.get(from) == Some(&Kind::Dir) {
+                    mapped("rename", from, self.fs.rename(&format!("{from}/"), &format!("{to}/")))?;
+                    self.names.remove(from);
+                    self.names.insert(to.to_string(), Kind::Dir);
+                }
                 Ok(())
             }
         }

m1-every-node-in-place

--- a/bcachefs/src/alloc_bitmap.rs	2026-10-09 17:59:56
+++ b/bcachefs/src/alloc_bitmap.rs	2026-10-09 17:59:56
@@ -116,7 +116,7 @@
 
     /// Whether a node at `block` may be written where it is.
     pub fn in_place(&self, block: BlockNum) -> bool {
-        !self.shadow || self.op.as_ref().is_some_and(|op| op.taken.contains(block.raw()))
+        true || self.op.as_ref().is_some_and(|op| op.taken.contains(block.raw()))
     }
 
     /// The operation took effect: what it gave up is given up now. A bit the

m2-no-flush-before-the-superblock

--- a/bcachefs/src/fs.rs	2026-10-09 17:59:56
+++ b/bcachefs/src/fs.rs	2026-10-09 17:59:56
@@ -884,7 +884,6 @@
 
     /// Commit the tree as it stands: see this block's header.
     pub fn sync(&mut self) -> Result<(), FsError> {
-        self.io.flush()?;
         // What the volume holds free once this commit's own frees are made.
         self.sb.free_blocks = self.alloc.free_blocks + self.alloc.pending();
         self.sb.next_alloc = self.alloc.next_alloc;

m3-committed-blocks-free-at-once

--- a/bcachefs/src/alloc_bitmap.rs	2026-10-09 17:59:56
+++ b/bcachefs/src/alloc_bitmap.rs	2026-10-09 17:59:56
@@ -170,7 +170,7 @@
     /// them, and otherwise once the next one lands.
     fn give(&mut self, io: &dyn BlockIO, start: u64, count: u32) -> Result<(), FsError> {
         for block in start..start + count as u64 {
-            if !self.shadow || self.fresh.remove(block) {
+            if true || self.fresh.remove(block) {
                 self.set_free(io, BlockNum::new(block))?;
             } else {
                 match self.pending.last_mut() {

m4-a-refused-operation-keeps-its-root

--- a/bcachefs/src/fs.rs	2026-10-09 17:59:56
+++ b/bcachefs/src/fs.rs	2026-10-09 17:59:56
@@ -784,7 +784,6 @@
                 Ok(done)
             }
             Err(e) => {
-                self.sb.root_node = root;
                 self.alloc.fail(&self.io);
                 Err(e)
             }

m5-no-seal

--- a/bcachefs/src/fs.rs	2026-10-09 17:59:56
+++ b/bcachefs/src/fs.rs	2026-10-09 17:59:56
@@ -891,7 +891,6 @@
         self.sb.set_clean(true);
         // From the first superblock write on, this commit may be what a mount
         // finds, even where the write is refused.
-        self.alloc.seal();
         for copy in self.sb.copies() {
             self.sb.write_at(&self.io, copy)?;
             self.io.flush()?;

m6-fileserver-renames-entry-by-entry

--- a/userland/fileserver/src/data.rs	2026-10-09 18:00:09
+++ b/userland/fileserver/src/data.rs	2026-10-09 18:00:09
@@ -687,42 +687,38 @@
                 if to.starts_with(&format!("{from}/")) {
                     return Err(SyscallError::InvalidArgument);
                 }
-                // Every entry beneath, and the directory's own, in one
-                // operation of the format's: the directory moves whole or not at all.
+                // One entry at a time: the format has no rename of a prefix,
+                // so a kill in the middle leaves the directory in two halves,
+                // every entry under exactly one of its names.
                 let prefix = format!("{from}/");
                 let moving: Vec<(String, Kind)> = self
                     .names
-                    .get_key_value(from)
-                    .into_iter()
-                    .chain(self.names.range(prefix.clone()..).take_while(|(n, _)| n.starts_with(&prefix)))
+                    .range(prefix.clone()..)
+                    .take_while(|(n, _)| n.starts_with(&prefix))
                     .map(|(n, k)| (n.clone(), *k))
                     .collect();
-                for (name, _) in &moving {
+                for (name, kind) in &moving {
+                    let moved = format!("{to}/{}", &name[prefix.len()..]);
                     if let Some(node) = self.by_path.get(name).copied() {
                         self.persist(node)?;
                     }
-                }
-                let renames: Vec<(String, String)> = moving
-                    .iter()
-                    .map(|(name, kind)| {
-                        let moved = format!("{to}{}", &name[from.len()..]);
-                        match kind {
-                            Kind::Dir => (format!("{name}/"), format!("{moved}/")),
-                            _ => (name.clone(), moved),
-                        }
-                    })
-                    .collect();
-                let pairs: Vec<(&str, &str)> = renames.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect();
-                mapped("rename", from, self.fs.rename_all(&pairs))?;
-                for (name, kind) in moving {
-                    let moved = format!("{to}{}", &name[from.len()..]);
-                    self.names.remove(&name);
-                    self.names.insert(moved.clone(), kind);
-                    if let Some(node) = self.by_path.remove(&name) {
+                    let (on_disk_from, on_disk_to) = match kind {
+                        Kind::Dir => (format!("{name}/"), format!("{moved}/")),
+                        _ => (name.clone(), moved.clone()),
+                    };
+                    mapped("rename", name, self.fs.rename(&on_disk_from, &on_disk_to))?;
+                    self.names.remove(name);
+                    self.names.insert(moved.clone(), *kind);
+                    if let Some(node) = self.by_path.remove(name) {
                         self.open.get_mut(&node).expect("indexed").path = moved.clone();
                         self.by_path.insert(moved, node);
                     }
                 }
+                if self.names.get(from) == Some(&Kind::Dir) {
+                    mapped("rename", from, self.fs.rename(&format!("{from}/"), &format!("{to}/")))?;
+                    self.names.remove(from);
+                    self.names.insert(to.to_string(), Kind::Dir);
+                }
                 Ok(())
             }
         }

@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #816 at 3adcf42 (round 1). Net git diff --shortstat origin/main...3adcf424e: 9 files, +1050 −358. Production (bcachefs/src and the non-test part of data.rs): about +463 −303. Tests: +532 −55. Issues: +55.

BLOCKER

  • bcachefs/src/fs.rs:895-898, bcachefs/tests/crash.rs:4-9,184-197 — The crash model never tears a write and never mounts from the backup, so nothing tests the backup half of the commit protocol. Every stop lands each 4 KiB write whole or not at all. The primary always parses, so Superblock::read never falls back, and the flush between the primary and the backup protects nothing the oracle checks. The body's claim that a torn primary fails its CRC and the backup is still the last commit (fs.rs:761, body "The commit") has no measurement behind it. Mutation to run: in Mounted::sync, take self.io.flush()?; out of the for copy in self.sb.copies() loop and put it once after the loop. I expect crash.rs and fileserver's three a_directory_rename_* tests to stay green. The model needs one more shape: the stop's write torn at a 512-byte sector boundary (some sectors new, the rest old), at least for writes to blocks 0 and N-1. That mutation must then turn a_directory_rename_is_whole_under_one_name_wherever_the_device_stops red. Without the torn shape, the 1178 stops cannot fail the claim about which superblock copy a mount trusts.
  • bcachefs/src/alloc_bitmap.rs:232-274,249-256; bcachefs/src/btree.rs own — A volume can reach a state where no delete or truncate ever succeeds again, sync or not. On main a delete wrote the leaf in place and needed no block. Now every delete and every update_metadata copies its path through alloc_block/alloc_exact, but NODE_RESERVE is only held against alloc_up_to (file data). So new nodes for names with no data (fileserver's create(path, &[]) at every open(CREATE), and mkdir's path/ entry) take the reserve down to 0. bcachefs/tests/integration.rs's a_metadata_update_that_cannot_be_reinserted… reaches exactly that state with while fs.create("emptyN", b"").is_ok(). Once a sync has freed what was pending, nothing is left to free, and delete gets NoSpace from own, so the volume cannot shrink. There is a second road to the same state: after a crash, free_blocks from the superblock overstates the free space (that issue's own measurement: 322 against 278), so data alone can take the real reserve. The test to add, which I expect red at this head: fill a volume with one-block files until a write is refused; then loop "create empty names until refused, sync" until a create right after a sync is refused; then assert that delete of one file succeeds and, after a sync, a one-block write succeeds. Fix it, for example by holding the reserve against every allocation except the copies a delete or shrink makes. issues/a-full-data-volume-frees-space-only-at-a-commit.md says such a refusal lasts "until fileserver's next sync". That is false of this state, and the state is recorded nowhere.
  • PR body, "Independent oracle" — The mkfs differential has no log at this head. No command, no exit code, no log in the body. logs/mkfs-{base,new}.log and mkfs2-*.log print only "22 files" and "232 files", with no sha256 and no cmp. They are from 17:57, before the head commit (18:38) and before the m1-m5 rounds that followed. "mkfs bytes are unchanged" is the claim that ROOT images, which the kernel parses, did not move. Rerun it at 3adcf42 and post the command, both hashes, the cmp exit and the log.
  • PR body, "T14" — The metal reading is still owed. By the body's own account the change reaches every T14 boot (/state/<service> goes through own and the new commit). metal-stage.log exits 2, which stages the images and touches no machine. This closes when the orchestrator's boot:testcases reading at 3adcf42 is posted.

NOTE

  • userland/fileserver/src/data.rs:23-27, bcachefs/src/fs.rs:754-764 — The contract says a kill leaves the volume as one sync or the other, "whole either way". That holds for names and entries, not for file contents. write (data.rs:551-562) overwrites a page of a committed file in place through the cache. So a kill mid-sync, or a dirty block written out by the cache, leaves that file's committed extents holding some new pages and some old. crash.rs never overwrites a committed page. Narrow the contract to what holds, and file the in-place data overwrite with an exit condition.
  • bcachefs/src/alloc_bitmap.rs:125-143 — succeed and fail throw away set_free's error (let _). In succeed, give's ? also leaves the rest of that run neither freed nor pending. Each of these leaks blocks without a word. Say it in a log line, or record it under issues/a-crash-leaks-the-blocks-of-its-last-commit.md, whose exit then covers it.
  • bcachefs/src/alloc_bitmap.rs:52-56 — NODE_RESERVE = 16 assumes a tree four levels deep and does not grow with depth. Nothing reads DATA's depth (body, "Unsure"). It belongs in the same record as the reserve's other limits.
  • issues/a-directory-rename-on-data-is-not-atomic.md (added in 7429e0a, deleted in 3adcf42) — Neither landing order closes the issue without action in the other branch. Over the branch the file is added and then deleted, a net no-op. If toyos-update verifies a signed package repository and src/publish.rs writes one through it; pkg install <name> waits on an atomic directory rename on DATA #808 (open, not a draft, head 8b7c687) lands first, merging main here keeps toyos-update verifies a signed package repository and src/publish.rs writes one through it; pkg install <name> waits on an atomic directory rename on DATA #808's copy and its citation at issues/a-package-is-a-directory-under-apps-and-the-installer-is-a-program.md:99. The body names only the other order.
  • PR body, "Gates" — the cargo test -p bcachefs --test crash row is backed by crash-5.log (18:34), which predates the head commit. ci-host-4.log (post-commit, EXIT=0, crash.rs at line 630) is what shows it at this head.

SEND BACK

@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

T14 at 3adcf424e, run by the orchestrator: boot:testcases, two boots (testcases-watchdog 79439090…3cfcc7c, testcases c341746f…c53592d, hashes checked), each toyos-metal --fat32-check exit 0; judge EXIT=0, [metal] 249 passed, 0 failed, 2 boot(s); DATA mounted and served on both. This head is superseded by the review's fix round, which owes a new reading.

Japabu and others added 3 commits October 9, 2026 19:55
…crash model tears sectors and mounts from the backup

The review of round 1 found the volume could wedge: every delete and
truncate copies its path through the allocator, NODE_RESERVE was held
only against a file's data, so names with no data (an open(CREATE), a
mkdir) took the reserve down to nothing and, once a sync had freed what
was pending, no delete ever succeeded again. The reserve is now held
against every allocation (`Reserve::Keep`) but those of an operation
that leaves the tree no larger (`Reserve::Draw`): a delete, and an
update whose entry grows no longer, which splits no node. A commit gives
back whatever such an operation drew, so the reserve is whole after every
sync and no state reachable by changes refuses a delete for longer than
the next sync. `a_volume_full_of_empty_names_still_deletes_and_frees` is
red on the round-1 source (the shrink of f00: NoSpace) and green here.

The second road to the same state: after a stop between two commits the
superblock's free count names blocks the bitmap holds taken, so data alone
could take the real reserve. A mount now counts the bitmap's free bits
(`BitmapAllocator::open`); `a_mount_after_a_stop_counts_the_blocks_its_bitmap_holds`
is red on the round-1 source and under a mutation that takes the
superblock's count.

The crash model (`bcachefs/tests/crash.rs`) now lands the stop's write on
a block the layout writes in place (a superblock copy, the bitmap) torn at
every 512-byte sector boundary, first sectors or last, and checks every
state again with block 0 lost, mounting from the backup. Under it the
review's mutation (one flush after both superblock copies, not one
between them) stays green, and has to: a superblock's bytes lie in its
first sector, so a device that writes a sector whole lands each copy old
or new, and no order between the two copies changes what a mount finds.
The flush between them is deleted, and `Superblock::write` is main's
again. Two mutations show the new shapes see what the old did not: the
root's number written past the first sector (red only through a torn
backup with block 0 lost), and the backup written before the flush of
the nodes it names (red only with block 0 lost).

`BitmapAllocator::give` gives up the rest of a run past a bit the device
would not clear, where it stopped; the block it could not free is a leak,
recorded in issues/a-crash-leaks-the-blocks-of-its-last-commit.md with
the frees `succeed` and `fail` drop. The read-write block's contract is
narrowed to names, lengths and extents: a page written over in place is
not shadowed, filed as
issues/a-write-over-a-files-committed-page-is-not-shadowed.md. The
reserve's limits, depth among them, are in
issues/a-full-data-volume-frees-space-only-at-a-commit.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
Found while sizing the full-volume test: on main's crate, 300 names with
no data on a 1024-block volume, one sync after each, took 97 blocks where
a packed tree needs five. `pack` fills each node in key order, so a full
leaf splits into a full node and a node of what is left, and hashed keys
land in the full one again. Not this branch's to fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Round 2 evidence for #816 at 3c280aa99. Mutation patches, each applied with git apply at the head, run (cargo test -p bcachefs --no-fail-fast, then cargo test -p fileserver --lib), and reverted with git apply -R in the same script; tree clean after. Exits are in the body.

The runner

#!/bin/bash
# Apply each patch at the head, run the crate's and fileserver's tests, restore.
cd <worktree> || exit 1
M=<scratch>/mutations
L=<scratch>/logs
echo "head $(git rev-parse HEAD)"
for n in negative-control-whole-change-reverted m1-every-node-in-place m2-no-flush-before-the-superblock m3-committed-blocks-free-at-once m4-a-refused-operation-keeps-its-root m5-no-seal m6-fileserver-renames-entry-by-entry m7-the-root-past-the-first-sector m8-the-backup-before-the-nodes-flush m9-a-mount-takes-the-superblocks-count m10-a-delete-keeps-the-reserve m11-a-shrink-keeps-the-reserve; do
  git apply --check $M/$n.patch || { echo "$n: does not apply"; exit 1; }
  git apply $M/$n.patch
  cargo test -p bcachefs --no-fail-fast > $L/$n.final.bcachefs.log 2>&1; b=$?
  cargo test -p fileserver --lib > $L/$n.final.fileserver.log 2>&1; f=$?
  git apply -R $M/$n.patch
  echo "$n bcachefs EXIT=$b fileserver EXIT=$f"
done
git status --porcelain --ignore-submodules=none

Its log

head 3c280aa999778d7b38721abd28dec170d8bb88a0
negative-control-whole-change-reverted bcachefs EXIT=101 fileserver EXIT=101
m1-every-node-in-place bcachefs EXIT=101 fileserver EXIT=101
m2-no-flush-before-the-superblock bcachefs EXIT=101 fileserver EXIT=101
m3-committed-blocks-free-at-once bcachefs EXIT=101 fileserver EXIT=0
m4-a-refused-operation-keeps-its-root bcachefs EXIT=101 fileserver EXIT=101
m5-no-seal bcachefs EXIT=101 fileserver EXIT=0
m6-fileserver-renames-entry-by-entry bcachefs EXIT=0 fileserver EXIT=101
m7-the-root-past-the-first-sector bcachefs EXIT=101 fileserver EXIT=0
m8-the-backup-before-the-nodes-flush bcachefs EXIT=101 fileserver EXIT=0
m9-a-mount-takes-the-superblocks-count bcachefs EXIT=101 fileserver EXIT=0
m10-a-delete-keeps-the-reserve bcachefs EXIT=101 fileserver EXIT=0
m11-a-shrink-keeps-the-reserve bcachefs EXIT=101 fileserver EXIT=0
EXIT=0

m1-every-node-in-place

--- a/bcachefs/src/alloc_bitmap.rs	2026-10-09 17:59:56
+++ b/bcachefs/src/alloc_bitmap.rs	2026-10-09 17:59:56
@@ -116,7 +116,7 @@
 
     /// Whether a node at `block` may be written where it is.
     pub fn in_place(&self, block: BlockNum) -> bool {
-        !self.shadow || self.op.as_ref().is_some_and(|op| op.taken.contains(block.raw()))
+        true || self.op.as_ref().is_some_and(|op| op.taken.contains(block.raw()))
     }
 
     /// The operation took effect: what it gave up is given up now. A bit the

m2-no-flush-before-the-superblock

--- a/bcachefs/src/fs.rs	2026-10-09 17:59:56
+++ b/bcachefs/src/fs.rs	2026-10-09 17:59:56
@@ -884,7 +884,6 @@
 
     /// Commit the tree as it stands: see this block's header.
     pub fn sync(&mut self) -> Result<(), FsError> {
-        self.io.flush()?;
         // What the volume holds free once this commit's own frees are made.
         self.sb.free_blocks = self.alloc.free_blocks + self.alloc.pending();
         self.sb.next_alloc = self.alloc.next_alloc;

m3-committed-blocks-free-at-once

--- a/bcachefs/src/alloc_bitmap.rs
+++ b/bcachefs/src/alloc_bitmap.rs
@@ -201,7 +201,7 @@
     fn give(&mut self, io: &dyn BlockIO, start: u64, count: u32) -> Result<(), FsError> {
         let mut refused = Ok(());
         for block in start..start + count as u64 {
-            if !self.shadow || self.fresh.remove(block) {
+            if true || self.fresh.remove(block) {
                 if let Err(e) = self.set_free(io, BlockNum::new(block)) {
                     refused = refused.and(Err(e));
                 }

m4-a-refused-operation-keeps-its-root

--- a/bcachefs/src/fs.rs	2026-10-09 17:59:56
+++ b/bcachefs/src/fs.rs	2026-10-09 17:59:56
@@ -784,7 +784,6 @@
                 Ok(done)
             }
             Err(e) => {
-                self.sb.root_node = root;
                 self.alloc.fail(&self.io);
                 Err(e)
             }

m5-no-seal

--- a/bcachefs/src/fs.rs
+++ b/bcachefs/src/fs.rs
@@ -906,7 +906,7 @@
         self.sb.set_clean(true);
         // From the first superblock write on, this commit may be what a mount
         // finds, even where the write is refused.
-        self.alloc.seal();
+
         self.sb.write(&self.io)?;
         self.io.flush()?;
         self.alloc.committed(&self.io)

m6-fileserver-renames-entry-by-entry

--- a/userland/fileserver/src/data.rs	2026-10-09 18:00:09
+++ b/userland/fileserver/src/data.rs	2026-10-09 18:00:09
@@ -687,42 +687,38 @@
                 if to.starts_with(&format!("{from}/")) {
                     return Err(SyscallError::InvalidArgument);
                 }
-                // Every entry beneath, and the directory's own, in one
-                // operation of the format's: the directory moves whole or not at all.
+                // One entry at a time: the format has no rename of a prefix,
+                // so a kill in the middle leaves the directory in two halves,
+                // every entry under exactly one of its names.
                 let prefix = format!("{from}/");
                 let moving: Vec<(String, Kind)> = self
                     .names
-                    .get_key_value(from)
-                    .into_iter()
-                    .chain(self.names.range(prefix.clone()..).take_while(|(n, _)| n.starts_with(&prefix)))
+                    .range(prefix.clone()..)
+                    .take_while(|(n, _)| n.starts_with(&prefix))
                     .map(|(n, k)| (n.clone(), *k))
                     .collect();
-                for (name, _) in &moving {
+                for (name, kind) in &moving {
+                    let moved = format!("{to}/{}", &name[prefix.len()..]);
                     if let Some(node) = self.by_path.get(name).copied() {
                         self.persist(node)?;
                     }
-                }
-                let renames: Vec<(String, String)> = moving
-                    .iter()
-                    .map(|(name, kind)| {
-                        let moved = format!("{to}{}", &name[from.len()..]);
-                        match kind {
-                            Kind::Dir => (format!("{name}/"), format!("{moved}/")),
-                            _ => (name.clone(), moved),
-                        }
-                    })
-                    .collect();
-                let pairs: Vec<(&str, &str)> = renames.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect();
-                mapped("rename", from, self.fs.rename_all(&pairs))?;
-                for (name, kind) in moving {
-                    let moved = format!("{to}{}", &name[from.len()..]);
-                    self.names.remove(&name);
-                    self.names.insert(moved.clone(), kind);
-                    if let Some(node) = self.by_path.remove(&name) {
+                    let (on_disk_from, on_disk_to) = match kind {
+                        Kind::Dir => (format!("{name}/"), format!("{moved}/")),
+                        _ => (name.clone(), moved.clone()),
+                    };
+                    mapped("rename", name, self.fs.rename(&on_disk_from, &on_disk_to))?;
+                    self.names.remove(name);
+                    self.names.insert(moved.clone(), *kind);
+                    if let Some(node) = self.by_path.remove(name) {
                         self.open.get_mut(&node).expect("indexed").path = moved.clone();
                         self.by_path.insert(moved, node);
                     }
                 }
+                if self.names.get(from) == Some(&Kind::Dir) {
+                    mapped("rename", from, self.fs.rename(&format!("{from}/"), &format!("{to}/")))?;
+                    self.names.remove(from);
+                    self.names.insert(to.to_string(), Kind::Dir);
+                }
                 Ok(())
             }
         }

m7-the-root-past-the-first-sector

--- a/bcachefs/src/superblock.rs
+++ b/bcachefs/src/superblock.rs
@@ -147,7 +147,7 @@
 
         Ok(Self {
             block_count: read_u64(b, 12),
-            root_node: BlockNum::new(read_u64(b, 24)),
+            root_node: BlockNum::new(read_u64(b, 600)),
             next_alloc: read_u64(b, 36),
             free_blocks: read_u64(b, 44),
             bitmap_start: BlockNum::new(read_u64(b, 52)),
@@ -172,7 +172,7 @@
 
         write_u64(b, 12, self.block_count);
         write_u32(b, 20, BLOCK_SIZE as u32);
-        write_u64(b, 24, self.root_node.raw());
+        write_u64(b, 600, self.root_node.raw());
         // [32..36] pad — the tree's depth used to live here, and drove three
         // recursions against a 128 KiB kernel stack. The descent ends at a
         // `Node::Leaf` now, so the disk has no say in how deep it goes.

m8-the-backup-before-the-nodes-flush

--- a/bcachefs/src/fs.rs
+++ b/bcachefs/src/fs.rs
@@ -899,6 +899,9 @@
 
     /// Commit the tree as it stands: see this block's header.
     pub fn sync(&mut self) -> Result<(), FsError> {
+        let mut backup = BlockBuf::zeroed();
+        self.sb.write_to(&mut backup);
+        self.io.write(BlockNum::new(self.sb.block_count - 1), &backup)?;
         self.io.flush()?;
         // What the volume holds free once this commit's own frees are made.
         self.sb.free_blocks = self.alloc.free_blocks + self.alloc.pending();

m9-a-mount-takes-the-superblocks-count

--- a/bcachefs/src/alloc_bitmap.rs
+++ b/bcachefs/src/alloc_bitmap.rs
@@ -118,7 +118,7 @@
             bitmap_start: sb.bitmap_start,
             bitmap_blocks: sb.bitmap_blocks,
             total_blocks: sb.block_count,
-            free_blocks,
+            free_blocks: sb.free_blocks + 0 * free_blocks,
             next_alloc: sb.next_alloc,
             shadow: true,
             fresh: Runs::default(),

m10-a-delete-keeps-the-reserve

--- a/bcachefs/src/fs.rs
+++ b/bcachefs/src/fs.rs
@@ -882,7 +882,7 @@
     /// types, where the old shape fell through from File to Symlink after a
     /// non-matching removal and could take two entries out in one call.
     pub fn delete(&mut self, name: &str) -> Result<bool, FsError> {
-        self.atomic(Reserve::Draw, |fs| {
+        self.atomic(Reserve::Keep, |fs| {
             let Some((key, value)) = fs.find_by_name(name)? else { return Ok(false) };
             let extents = fs.decode(&value)?.extents().to_vec();
 

m11-a-shrink-keeps-the-reserve

--- a/bcachefs/src/fs.rs
+++ b/bcachefs/src/fs.rs
@@ -984,7 +984,7 @@
         let new_value = encode_leaf_value(old_key.key_type, leaf.name(), size, mtime, new_extents);
         // An entry no longer than the one it replaces splits no node.
         let reserve = match new_value.len() <= old_value.len() {
-            true => Reserve::Draw,
+            true => Reserve::Keep,
             false => Reserve::Keep,
         };
         let new_entry = Entry { key: old_key, value: new_value };

The review's mutation, at the round-1 sync with the new crash model (before the flush between the copies was deleted)

--- a/bcachefs/src/fs.rs
+++ b/bcachefs/src/fs.rs
@@ -898,6 +898,6 @@
         self.alloc.seal();
         for copy in self.sb.copies() {
             self.sb.write_at(&self.io, copy)?;
-            self.io.flush()?;
         }
+        self.io.flush()?;
         self.alloc.committed(&self.io)
     }
r-one-flush-after-both-copies build EXIT=0
r-one-flush-after-both-copies crash EXIT=0
r-one-flush-after-both-copies fileserver EXIT=0
m7-the-root-past-the-first-sector build EXIT=0
m7-the-root-past-the-first-sector crash EXIT=101
m7-the-root-past-the-first-sector fileserver EXIT=0
m8-the-backup-before-the-nodes-flush build EXIT=0
m8-the-backup-before-the-nodes-flush crash EXIT=101
m8-the-backup-before-the-nodes-flush fileserver EXIT=0
 M bcachefs/src/alloc_bitmap.rs
 M bcachefs/src/btree.rs
 M bcachefs/src/fs.rs
 M bcachefs/tests/crash.rs
 M bcachefs/tests/integration.rs

What m7 and m8 turn red, from their crash logs

1 of 2360 stops tear the volume:
stopped at write 554 of 590 (block 511), alone, whole, block 0 lost: the list: BadMagic { expected: [66, 84, 78, 68], got: [0, 0, 0, 0] }
4 of 2372 stops tear the volume:
stopped at write 555 of 589 (block 511), the epoch's writes before it landed, its first 1 sectors, block 0 lost: unmountable: ChecksumMismatch { block: BlockNum(0), stored: 985600850, computed: 1457330728 }
stopped at write 555 of 589 (block 511), the epoch's writes before it landed, its last 7 sectors, block 0 lost: unmountable: ChecksumMismatch { block: BlockNum(0), stored: 1670885067, computed: 267757489 }
stopped at write 555 of 589 (block 511), alone, its first 1 sectors, block 0 lost: unmountable: ChecksumMismatch { block: BlockNum(0), stored: 985600850, computed: 1457330728 }
stopped at write 555 of 589 (block 511), alone, its last 7 sectors, block 0 lost: unmountable: ChecksumMismatch { block: BlockNum(0), stored: 1670885067, computed: 267757489 }

The full-volume tests on the round-1 source (git diff HEAD -- bcachefs/src at 0c9c05c's parent, reverse-applied) and under m9-m11

== wedge-at-3adcf424e
test a_mount_after_a_stop_counts_the_blocks_its_bitmap_holds ... FAILED
test a_volume_full_of_empty_names_still_deletes_and_frees ... FAILED
thread 'a_mount_after_a_stop_counts_the_blocks_its_bitmap_holds' (231920337) panicked at bcachefs/tests/integration.rs:1054:46:
a shrink of a full volume's file: NoSpace { requested: 1, available: 21 }
thread 'a_volume_full_of_empty_names_still_deletes_and_frees' (231920338) panicked at bcachefs/tests/integration.rs:1054:46:
a shrink of a full volume's file: NoSpace { requested: 1, available: 0 }
test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 54 filtered out; finished in 0.02s
== m9-a-mount-takes-the-superblocks-count
test a_mount_after_a_stop_counts_the_blocks_its_bitmap_holds ... FAILED
test a_volume_full_of_empty_names_still_deletes_and_frees ... ok
thread 'a_mount_after_a_stop_counts_the_blocks_its_bitmap_holds' (231921463) panicked at bcachefs/tests/integration.rs:1054:46:
a shrink of a full volume's file: NoSpace { requested: 1, available: 21 }
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 54 filtered out; finished in 0.02s
== m10-a-delete-keeps-the-reserve
test a_mount_after_a_stop_counts_the_blocks_its_bitmap_holds ... FAILED
test a_volume_full_of_empty_names_still_deletes_and_frees ... FAILED
thread 'a_mount_after_a_stop_counts_the_blocks_its_bitmap_holds' (231922691) panicked at bcachefs/tests/integration.rs:1058:9:
the delete of f00
thread 'a_volume_full_of_empty_names_still_deletes_and_frees' (231922692) panicked at bcachefs/tests/integration.rs:1058:9:
the delete of f00
test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 54 filtered out; finished in 0.02s
== m11-a-shrink-keeps-the-reserve
test a_mount_after_a_stop_counts_the_blocks_its_bitmap_holds ... FAILED
test a_volume_full_of_empty_names_still_deletes_and_frees ... FAILED
thread 'a_mount_after_a_stop_counts_the_blocks_its_bitmap_holds' (231923817) panicked at bcachefs/tests/integration.rs:1054:46:
a shrink of a full volume's file: NoSpace { requested: 1, available: 0 }
thread 'a_volume_full_of_empty_names_still_deletes_and_frees' (231923818) panicked at bcachefs/tests/integration.rs:1054:46:
a shrink of a full volume's file: NoSpace { requested: 1, available: 0 }
test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 54 filtered out; finished in 0.02s
negative-control-whole-change-reverted (bcachefs/src to origin/main, fileserver's rename to main's; this branch's tests)
diff --git a/bcachefs/src/alloc_bitmap.rs b/bcachefs/src/alloc_bitmap.rs
index 55d1d91c4..c6a5db186 100644
--- a/bcachefs/src/alloc_bitmap.rs
+++ b/bcachefs/src/alloc_bitmap.rs
@@ -1,9 +1,5 @@
-use alloc::collections::BTreeMap;
-use alloc::vec::Vec;
-
 use crate::block_io::{BlockBuf, BlockNum, BlockIO, BlockIOExt, BLOCK_SIZE};
 use crate::fs::FsError;
-use crate::superblock::Superblock;
 
 const BITS_PER_BLOCK: u64 = (BLOCK_SIZE * 8) as u64;
 
@@ -26,195 +22,15 @@ pub struct Run {
 ///
 /// The bitmap is stored on disk starting at `bitmap_start` and spanning
 /// `bitmap_blocks` blocks. Each bit represents one block: 1 = used, 0 = free.
-///
-/// **With `shadow` on, no node the last committed tree reaches is written
-/// over, and no block it reaches is handed out again, before the next commit
-/// lands** (`Mounted::sync`): a btree node is written in place only when the
-/// operation in progress took its block ([`Self::in_place`]), and a block
-/// given up is free at once only if no commit has named it since it was taken
-/// (`fresh`); any other waits in `pending` for the commit. An operation
-/// ([`Self::begin`]) gives up its blocks only when it succeeds, and refused
-/// gives back every one it took.
-/// mkfs runs with `shadow` off: nothing it writes is committed until it ends.
 pub struct BitmapAllocator {
     pub bitmap_start: BlockNum,
     pub bitmap_blocks: u64,
     pub total_blocks: u64,
     pub free_blocks: u64,
     pub next_alloc: u64, // cursor — scan starts here, wraps once
-    pub shadow: bool,
-    /// Taken since the last commit's superblock was handed to the device.
-    fresh: Runs,
-    /// Given up, and reached by a tree a mount may still find.
-    pending: Vec<(u64, u32)>,
-    op: Option<Op>,
-}
-
-/// Blocks only an operation that [`Reserve::Draw`]s may take while `shadow`
-/// is on, so a full volume still copies the nodes a delete or a shrink
-/// changes, as many as sixteen between two commits.
-pub const NODE_RESERVE: u64 = 16;
-
-/// Whether an operation may take the blocks [`NODE_RESERVE`] keeps: only one
-/// that leaves the tree no larger, so a commit gives back all it drew.
-#[derive(Clone, Copy, PartialEq, Eq)]
-pub enum Reserve {
-    Keep,
-    Draw,
-}
-
-/// The operation in progress: what it took, and what it gives up if it succeeds.
-struct Op {
-    reserve: Reserve,
-    taken: Runs,
-    given: Vec<(u64, u32)>,
-}
-
-/// Disjoint runs of blocks, by first block, to one past the last.
-#[derive(Default)]
-struct Runs(BTreeMap<u64, u64>);
-
-impl Runs {
-    fn insert(&mut self, start: u64, len: u64) {
-        self.0.insert(start, start + len);
-    }
-
-    fn contains(&self, block: u64) -> bool {
-        self.0.range(..=block).next_back().is_some_and(|(_, &end)| block < end)
-    }
-
-    /// Take `block` out, answering whether it was in.
-    fn remove(&mut self, block: u64) -> bool {
-        let Some((&start, &end)) = self.0.range(..=block).next_back() else { return false };
-        if block >= end {
-            return false;
-        }
-        self.0.remove(&start);
-        if start < block {
-            self.0.insert(start, block);
-        }
-        if block + 1 < end {
-            self.0.insert(block + 1, end);
-        }
-        true
-    }
 }
 
 impl BitmapAllocator {
-    /// The allocator of a mounted volume, its free count read off the bitmap:
-    /// the superblock's is the last commit's, and a stop after it leaves the
-    /// bitmap holding fewer.
-    pub fn open(io: &dyn BlockIO, sb: &Superblock) -> Result<Self, FsError> {
-        let mut free_blocks = 0;
-        let mut buf = BlockBuf::zeroed();
-        for i in 0..sb.block_count.div_ceil(BITS_PER_BLOCK) {
-            io.read(BlockNum::new(sb.bitmap_start.raw() + i), &mut buf)?;
-            let bits = (sb.block_count - i * BITS_PER_BLOCK).min(BITS_PER_BLOCK);
-            let (whole, rest) = buf.0[..bits.div_ceil(8) as usize].split_at((bits / 8) as usize);
-            free_blocks += whole.iter().map(|b| b.count_zeros() as u64).sum::<u64>();
-            free_blocks += rest.first().map_or(0, |b| (!b & ((1u8 << (bits % 8)) - 1)).count_ones() as u64);
-        }
-        Ok(Self {
-            bitmap_start: sb.bitmap_start,
-            bitmap_blocks: sb.bitmap_blocks,
-            total_blocks: sb.block_count,
-            free_blocks,
-            next_alloc: sb.next_alloc,
-            shadow: true,
-            fresh: Runs::default(),
-            pending: Vec::new(),
-            op: None,
-        })
-    }
-
-    /// Begin one operation on the tree, which [`Self::succeed`] or
-    /// [`Self::fail`] ends.
-    pub fn begin(&mut self, reserve: Reserve) {
-        let op = Op { reserve, taken: Runs::default(), given: Vec::new() };
-        assert!(self.op.replace(op).is_none(), "an operation began inside another");
-    }
-
-    /// What an allocation may take now: every free block, but for those
-    /// [`NODE_RESERVE`] keeps from all but an operation that draws on it.
-    fn spare(&self) -> u64 {
-        match !self.shadow || self.op.as_ref().is_some_and(|op| op.reserve == Reserve::Draw) {
-            true => self.free_blocks,
-            false => self.free_blocks.saturating_sub(NODE_RESERVE),
-        }
-    }
-
-    /// Whether a node at `block` may be written where it is.
-    pub fn in_place(&self, block: BlockNum) -> bool {
-        !self.shadow || self.op.as_ref().is_some_and(|op| op.taken.contains(block.raw()))
-    }
-
-    /// The operation took effect: what it gave up is given up now. A bit the
-    /// device would not clear is a leaked block, and no reason to call an
-    /// operation that took effect refused.
-    pub fn succeed(&mut self, io: &dyn BlockIO) {
-        let op = self.op.take().expect("an operation in progress");
-        for (start, count) in op.given {
-            let _ = self.give(io, start, count);
-        }
-    }
-
-    /// The operation was refused: every block it took is free again, and what
-    /// it would have given up stays its owner's. A bit the device would not
-    /// clear is a leaked block, and the refusal already in hand is the answer.
-    pub fn fail(&mut self, io: &dyn BlockIO) {
-        let op = self.op.take().expect("an operation in progress");
-        for (start, end) in op.taken.0 {
-            for block in start..end {
-                self.fresh.remove(block);
-                let _ = self.set_free(io, BlockNum::new(block));
-            }
-        }
-    }
-
-    /// A superblock naming the tree as it stands may land from here on, so
-    /// nothing taken so far is free at once when given up.
-    pub fn seal(&mut self) {
-        self.fresh = Runs::default();
-    }
-
-    /// Blocks given up that a commit landing makes free.
-    pub fn pending(&self) -> u64 {
-        self.pending.iter().map(|&(_, count)| count as u64).sum()
-    }
-
-    /// A commit landed: the blocks only an older tree reached are free.
-    pub fn committed(&mut self, io: &dyn BlockIO) -> Result<(), FsError> {
-        while let Some((start, count)) = self.pending.pop() {
-            for i in 0..count {
-                if let Err(e) = self.set_free(io, BlockNum::new(start + i as u64)) {
-                    self.pending.push((start + i as u64, count - i));
-                    return Err(e);
-                }
-            }
-        }
-        Ok(())
-    }
-
-    /// Give up `count` blocks from `start`: free now if no commit has named
-    /// them, and otherwise once the next one lands. A block whose bit the
-    /// device would not clear is leaked, and the rest are given up still.
-    fn give(&mut self, io: &dyn BlockIO, start: u64, count: u32) -> Result<(), FsError> {
-        let mut refused = Ok(());
-        for block in start..start + count as u64 {
-            if !self.shadow || self.fresh.remove(block) {
-                if let Err(e) = self.set_free(io, BlockNum::new(block)) {
-                    refused = refused.and(Err(e));
-                }
-            } else {
-                match self.pending.last_mut() {
-                    Some((at, n)) if *at + *n as u64 == block => *n += 1,
-                    _ => self.pending.push((block, 1)),
-                }
-            }
-        }
-        refused
-    }
-
     /// Where a block's bit lives, and which bit of that byte it is.
     fn bit_of(&self, block: BlockNum) -> (BlockNum, usize, u8) {
         let byte_idx = block.raw() / 8;
@@ -280,12 +96,8 @@ impl BitmapAllocator {
     /// The run is never empty and may be shorter than asked for, so every
     /// caller has to loop or has to be wrong.
     pub fn alloc_up_to(&mut self, io: &dyn BlockIO, from: u64, wanted: u32) -> Result<Run, FsError> {
-        let spare = self.spare();
-        if spare == 0 {
-            return Err(FsError::NoSpace { requested: wanted, available: 0 });
-        }
         // A zero-length run would let a caller's loop spin without progress.
-        let wanted = wanted.max(1).min(u32::try_from(spare).unwrap_or(u32::MAX));
+        let wanted = wanted.max(1);
         let (start, len) = self.longest_free_run(io, from % self.total_blocks, wanted)?;
         self.reserve(io, start, len.min(wanted))
     }
@@ -293,10 +105,6 @@ impl BitmapAllocator {
     /// Reserve all of `count` or nothing, for callers that cannot place a
     /// short run. Nothing is marked used unless the whole run is there.
     pub fn alloc_exact(&mut self, io: &dyn BlockIO, count: u32) -> Result<Run, FsError> {
-        let spare = self.spare();
-        if spare < count as u64 {
-            return Err(FsError::NoSpace { requested: count, available: spare });
-        }
         let (start, len) = self.longest_free_run(io, self.next_alloc, count)?;
         if len < count {
             return Err(FsError::NoSpace {
@@ -315,10 +123,6 @@ impl BitmapAllocator {
     fn reserve(&mut self, io: &dyn BlockIO, start: u64, len: u32) -> Result<Run, FsError> {
         let start_block = BlockNum::new(start);
         self.set_range_used(io, start_block, len as u64)?;
-        self.fresh.insert(start, len as u64);
-        if let Some(op) = &mut self.op {
-            op.taken.insert(start, len as u64);
-        }
         self.free_blocks -= len as u64;
         self.next_alloc = start + len as u64;
         if self.next_alloc >= self.total_blocks {
@@ -412,21 +216,17 @@ impl BitmapAllocator {
         Ok((start, best_count))
     }
 
-    /// Give up a contiguous range of blocks: inside an operation, once it
-    /// succeeds.
+    /// Free a contiguous range of blocks.
     pub fn free_range(
         &mut self,
         io: &dyn BlockIO,
         start: BlockNum,
         count: u32,
     ) -> Result<(), FsError> {
-        match &mut self.op {
-            Some(op) => {
-                op.given.push((start.raw(), count));
-                Ok(())
-            }
-            None => self.give(io, start.raw(), count),
+        for i in 0..count as u64 {
+            self.set_free(io, BlockNum::new(start.raw() + i))?;
         }
+        Ok(())
     }
 
     /// Initialize bitmap on disk: zero all bitmap blocks, then mark metadata blocks as used.
@@ -448,10 +248,6 @@ impl BitmapAllocator {
             total_blocks,
             free_blocks: total_blocks - metadata_blocks,
             next_alloc: metadata_blocks,
-            shadow: false,
-            fresh: Runs::default(),
-            pending: Vec::new(),
-            op: None,
         };
 
         // Metadata blocks (superblock, bitmap, journal area), and the backup
diff --git a/bcachefs/src/btree.rs b/bcachefs/src/btree.rs
index 5a616feb1..59e6ba3d8 100644
--- a/bcachefs/src/btree.rs
+++ b/bcachefs/src/btree.rs
@@ -365,24 +365,15 @@ fn write_entry(b: &mut [u8; BLOCK_SIZE], offset: usize, key: &Key, value: &[u8])
 /// child's subtree, so the answer is the last child whose key is `<= key`,
 /// defaulting to the first — which covers everything below the second key.
 fn find_child(children: &[Child], key: &Key) -> Option<BlockNum> {
-    children.get(child_index(children, key)).map(|c| c.block)
-}
-
-/// [`find_child`]'s answer as an index into `children`.
-fn child_index(children: &[Child], key: &Key) -> usize {
-    children.iter().take_while(|c| c.key <= *key).count().saturating_sub(1)
-}
-
-/// The block a node the operation in progress changed is written to: its
-/// own where [`BitmapAllocator::in_place`] allows, and otherwise a new one,
-/// the old given up.
-fn own(io: &dyn BlockIO, alloc: &mut BitmapAllocator, block: BlockNum) -> Result<BlockNum, FsError> {
-    if alloc.in_place(block) {
-        return Ok(block);
+    let mut chosen = children.first()?.block;
+    for child in children {
+        if child.key <= *key {
+            chosen = child.block;
+        } else {
+            break;
+        }
     }
-    let moved = alloc.alloc_block(io)?;
-    alloc.free_range(io, block, 1)?;
-    Ok(moved)
+    Some(chosen)
 }
 
 /// Search the B+ tree for an exact key match. Returns the leaf entry's value.
@@ -443,48 +434,27 @@ pub fn search_by_hash(
     }
 }
 
-/// Delete an exact key from the B+ tree: the root after it and the old value,
-/// or `None` with nothing written when no entry has the key.
+/// Delete an exact key from the B+ tree. Returns the old value if found.
 /// Does not merge underflowing nodes — just removes the entry from the leaf.
-pub fn delete(
-    io: &dyn BlockIO,
-    alloc: &mut BitmapAllocator,
-    root: BlockNum,
-    key: &Key,
-) -> Result<Option<(BlockNum, Vec<u8>)>, FsError> {
-    delete_recursive(io, alloc, root, Depth::ROOT, key)
-}
+pub fn delete(io: &dyn BlockIO, root: BlockNum, key: &Key) -> Result<Option<Vec<u8>>, FsError> {
+    let mut block = root;
+    let mut depth = Depth::ROOT;
 
-fn delete_recursive(
-    io: &dyn BlockIO,
-    alloc: &mut BitmapAllocator,
-    block: BlockNum,
-    depth: Depth,
-    key: &Key,
-) -> Result<Option<(BlockNum, Vec<u8>)>, FsError> {
-    match Node::read(io, block)? {
-        Node::Leaf(mut entries) => {
-            let Some(pos) = entries.iter().position(|e| e.key == *key) else {
-                return Ok(None);
-            };
-            let old = entries.remove(pos);
-            let at = own(io, alloc, block)?;
-            Node::Leaf(entries).write(io, at)?;
-            Ok(Some((at, old.value)))
-        }
-        Node::Interior { level, mut children } => {
-            let idx = child_index(&children, key);
-            let child = children.get(idx).ok_or(FsError::CorruptedNode(block))?.block;
-            let Some((moved, old)) = delete_recursive(io, alloc, child, depth.descend(block)?, key)? else {
-                return Ok(None);
-            };
-            if moved == child {
-                return Ok(Some((block, old)));
+    loop {
+        match Node::read(io, block)? {
+            Node::Leaf(mut entries) => {
+                let Some(pos) = entries.iter().position(|e| e.key == *key) else {
+                    return Ok(None);
+                };
+                let old = entries.remove(pos);
+                Node::Leaf(entries).write(io, block)?;
+                return Ok(Some(old.value));
+            }
+            Node::Interior { children, .. } => {
+                let next = find_child(&children, key).ok_or(FsError::CorruptedNode(block))?;
+                depth = depth.descend(block)?;
+                block = next;
             }
-            children[idx].block = moved;
-            let at = own(io, alloc, block)?;
-            Node::Interior { level, children }.write(io, at)?;
-            Ok(Some((at, old)))
         }
     }
 }
@@ -529,8 +499,7 @@ fn walk_recursive(
 
 /// Insert a key-value pair into the B+ tree.
 ///
-/// Returns the root block, which changes when the old root was split or
-/// written somewhere new.
+/// Returns the root block, which changes when the old root was split.
 pub fn insert(
     io: &dyn BlockIO,
     alloc: &mut BitmapAllocator,
@@ -539,29 +508,29 @@ pub fn insert(
 ) -> Result<BlockNum, FsError> {
     check_entry_fits(&entry)?;
 
-    let Placed { at, split } = insert_recursive(io, alloc, root, Depth::ROOT, entry)?;
-    if split.is_empty() {
-        return Ok(at);
+    match insert_recursive(io, alloc, root, Depth::ROOT, entry)? {
+        InsertResult::Done => Ok(root),
+        InsertResult::Split(siblings) => {
+            let level = Node::read(io, root)?
+                .level()
+                .checked_add(1)
+                .ok_or(FsError::CorruptedNode(root))?;
+            let old_min_key = min_key(io, root, Depth::ROOT)?;
+            let new_root_block = alloc.alloc_block(io)?;
+
+            let mut children = alloc::vec![Child { key: old_min_key, block: root }];
+            children.extend(siblings);
+            Node::Interior { level, children }.write(io, new_root_block)?;
+
+            Ok(new_root_block)
+        }
     }
-    let level = Node::read(io, at)?
-        .level()
-        .checked_add(1)
-        .ok_or(FsError::CorruptedNode(at))?;
-    let old_min_key = min_key(io, at, Depth::ROOT)?;
-    let new_root_block = alloc.alloc_block(io)?;
-
-    let mut children = alloc::vec![Child { key: old_min_key, block: at }];
-    children.extend(split);
-    Node::Interior { level, children }.write(io, new_root_block)?;
-
-    Ok(new_root_block)
 }
 
-/// Where an insert left a node: its block, and the siblings it split off, in
-/// key order.
-struct Placed {
-    at: BlockNum,
-    split: Vec<Child>,
+enum InsertResult {
+    Done,
+    /// The node split: these follow it, in key order.
+    Split(Vec<Child>),
 }
 
 fn insert_recursive(
@@ -570,7 +539,7 @@ fn insert_recursive(
     block: BlockNum,
     depth: Depth,
     entry: Entry,
-) -> Result<Placed, FsError> {
+) -> Result<InsertResult, FsError> {
     match Node::read(io, block)? {
         Node::Leaf(mut entries) => {
             match entries.binary_search_by(|e| e.key.cmp(&entry.key)) {
@@ -579,24 +548,32 @@ fn insert_recursive(
             }
             write_or_split(io, alloc, block, Node::Leaf(entries))
         }
-        Node::Interior { level, mut children } => {
-            let idx = child_index(&children, &entry.key);
+        Node::Interior { level, children } => {
+            let mut idx = 0;
+            for (i, child) in children.iter().enumerate() {
+                if child.key <= entry.key {
+                    idx = i;
+                } else {
+                    break;
+                }
+            }
             let child_block = children.get(idx).ok_or(FsError::CorruptedNode(block))?.block;
             let deeper = depth.descend(block)?;
 
-            let Placed { at, split } = insert_recursive(io, alloc, child_block, deeper, entry)?;
-            if at == child_block && split.is_empty() {
-                return Ok(Placed { at: block, split });
-            }
-            children[idx].block = at;
-            for sibling in split {
-                let pos = match children.binary_search_by(|c| c.key.cmp(&sibling.key)) {
-                    Ok(i) => i + 1,
-                    Err(i) => i,
-                };
-                children.insert(pos, sibling);
+            match insert_recursive(io, alloc, child_block, deeper, entry)? {
+                InsertResult::Done => Ok(InsertResult::Done),
+                InsertResult::Split(siblings) => {
+                    let mut children = children;
+                    for sibling in siblings {
+                        let pos = match children.binary_search_by(|c| c.key.cmp(&sibling.key)) {
+                            Ok(i) => i + 1,
+                            Err(i) => i,
+                        };
+                        children.insert(pos, sibling);
+                    }
+                    write_or_split(io, alloc, block, Node::Interior { level, children })
+                }
             }
-            write_or_split(io, alloc, block, Node::Interior { level, children })
         }
     }
 }
@@ -606,23 +583,20 @@ fn write_or_split(
     alloc: &mut BitmapAllocator,
     block: BlockNum,
     node: Node,
-) -> Result<Placed, FsError> {
-    let at = own(io, alloc, block)?;
+) -> Result<InsertResult, FsError> {
     if NODE_HEADER_SIZE + node.payload_size() <= BLOCK_SIZE {
-        node.write(io, at)?;
-        return Ok(Placed { at, split: Vec::new() });
+        node.write(io, block)?;
+        return Ok(InsertResult::Done);
     }
-    Ok(Placed { at, split: split_node(io, alloc, at, node)? })
+    split_node(io, alloc, block, node)
 }
 
-/// Split `node` across `block` and new siblings, answering the siblings. The
-/// blocks it took are the operation's, which gives them back if it fails.
 fn split_node(
     io: &dyn BlockIO,
     alloc: &mut BitmapAllocator,
     block: BlockNum,
     node: Node,
-) -> Result<Vec<Child>, FsError> {
+) -> Result<InsertResult, FsError> {
     match node {
         Node::Leaf(entries) => {
             // One entry is not a split problem. Halving by *count* used to
@@ -636,15 +610,28 @@ fn split_node(
 
             let mut nodes = pack(entries);
             let first = nodes.remove(0);
+            let mut blocks = Vec::with_capacity(nodes.len());
+            for _ in &nodes {
+                match alloc.alloc_block(io) {
+                    Ok(sibling) => blocks.push(sibling),
+                    Err(e) => {
+                        for taken in blocks {
+                            alloc.free_range(io, taken, 1)?;
+                        }
+                        return Err(e);
+                    }
+                }
+            }
+            // The siblings first: a failure before `block` is replaced leaves
+            // the tree as it was and blocks nothing names.
             let mut children = Vec::with_capacity(nodes.len());
-            for node in nodes {
-                let sibling = alloc.alloc_block(io)?;
+            for (node, sibling) in nodes.into_iter().zip(blocks) {
                 children.push(Child { key: node[0].key, block: sibling });
                 Node::Leaf(node).write(io, sibling)?;
             }
             Node::Leaf(first).write(io, block)?;
 
-            Ok(children)
+            Ok(InsertResult::Split(children))
         }
         Node::Interior { level, mut children } => {
             if children.len() < 2 {
@@ -662,7 +649,7 @@ fn split_node(
             Node::Interior { level, children }.write(io, block)?;
             Node::Interior { level, children: right }.write(io, right_block)?;
 
-            Ok(alloc::vec![Child { key: split_key, block: right_block }])
+            Ok(InsertResult::Split(alloc::vec![Child { key: split_key, block: right_block }]))
         }
     }
 }
@@ -819,8 +806,9 @@ mod tests {
         );
     }
 
-    /// A split that finds no block for a later sibling fails its operation,
-    /// which gives back the block it took for the earlier: nothing names it.
+    /// A split that finds no block for a later sibling gives back the ones it
+    /// took for the earlier: nothing names them yet, so nothing else frees
+    /// them.
     #[test]
     fn a_split_short_of_a_sibling_gives_back_the_ones_it_took() {
         let io = crate::block_io::VecBlockIO::new(16);
@@ -833,13 +821,10 @@ mod tests {
         let mut middle: Vec<Entry> = (0..40).map(|_| entry(72)).collect();
         middle.insert(20, entry(MAX_ENTRY_SIZE - KEY_HEADER_SIZE));
         assert_eq!(pack(middle.clone()).len(), 3, "two siblings, and a block for one");
-        alloc.begin(crate::alloc_bitmap::Reserve::Keep);
         assert!(matches!(
             split_node(&io, &mut alloc, leaf, Node::Leaf(middle)),
             Err(FsError::NoSpace { .. }),
         ));
-        assert_eq!(alloc.free_blocks, 0, "the first sibling took the last block");
-        alloc.fail(&io);
         assert_eq!(alloc.free_blocks, 1, "the first sibling's block went back");
     }
 
diff --git a/bcachefs/src/fs.rs b/bcachefs/src/fs.rs
index 7793060e7..e83a49d17 100644
--- a/bcachefs/src/fs.rs
+++ b/bcachefs/src/fs.rs
@@ -4,7 +4,7 @@ use alloc::vec::Vec;
 use core::marker::PhantomData;
 use core::ops::ControlFlow;
 
-use crate::alloc_bitmap::{BitmapAllocator, Reserve};
+use crate::alloc_bitmap::BitmapAllocator;
 use crate::block_io::{BlockBuf, BlockNum, BlockIO, BlockIOExt, DeviceError, BLOCK_SIZE};
 use crate::btree::{self, Entry, Key, KeyType, Node};
 use crate::superblock::{FsUuid, Superblock};
@@ -139,10 +139,12 @@ impl FsError {
 pub struct ReadOnly;
 pub struct ReadWrite;
 
-/// A formatted but not yet mounted filesystem. Used for building images
-/// (mkfs): written in place, since nothing it holds is committed before
-/// [`Formatted::into_io`].
-pub struct Formatted<IO: BlockIO>(Mounted<IO, ReadWrite>);
+/// A formatted but not yet mounted filesystem. Used for building images (mkfs).
+pub struct Formatted<IO: BlockIO> {
+    io: IO,
+    sb: Superblock,
+    alloc: BitmapAllocator,
+}
 
 /// A mounted filesystem. Mode is ReadOnly or ReadWrite.
 pub struct Mounted<IO: BlockIO, Mode = ReadWrite> {
@@ -410,8 +412,9 @@ fn decode_leaf_value(value: &[u8], volume_blocks: u64) -> Result<LeafValue, FsEr
 /// Allocate blocks and write `data` into them, returning the extent list.
 ///
 /// The allocator answers with a run that may be shorter than the request, so
-/// covering `data` takes a loop. A run reserved by an earlier turn of that loop
-/// is the operation's, which gives it back if a later turn fails.
+/// covering `data` takes a loop — and a run reserved by an earlier turn of that
+/// loop is a block the bitmap calls taken that no entry names, once a later
+/// turn fails. Every run goes back before the error does.
 fn write_data(
     io: &dyn BlockIO,
     alloc: &mut BitmapAllocator,
@@ -427,7 +430,10 @@ fn write_data(
     let mut data_offset = 0usize;
 
     while remaining > 0 {
-        let run = alloc.alloc_up_to(io, alloc.next_alloc, remaining)?;
+        let run = match alloc.alloc_up_to(io, alloc.next_alloc, remaining) {
+            Ok(run) => run,
+            Err(err) => return Err(give_back(io, alloc, &extents, err)),
+        };
         push_extent(&mut extents, run.start.raw(), run.len);
 
         let mut buf = BlockBuf::zeroed();
@@ -438,7 +444,9 @@ fn write_data(
                 let len = chunk_end - data_offset;
                 buf.0[..len].copy_from_slice(&data[data_offset..chunk_end]);
             }
-            io.write(BlockNum::new(run.start.raw() + i), &buf)?;
+            if let Err(err) = io.write(BlockNum::new(run.start.raw() + i), &buf) {
+                return Err(give_back(io, alloc, &extents, err));
+            }
             data_offset += BLOCK_SIZE;
         }
 
@@ -448,6 +456,24 @@ fn write_data(
     Ok(extents)
 }
 
+/// Hand back the runs a failed [`write_data`] had already reserved, and return
+/// the failure that stopped it.
+///
+/// Best effort by construction: this runs because something has already gone
+/// wrong, and a bitmap write that also fails has no better answer to give than
+/// the error already in hand.
+fn give_back(
+    io: &dyn BlockIO,
+    alloc: &mut BitmapAllocator,
+    extents: &[Extent],
+    err: FsError,
+) -> FsError {
+    for ext in extents {
+        let _ = alloc.free_range(io, BlockNum::new(ext.start_block), ext.block_count);
+    }
+    err
+}
+
 /// Read file data from a list of extents.
 ///
 /// **`size` sizes the `Vec` and `size` is a number off the disk**, so it is
@@ -549,44 +575,84 @@ impl<IO: BlockIO> Formatted<IO> {
 
         sb.write(&io)?;
 
-        Ok(Self(Mounted { io, sb, alloc, _mode: PhantomData }))
+        Ok(Self { io, sb, alloc })
     }
 
     /// Name this filesystem, so a role's kernel argument can select it.
     ///
     /// A separate act from formatting: a volume nothing names is legal, and
-    /// nothing here invents a name for one. Persisted by [`Self::into_io`].
+    /// nothing here invents a name for one. Persisted by [`Self::sync`], which
+    /// [`Self::into_io`] runs.
     pub fn set_uuid(&mut self, uuid: FsUuid) {
-        self.0.sb.uuid = uuid;
+        self.sb.uuid = uuid;
     }
 
     /// Create a file on the formatted filesystem (used during mkfs).
     pub fn create(&mut self, name: &str, data: &[u8], mtime: u64) -> Result<(), FsError> {
-        self.0.create(name, data, mtime)
+        if name.is_empty() || name.len() > MAX_NAME_LEN {
+            return Err(FsError::NameTooLong { len: name.len(), max: MAX_NAME_LEN });
+        }
+
+        let extents = write_data(&self.io, &mut self.alloc, data)?;
+        let value = encode_leaf_value(KeyType::File, name, data.len() as u64, mtime, &extents);
+        let key = make_key(&self.sb.hash_seed, name, KeyType::File);
+        let entry = Entry { key, value };
+
+        self.sb.root_node = btree::insert(&self.io, &mut self.alloc, self.sb.root_node, entry)?;
+
+        Ok(())
     }
 
     /// Create a symlink on the formatted filesystem.
     pub fn create_symlink(&mut self, name: &str, target: &str, mtime: u64) -> Result<(), FsError> {
-        self.0.put(name, KeyType::Symlink, target.as_bytes(), mtime)
+        if name.is_empty() || name.len() > MAX_NAME_LEN {
+            return Err(FsError::NameTooLong { len: name.len(), max: MAX_NAME_LEN });
+        }
+
+        let target_bytes = target.as_bytes();
+        let extents = write_data(&self.io, &mut self.alloc, target_bytes)?;
+        let value = encode_leaf_value(KeyType::Symlink, name, target_bytes.len() as u64, mtime, &extents);
+        let key = make_key(&self.sb.hash_seed, name, KeyType::Symlink);
+        let entry = Entry { key, value };
+
+        self.sb.root_node = btree::insert(&self.io, &mut self.alloc, self.sb.root_node, entry)?;
+
+        Ok(())
+    }
+
+    /// Finalize the filesystem: write superblock with clean flag.
+    pub fn sync(&mut self) -> Result<(), FsError> {
+        self.sb.free_blocks = self.alloc.free_blocks;
+        self.sb.next_alloc = self.alloc.next_alloc;
+        self.sb.set_clean(true);
+        self.sb.write(&self.io)?;
+        self.io.flush()
     }
 
     /// Mount this formatted filesystem for read-write access.
     pub fn mount(self) -> Mounted<IO, ReadWrite> {
-        let mut fs = self.0;
-        fs.alloc.shadow = true;
-        fs
+        Mounted {
+            io: self.io,
+            sb: self.sb,
+            alloc: self.alloc,
+            _mode: PhantomData,
+        }
     }
 
     /// Mount this formatted filesystem for read-only access.
     pub fn mount_readonly(self) -> Mounted<IO, ReadOnly> {
-        let Mounted { io, sb, alloc, .. } = self.0;
-        Mounted { io, sb, alloc, _mode: PhantomData }
+        Mounted {
+            io: self.io,
+            sb: self.sb,
+            alloc: self.alloc,
+            _mode: PhantomData,
+        }
     }
 
     /// Consume and return the underlying IO (for extracting the image bytes).
     pub fn into_io(mut self) -> Result<IO, FsError> {
-        self.0.sync()?;
-        Ok(self.0.io)
+        self.sync()?;
+        Ok(self.io)
     }
 }
 
@@ -596,7 +662,13 @@ impl<IO: BlockIO, Mode> Mounted<IO, Mode> {
     /// Open an existing filesystem from disk.
     pub fn open(io: IO) -> Result<Mounted<IO, Mode>, FsError> {
         let sb = Superblock::read(&io)?;
-        let alloc = BitmapAllocator::open(&io, &sb)?;
+        let alloc = BitmapAllocator {
+            bitmap_start: sb.bitmap_start,
+            bitmap_blocks: sb.bitmap_blocks,
+            total_blocks: sb.block_count,
+            free_blocks: sb.free_blocks,
+            next_alloc: sb.next_alloc,
+        };
         Ok(Mounted {
             io,
             sb,
@@ -731,9 +803,11 @@ impl<IO: BlockIO, Mode> Mounted<IO, Mode> {
 
     /// Convert back to Formatted state (for testing — insert more files after reading).
     pub fn into_formatted(self) -> Formatted<IO> {
-        let Self { io, sb, mut alloc, .. } = self;
-        alloc.shadow = false;
-        Formatted(Mounted { io, sb, alloc, _mode: PhantomData })
+        Formatted {
+            io: self.io,
+            sb: self.sb,
+            alloc: self.alloc,
+        }
     }
 
     /// Return the extents and file size for a file.
@@ -751,28 +825,6 @@ impl<IO: BlockIO, Mode> Mounted<IO, Mode> {
 
 // --- ReadWrite-only operations ---
 
-/// **Every change is one operation and every sync one commit.** An operation
-/// ([`Mounted::atomic`]) takes effect whole or, refused anywhere, leaves the
-/// tree and the allocator as they were; it never writes over a node the last
-/// commit's tree reaches, but to new blocks up to a new root. A commit
-/// ([`Mounted::sync`]) is both superblock copies naming that root landing on
-/// the device after everything the root reaches. A superblock's bytes lie in
-/// its first 512-byte sector, which a device writes whole, so killed anywhere
-/// each copy names the last commit's tree whole or the new one whole.
-///
-/// That is true of names, and of each entry's length and extents; not of a
-/// file's bytes. A page written over where the file already holds it
-/// ([`Mounted::resolve_or_alloc_block`]) is written in place, so a kill can
-/// leave a file's committed blocks holding pages from both sides of a write
-/// (`issues/a-write-over-a-files-committed-page-is-not-shadowed.md`). What a
-/// kill costs besides is the blocks taken since the last commit and those only
-/// the old tree reached: marked used, and named by no tree, until a sweep
-/// (`issues/a-crash-leaks-the-blocks-of-its-last-commit.md`).
-///
-/// A delete, and an update whose entry grows no longer, may take the blocks
-/// [`crate::alloc_bitmap::NODE_RESERVE`] keeps from every other change, so a
-/// full volume still shrinks, by as many committed nodes as the reserve holds
-/// between two commits (`issues/a-full-data-volume-frees-space-only-at-a-commit.md`).
 impl<IO: BlockIO> Mounted<IO, ReadWrite> {
     /// Create a file, replacing whatever answered to `name`.
     pub fn create(&mut self, name: &str, data: &[u8], mtime: u64) -> Result<(), FsError> {
@@ -784,28 +836,6 @@ impl<IO: BlockIO> Mounted<IO, ReadWrite> {
         self.put(name, KeyType::Symlink, target.as_bytes(), 0)
     }
 
-    /// Run `change` as one operation: whole, or refused with the root and
-    /// every block as they were.
-    fn atomic<T>(
-        &mut self,
-        reserve: Reserve,
-        change: impl FnOnce(&mut Self) -> Result<T, FsError>,
-    ) -> Result<T, FsError> {
-        let root = self.sb.root_node;
-        self.alloc.begin(reserve);
-        match change(self) {
-            Ok(done) => {
-                self.alloc.succeed(&self.io);
-                Ok(done)
-            }
-            Err(e) => {
-                self.sb.root_node = root;
-                self.alloc.fail(&self.io);
-                Err(e)
-            }
-        }
-    }
-
     /// Put `name` on the volume, displacing whatever answered to it.
     ///
     /// The new entry goes in before the old one comes out, for the reason
@@ -830,28 +860,21 @@ impl<IO: BlockIO> Mounted<IO, ReadWrite> {
             return Err(FsError::NameTooLong { len: name.len(), max: MAX_NAME_LEN });
         }
 
-        self.atomic(Reserve::Keep, |fs| {
-            let displaced = match fs.find_by_name(name)? {
-                Some((key, value)) => Some((key, fs.decode(&value)?.extents().to_vec())),
-                None => None,
-            };
-
-            let extents = write_data(&fs.io, &mut fs.alloc, data)?;
-            let value = encode_leaf_value(key_type, name, data.len() as u64, mtime, &extents);
-            let key = make_key(&fs.sb.hash_seed, name, key_type);
-            fs.sb.root_node = btree::insert(&fs.io, &mut fs.alloc, fs.sb.root_node, Entry { key, value })?;
+        let displaced = match self.find_by_name(name)? {
+            Some((key, value)) => Some((key, self.decode(&value)?.extents().to_vec())),
+            None => None,
+        };
 
-            fs.retire_displaced(displaced, key)
-        })
-    }
+        let extents = write_data(&self.io, &mut self.alloc, data)?;
+        let value = encode_leaf_value(key_type, name, data.len() as u64, mtime, &extents);
+        let key = make_key(&self.sb.hash_seed, name, key_type);
+        self.sb.root_node = btree::insert(
+            &self.io, &mut self.alloc,
+            self.sb.root_node,
+            Entry { key, value },
+        )?;
 
-    /// Remove `key`'s entry, if the tree has one.
-    fn remove(&mut self, key: &Key) -> Result<bool, FsError> {
-        let Some((root, _)) = btree::delete(&self.io, &mut self.alloc, self.sb.root_node, key)? else {
-            return Ok(false);
-        };
-        self.sb.root_node = root;
-        Ok(true)
+        self.retire_displaced(displaced, key)
     }
 
     /// Remove the entry the insert of `new_key` did not replace, and free the
@@ -867,9 +890,26 @@ impl<IO: BlockIO> Mounted<IO, ReadWrite> {
     ) -> Result<(), FsError> {
         let Some((old_key, old_extents)) = displaced else { return Ok(()) };
         if old_key != new_key {
-            self.remove(&old_key)?;
+            btree::delete(&self.io, self.sb.root_node, &old_key)?;
+        }
+        for ext in &old_extents {
+            self.alloc.free_range(&self.io, BlockNum::new(ext.start_block), ext.block_count)?;
         }
-        self.free_extents(&old_extents)
+        Ok(())
+    }
+
+    /// Delete a file or symlink by name. Returns true if found and deleted.
+    pub fn delete(&mut self, name: &str) -> Result<bool, FsError> {
+        self.delete_by_name(name)
+    }
+
+    /// Sync filesystem state to disk.
+    pub fn sync(&mut self) -> Result<(), FsError> {
+        self.sb.free_blocks = self.alloc.free_blocks;
+        self.sb.next_alloc = self.alloc.next_alloc;
+        self.sb.set_clean(true);
+        self.sb.write(&self.io)?;
+        self.io.flush()
     }
 
     /// Delete a file/symlink by name, freeing its data blocks. Returns true if found.
@@ -881,55 +921,32 @@ impl<IO: BlockIO> Mounted<IO, ReadWrite> {
     /// caller nothing had happened. It also answers it once for both key
     /// types, where the old shape fell through from File to Symlink after a
     /// non-matching removal and could take two entries out in one call.
-    pub fn delete(&mut self, name: &str) -> Result<bool, FsError> {
-        self.atomic(Reserve::Draw, |fs| {
-            let Some((key, value)) = fs.find_by_name(name)? else { return Ok(false) };
-            let extents = fs.decode(&value)?.extents().to_vec();
-
-            // `find_by_name` reached this key by the descent `btree::delete` is
-            // about to repeat, so an empty removal is not "no such file" — it is a
-            // tree that answers two ways.
-            if !fs.remove(&key)? {
-                return Err(FsError::CorruptedNode(fs.sb.root_node));
-            }
-            fs.free_extents(&extents)?;
-            Ok(true)
-        })
-    }
-
-    /// Commit the tree as it stands: see this block's header.
-    pub fn sync(&mut self) -> Result<(), FsError> {
-        self.io.flush()?;
-        // What the volume holds free once this commit's own frees are made.
-        self.sb.free_blocks = self.alloc.free_blocks + self.alloc.pending();
-        self.sb.next_alloc = self.alloc.next_alloc;
-        self.sb.set_clean(true);
-        // From the first superblock write on, this commit may be what a mount
-        // finds, even where the write is refused.
-        self.alloc.seal();
-        self.sb.write(&self.io)?;
-        self.io.flush()?;
-        self.alloc.committed(&self.io)
+    fn delete_by_name(&mut self, name: &str) -> Result<bool, FsError> {
+        let Some((key, value)) = self.find_by_name(name)? else { return Ok(false) };
+        let extents = self.decode(&value)?.extents().to_vec();
+
+        // `find_by_name` reached this key by the descent `btree::delete` is
+        // about to repeat, so an empty removal is not "no such file" — it is a
+        // tree that answers two ways.
+        if btree::delete(&self.io, self.sb.root_node, &key)?.is_none() {
+            return Err(FsError::CorruptedNode(self.sb.root_node));
+        }
+        for ext in &extents {
+            self.alloc.free_range(&self.io, BlockNum::new(ext.start_block), ext.block_count)?;
+        }
+        Ok(true)
     }
 
     /// Rename a file or symlink.
+    ///
+    /// The new entry goes in before the old one comes out, so a crash between
+    /// the two leaves the file under both names rather than under neither. What
+    /// that ordering costs is that the insert *is* the removal of whatever
+    /// `new_name` named — same name and same type is the same key, and
+    /// `btree::insert` replaces on an equal key — so the displaced entry has to
+    /// be read out of the tree before the insert. Asking for it afterwards, by
+    /// name, answers with the file that was just renamed and frees its extents.
     pub fn rename(&mut self, old_name: &str, new_name: &str) -> Result<(), FsError> {
-        self.rename_all(&[(old_name, new_name)])
-    }
-
-    /// Rename every `(old, new)` pair in turn, as one operation: a directory
-    /// is the names beneath it, and moves whole or not at all.
-    pub fn rename_all(&mut self, renames: &[(&str, &str)]) -> Result<(), FsError> {
-        self.atomic(Reserve::Keep, |fs| renames.iter().try_for_each(|&(old, new)| fs.rename_one(old, new)))
-    }
-
-    /// The new entry goes in before the old one comes out. What that ordering
-    /// costs is that the insert *is* the removal of whatever `new_name` named —
-    /// same name and same type is the same key, and `btree::insert` replaces on
-    /// an equal key — so the displaced entry has to be read out of the tree
-    /// before the insert. Asking for it afterwards, by name, answers with the
-    /// file that was just renamed and frees its extents.
-    fn rename_one(&mut self, old_name: &str, new_name: &str) -> Result<(), FsError> {
         // Every other name-taking entry point bounds its name; this one did
         // not, and `user_ptr::MAX_USER_STR` lets 64 KiB of it through.
         if new_name.is_empty() || new_name.len() > MAX_NAME_LEN {
@@ -965,7 +982,7 @@ impl<IO: BlockIO> Mounted<IO, ReadWrite> {
         // extent list. Nothing to delete when the two names share a key — the
         // entry under it is the one the insert just wrote.
         if new_key != old_key {
-            self.remove(&old_key)?;
+            btree::delete(&self.io, self.sb.root_node, &old_key)?;
         }
 
         Ok(())
@@ -979,27 +996,29 @@ impl<IO: BlockIO> Mounted<IO, ReadWrite> {
         size: u64,
         mtime: u64,
     ) -> Result<(), FsError> {
-        let (old_key, old_value) = self.find_by_name(name)?.ok_or(FsError::NotFound)?;
+        let (old_key, old_value) = self.find_by_name(name)?
+            .ok_or(FsError::NotFound)?;
         let leaf = self.decode(&old_value)?;
+
         let new_value = encode_leaf_value(old_key.key_type, leaf.name(), size, mtime, new_extents);
-        // An entry no longer than the one it replaces splits no node.
-        let reserve = match new_value.len() <= old_value.len() {
-            true => Reserve::Draw,
-            false => Reserve::Keep,
-        };
         let new_entry = Entry { key: old_key, value: new_value };
 
-        self.atomic(reserve, |fs| {
-            // No delete first: the key is unchanged and `btree::insert` replaces
-            // on an equal key. Blocks the caller drops from the extent list are
-            // the caller's to free, through [`Self::free_extents`], after this
-            // records the shortened list.
-            fs.sb.root_node = btree::insert(&fs.io, &mut fs.alloc, fs.sb.root_node, new_entry)?;
-            Ok(())
-        })
+        // No delete first. The key is unchanged and `btree::insert` replaces on
+        // an equal key, so the delete bought nothing and cost the file: a
+        // pre-check for `EntryTooLarge` does not cover `insert`'s other
+        // rejection, a split with no free block to split into, and that one
+        // left the entry deleted and never put back. Blocks the caller drops
+        // from the extent list are the caller's to free, through
+        // [`Self::free_extents`], after this records the shortened list.
+        self.sb.root_node = btree::insert(
+            &self.io, &mut self.alloc,
+            self.sb.root_node,
+            new_entry,
+        )?;
+        Ok(())
     }
 
-    /// Give `extents`' blocks back to the allocator. Record first, free second:
+    /// Return `extents`' blocks to the allocator. Record first, free second:
     /// the caller shortens the entry's list before calling this, so a failure
     /// between the two leaks blocks rather than leaving an entry naming freed
     /// ones.
--- a/userland/fileserver/src/data.rs	2026-10-09 18:00:09
+++ b/userland/fileserver/src/data.rs	2026-10-09 18:00:09
@@ -687,42 +687,38 @@
                 if to.starts_with(&format!("{from}/")) {
                     return Err(SyscallError::InvalidArgument);
                 }
-                // Every entry beneath, and the directory's own, in one
-                // operation of the format's: the directory moves whole or not at all.
+                // One entry at a time: the format has no rename of a prefix,
+                // so a kill in the middle leaves the directory in two halves,
+                // every entry under exactly one of its names.
                 let prefix = format!("{from}/");
                 let moving: Vec<(String, Kind)> = self
                     .names
-                    .get_key_value(from)
-                    .into_iter()
-                    .chain(self.names.range(prefix.clone()..).take_while(|(n, _)| n.starts_with(&prefix)))
+                    .range(prefix.clone()..)
+                    .take_while(|(n, _)| n.starts_with(&prefix))
                     .map(|(n, k)| (n.clone(), *k))
                     .collect();
-                for (name, _) in &moving {
+                for (name, kind) in &moving {
+                    let moved = format!("{to}/{}", &name[prefix.len()..]);
                     if let Some(node) = self.by_path.get(name).copied() {
                         self.persist(node)?;
                     }
-                }
-                let renames: Vec<(String, String)> = moving
-                    .iter()
-                    .map(|(name, kind)| {
-                        let moved = format!("{to}{}", &name[from.len()..]);
-                        match kind {
-                            Kind::Dir => (format!("{name}/"), format!("{moved}/")),
-                            _ => (name.clone(), moved),
-                        }
-                    })
-                    .collect();
-                let pairs: Vec<(&str, &str)> = renames.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect();
-                mapped("rename", from, self.fs.rename_all(&pairs))?;
-                for (name, kind) in moving {
-                    let moved = format!("{to}{}", &name[from.len()..]);
-                    self.names.remove(&name);
-                    self.names.insert(moved.clone(), kind);
-                    if let Some(node) = self.by_path.remove(&name) {
+                    let (on_disk_from, on_disk_to) = match kind {
+                        Kind::Dir => (format!("{name}/"), format!("{moved}/")),
+                        _ => (name.clone(), moved.clone()),
+                    };
+                    mapped("rename", name, self.fs.rename(&on_disk_from, &on_disk_to))?;
+                    self.names.remove(name);
+                    self.names.insert(moved.clone(), *kind);
+                    if let Some(node) = self.by_path.remove(name) {
                         self.open.get_mut(&node).expect("indexed").path = moved.clone();
                         self.by_path.insert(moved, node);
                     }
                 }
+                if self.names.get(from) == Some(&Kind::Dir) {
+                    mapped("rename", from, self.fs.rename(&format!("{from}/"), &format!("{to}/")))?;
+                    self.names.remove(from);
+                    self.names.insert(to.to_string(), Kind::Dir);
+                }
                 Ok(())
             }
         }

@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

mkfs differential for #816 at 0c9c05c67 (3c280aa99 adds only an issue file): the same files in the same order through origin/main's crate (198a9d3, git archive origin/main bcachefs) and through this branch's, every image written by Formatted::into_io.

Inputs: kernel/src at origin/main (231 files, 24 symlinks, a split tree) and main's bcachefs/ (21 files, 3 symlinks), each on a 65536-block volume.

The scratch binary (one copy per crate, [dependencies] bcachefs = { path = ... })

//! mkfs differential: the same files, in the same order, through a crate's
//! `Formatted`, written as an image. Usage: <dir> <blocks> <out>
use std::path::Path;
use bcachefs::{Formatted, FsUuid, VecBlockIO};

fn walk(root: &Path, dir: &Path, out: &mut Vec<(String, Vec<u8>)>) {
    let mut entries: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().path()).collect();
    entries.sort();
    for p in entries {
        if p.is_dir() {
            walk(root, &p, out);
        } else {
            out.push((p.strip_prefix(root).unwrap().to_str().unwrap().to_string(), std::fs::read(&p).unwrap()));
        }
    }
}

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let root = Path::new(&args[1]);
    let blocks: u64 = args[2].parse().unwrap();
    let mut files = Vec::new();
    walk(root, root, &mut files);
    let mut fs = Formatted::format(VecBlockIO::new(blocks)).unwrap();
    for (name, data) in &files {
        fs.create(name, data, 0).unwrap();
    }
    let links: Vec<_> = files.iter().step_by(10).collect();
    for (i, (name, _)) in links.iter().enumerate() {
        fs.create_symlink(&format!("links/{i}"), name, 0).unwrap();
    }
    fs.set_uuid(FsUuid([7; 16]));
    std::fs::write(&args[3], fs.into_io().unwrap().into_vec()).unwrap();
    println!("{} files, {} symlinks", files.len(), links.len());
}

The command

for v in base new; do CARGO_TARGET_DIR=<scratch>/mkfs/target-$v cargo run --release --manifest-path <scratch>/mkfs/mkfs-$v/Cargo.toml -- <scratch>/mkfs/input/kernel/src 65536 <scratch>/mkfs/img-kernel-$v.bin; CARGO_TARGET_DIR=<scratch>/mkfs/target-$v cargo run --release --manifest-path <scratch>/mkfs/mkfs-$v/Cargo.toml -- <scratch>/mkfs/base/bcachefs 65536 <scratch>/mkfs/img-bcachefs-$v.bin; done; shasum -a 256 <scratch>/mkfs/img-*.bin; cmp img-kernel-base.bin img-kernel-new.bin; cmp img-bcachefs-base.bin img-bcachefs-new.bin

The log

head 0c9c05c6787bcecf7e64302c6789b8dc3f7012ba, base 198a9d38e572bbd7664fcec05700fdcb47269d1c
    Blocking waiting for file lock on package cache
     Locking 1 package to latest compatible version
    Blocking waiting for file lock on package cache
    Blocking waiting for file lock on package cache
   Compiling bcachefs v0.1.0 (<scratch>/mkfs/base/bcachefs)
   Compiling mkfs-base v0.0.0 (<scratch>/mkfs/mkfs-base)
    Finished `release` profile [optimized] target(s) in 1.54s
     Running `<scratch>/mkfs/target-base/release/mkfs-base <scratch>/mkfs/input/kernel/src 65536 <scratch>/mkfs/img-kernel-base.bin`
231 files, 24 symlinks
kernel base EXIT=0
    Finished `release` profile [optimized] target(s) in 0.00s
     Running `<scratch>/mkfs/target-base/release/mkfs-base <scratch>/mkfs/base/bcachefs 65536 <scratch>/mkfs/img-bcachefs-base.bin`
21 files, 3 symlinks
bcachefs base EXIT=0
     Locking 1 package to latest compatible version
   Compiling bcachefs v0.1.0 (<worktree>/bcachefs)
   Compiling mkfs-new v0.0.0 (<scratch>/mkfs/mkfs-new)
    Finished `release` profile [optimized] target(s) in 2.20s
     Running `<scratch>/mkfs/target-new/release/mkfs-new <scratch>/mkfs/input/kernel/src 65536 <scratch>/mkfs/img-kernel-new.bin`
231 files, 24 symlinks
kernel new EXIT=0
    Finished `release` profile [optimized] target(s) in 0.00s
     Running `<scratch>/mkfs/target-new/release/mkfs-new <scratch>/mkfs/base/bcachefs 65536 <scratch>/mkfs/img-bcachefs-new.bin`
21 files, 3 symlinks
bcachefs new EXIT=0
b4692a62ea0ac8a8ab787de8cb5af31af8c5f780969cb7479127cd592b480cad  <scratch>/mkfs/img-bcachefs-base.bin
b4692a62ea0ac8a8ab787de8cb5af31af8c5f780969cb7479127cd592b480cad  <scratch>/mkfs/img-bcachefs-new.bin
3383376928e31116123e59e68303af0bfc9f9523a73c2ae57eba1345cea80039  <scratch>/mkfs/img-kernel-base.bin
3383376928e31116123e59e68303af0bfc9f9523a73c2ae57eba1345cea80039  <scratch>/mkfs/img-kernel-new.bin
cmp kernel EXIT=0
cmp bcachefs EXIT=0

@Japabu Japabu changed the title A directory rename on DATA moves whole or not at all, and a sync is a commit a kill cannot tear DATA's filesystem commits atomically, a directory rename moves whole or not at all, and a full volume still shrinks Oct 9, 2026
@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

T14 at 3b43b834a, run by the orchestrator: boot:testcases, two boots, each image's sha256 checked against request.txt before its flash (testcases-watchdog a03f5d04…a9b5fa, testcases e1c7e13a…e02d9f), each toyos-metal --fat32-check exit 0; judge cargo test --test toyos-build -- --metal --metal-readback <dir> boot:testcases: EXIT=0, [metal] 249 passed, 0 failed, 2 boot(s); every boot created and wrote /state/<service> on DATA through the new commit path.

@Japabu

Japabu commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator Author

Review of #816 at 3b43b83 (round 2). Net git diff --shortstat origin/main...3b43b834a: 10 files, +1257 −354. Production (bcachefs/src, and data.rs above its test module): about +493 −296. Tests: about +618 −58. Issues: +116. The production growth is the commit protocol, and I accept it.

Round-1 BLOCKERs

  • CLOSED: the crash model never tore a write or mounted from the backup. crash.rs:187-229 now tears blocks 0, N−1 and the bitmap at every 512-byte boundary, and mounts every state a second time with block 0 lost. The flush mutation I named exits 0 under this model (r-one-flush-after-both-copies.r2a.crash.log: 2 passed). The reason holds in the tree: superblock.rs:165-191 zero-fills the block and writes nothing past byte 122, so every sector tear lands the old copy or the new one. The model has teeth: m7 fails 4 of 2372 states and m8 1 of 2360, and only through a torn backup or a lost block 0 (mutations-r2a.log). Deleting the flush once it had been measured to change nothing is the right answer.
  • CLOSED: the volume could reach a state where no delete or shrink ever succeeds again. Both new tests exit 101 on the round-1 source (wedge-at-3adcf424e.log: NoSpace with 0 available and with 21 available) and pass at the head (wedge-head.log). m9, m10 and m11 each turn them red (mutations-final.log). The literal form of my test cannot hold, and the argument is right: a create pays for its data plus a copy of its path, both held against the reserve, so one freed block cannot buy one. Deleting every file and then creating one after a sync tests the claim instead.
  • CLOSED: the mkfs differential had no log. The comment shows both sha256 sums and cmp exit 0 for both inputs at 0c9c05c. I ran git diff 0c9c05c67 3b43b834a -- bcachefs userland/fileserver: it is empty. The base 198a9d3 is the merge base, and origin/main has not touched bcachefs/ since.
  • CLOSED: the T14 reading was owed. The orchestrator's comment of 18:57Z reads boot:testcases at 3b43b83: judge EXIT=0, 249 passed, 0 failed, 2 boot(s), and both images' sha256 sums match the body's request.txt (e1c7e13a…, a03f5d04…).

BLOCKER

  • bcachefs/src/alloc_bitmap.rs:288 — No test can fail if the reserve's clamp on multi-block data runs is removed. The body claims "the reserve is held against every allocation". Mutation to run: change let wanted = wanted.max(1).min(u32::try_from(spare).unwrap_or(u32::MAX)); to let wanted = wanted.max(1);. I expect cargo test -p bcachefs and cargo test -p fileserver --lib to stay green. Every fill in the suite asks for one block at a time (filled_to_the_last_name, one_block_holes, fragmented). The only multi-block request near the reserve is put's, and its own path copy refuses and rolls it back either way.
    • The defect still lands through resolve_or_alloc_block. It runs outside any operation, so nothing rolls it back. A sparse or multi-page write past a file's end takes every free block, including the reserve. If the new run is contiguous with the file's last extent, the entry does not grow, so update_metadata draws and succeeds, and the next sync commits a volume whose reserve is gone. From then on every delete gets NoSpace from own: the round-1 wedge, reached through data.
    • The test to add: on a volume filled until its spare is k > 0, extend one file with resolve_or_alloc_block to a page more than k past its end, so the run is contiguous; record it with update_metadata; sync; then run shrinks_and_deletes_and_takes_again. It must be green at the head and red under the patch.

NOTE

SEND BACK

Japabu and others added 2 commits October 9, 2026 21:01
…nd only a read-write mount counts the bitmap

The reserve's clamp in `alloc_up_to` had no test that could fail: every
fill in the suite asks for one block at a time. The new test grows a
one-block file, outside any operation, to a page past every block of a
volume whose free blocks all lie in one run after it, so the run is
contiguous and the entry that records it does not grow. With the clamp it
takes what the volume spares and is refused; without it, it takes the
reserve too, and the update that records the run is refused NoSpace.

`BitmapAllocator::open` read every bitmap block on every mount, the
kernel's read-only ROOT mount included, which never reads the free count.
The count moves to `count_free`, which only the read-write `open` calls;
the read-only mount takes the superblock's count, as it did before this
branch, and does no new work and gains no new refusal.

The leak issue's measurement is retaken with the run `crash.rs` makes at
this head: 40 one-block files after the rename, not one 9000-byte file;
284 blocks used where there were 190.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Round-3 evidence at b028cc8

Paths are scrubbed: <worktree> is the branch's worktree, <scratch> the run's scratch directory.

Mutation matrix (mutations.log)

Runner: each patch git apply --checked, applied, cargo build -p bcachefs, cargo test -p bcachefs --no-fail-fast, cargo test -p fileserver --lib, reverted; the tree's status printed after (empty). m1 to m8, m10 and m11 are the round-2 patches (round-2 evidence) and applied unchanged. m9 is rewritten for the moved count, and m12 is new. The negative control is git diff b028cc83f 8e3a81374 -- bcachefs/src (every crate source file back at the merge base), plus the round-2 control's data.rs hunk unchanged.

head b028cc83f14d122d6629a2defb7b5d790654f2c4
negative-control-whole-change-reverted build EXIT=0 bcachefs EXIT=101 fileserver EXIT=101
m1-every-node-in-place build EXIT=0 bcachefs EXIT=101 fileserver EXIT=101
m2-no-flush-before-the-superblock build EXIT=0 bcachefs EXIT=101 fileserver EXIT=101
m3-committed-blocks-free-at-once build EXIT=0 bcachefs EXIT=101 fileserver EXIT=0
m4-a-refused-operation-keeps-its-root build EXIT=0 bcachefs EXIT=101 fileserver EXIT=101
m5-no-seal build EXIT=0 bcachefs EXIT=101 fileserver EXIT=0
m6-fileserver-renames-entry-by-entry build EXIT=0 bcachefs EXIT=0 fileserver EXIT=101
m7-the-root-past-the-first-sector build EXIT=0 bcachefs EXIT=101 fileserver EXIT=0
m8-the-backup-before-the-nodes-flush build EXIT=0 bcachefs EXIT=101 fileserver EXIT=0
m9-a-mount-takes-the-superblocks-count build EXIT=0 bcachefs EXIT=101 fileserver EXIT=0
m10-a-delete-keeps-the-reserve build EXIT=0 bcachefs EXIT=101 fileserver EXIT=0
m11-a-shrink-keeps-the-reserve build EXIT=0 bcachefs EXIT=101 fileserver EXIT=0
m12-data-takes-the-reserve build EXIT=0 bcachefs EXIT=101 fileserver EXIT=0
status after: []
RUN_EXIT=0

m12, the review's clamp mutation

--- a/bcachefs/src/alloc_bitmap.rs
+++ b/bcachefs/src/alloc_bitmap.rs
@@ -285,7 +285,7 @@ impl BitmapAllocator {
             return Err(FsError::NoSpace { requested: wanted, available: 0 });
         }
         // A zero-length run would let a caller's loop spin without progress.
-        let wanted = wanted.max(1).min(u32::try_from(spare).unwrap_or(u32::MAX));
+        let wanted = wanted.max(1);
         let (start, len) = self.longest_free_run(io, from % self.total_blocks, wanted)?;
         self.reserve(io, start, len.min(wanted))
     }

Red under it (m12-data-takes-the-reserve.bcachefs.log); every other test stays green:

99:test a_file_grown_past_what_the_volume_spares_leaves_the_reserve ... FAILED
154:thread 'a_file_grown_past_what_the_volume_spares_leaves_the_reserve' panicked at bcachefs/tests/integration.rs:1087:59:
155:the run recorded: NoSpace { requested: 1, available: 0 }
162:test result: FAILED. 56 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s

Green at the head, alone (clamp-test-head.log, EXIT=0) and in the whole crate (bcachefs-head.log, EXIT=0).

m9, rewritten for the read-write count

--- a/bcachefs/src/fs.rs
+++ b/bcachefs/src/fs.rs
@@ -608,9 +608,7 @@ impl<IO: BlockIO> Mounted<IO, ReadWrite> {
     /// Open an existing filesystem from disk, to change; a mount that only
     /// reads never asks how much is free.
     pub fn open(io: IO) -> Result<Self, FsError> {
-        let mut fs = Self::read_superblock(io)?;
-        fs.alloc.count_free(&fs.io)?;
-        Ok(fs)
+        Self::read_superblock(io)
     }
 }
 
146:test a_mount_after_a_stop_counts_the_blocks_its_bitmap_holds ... FAILED
154:thread 'a_mount_after_a_stop_counts_the_blocks_its_bitmap_holds' panicked at bcachefs/tests/integration.rs:1054:46:
162:test result: FAILED. 56 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s

The leak issue's measurement, retaken

A scratch test appended to bcachefs/tests/crash.rs, run with cargo test -p bcachefs --test crash scratch_leak_measure -- --nocapture (EXIT=0), then the file restored. It replays crash.rs's run (the rename, 40 one-block files, the commit) up to the write before the first superblock write, and counts the bitmap's used bits:

/// Scratch: the bitmap's used count before the run, and after it stopped just
/// before the first superblock write.
#[test]
fn scratch_leak_measure() {
    let image = staged();
    let used = |img: &[u8]| -> u32 {
        let sb = Superblock::read(&VecBlockIO::from_vec(img.to_vec())).expect("sb");
        let at = sb.bitmap_start.raw() as usize * BLOCK;
        (0..BLOCKS as usize).filter(|&b| img[at + b / 8] & (1 << (b % 8)) != 0).count() as u32
    };
    let mut fs = Mounted::<_, ReadWrite>::open(Logged::new(&image, None)).expect("mount");
    rename(&mut fs).expect("the rename");
    for i in 0..40 {
        fs.create(&format!("after{i}"), &[7; BLOCK], 2).expect("a change after it");
    }
    fs.sync().expect("the commit");
    let log = fs.io().log.replace(Vec::new());
    let first_sb = log.iter().position(|e| matches!(e, Event::Write(b, _) if *b == 0 || *b == BLOCKS - 1)).expect("a superblock write");
    let mut held = image.clone();
    for event in &log[..first_sb] {
        if let Event::Write(block, data) = event {
            let at = *block as usize * BLOCK;
            held[at..at + BLOCK].copy_from_slice(&data[..]);
        }
    }
    let renamed = Want::of(&image).held(held.clone());
    println!("MEASURE renamed {} base used {} held used {} of {}", renamed.is_ok() && !renamed.unwrap(), used(&image), used(&held), BLOCKS);
}
6:MEASURE renamed true base used 190 held used 284 of 512

renamed true here means the directory is whole under its old name, the run's answer for a stop before any superblock lands.

@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #816 at b028cc8 (round 3). Net git diff --shortstat origin/main...b028cc83f: 10 files, +1303 −362. Since 3b43b83: bcachefs/src +35 −29, one new 22-line test, an issue's measurement retaken, and a merge of main at 8e3a813 that touches no crate or fileserver byte.

Round-2 BLOCKER

  • CLOSED: no test could fail if the reserve's clamp on multi-block data runs was removed. a_file_grown_past_what_the_volume_spares_leaves_the_reserve (integration.rs:1076-1093) is the test I asked for: the run is contiguous with f00 (it asserts this), it goes through resolve_or_alloc_block, it is recorded with update_metadata, and it is synced and then shrunk and deleted. Green at the head (clamp-test-head.log; bcachefs-head.log: integration 57 passed). Red under my exact patch (m12-data-takes-the-reserve.bcachefs.log: panics at integration.rs:1087 with NoSpace { requested: 1, available: 0 }, 56 passed 1 failed), and every other suite stays green. It fails at the record rather than at the reserve assertion, because the path copy has nothing left to draw on. Either way that is the wedge reached through data. Also checked in round 3: the clamp is at alloc_bitmap.rs:293, mutations.log RUN_EXIT=0 with an empty status after, and the negative control and m1 to m11 re-measured with exits matching the body's table.

Round-2 NOTEs

BLOCKER

NOTE

SEND BACK

@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

T14 at b028cc83f, run by the orchestrator: boot:testcases, two boots, each image's sha256 checked against request.txt before its flash (testcases 76ba2071…19de2ca, testcases-watchdog 8ff7ab74…6c98a29), each toyos-metal --fat32-check exit 0; judge cargo test --test toyos-build -- --metal --metal-readback <dir> boot:testcases: EXIT=0, [metal] 249 passed, 0 failed, 2 boot(s).

@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Reposted by the orchestrator with the T14's model and firmware strings masked; the text is otherwise the reviewer's (original comment 6087785084, deleted because GitHub keeps edit history).

Review of #816 at b028cc8 (round 4, head unchanged since round 3).

Round-3 BLOCKER

  • CLOSED: the T14 reading at b028cc8. In dirrename/metal-r3/request.txt, staged at b028cc8, the two images hash to testcases 76ba2071…2519de2ca and testcases-watchdog 8ff7ab74…a9d086c98a29. The orchestrator's run log records boot rc=0 for each boot, with the same two sha256s. Each boot log runs toyos-metal … --fat32-check and ends toyos-fat32-check: the log partition's 35651584 bytes check out. boot.txt reads the T14's vendor, product and firmware (masked), for both boots, and both verdict.txt files read passed. In judge.log, from the judge command named in request.txt, the summary is [metal] 249 passed, 0 failed, 2 boot(s), and the run log has judge EXIT=0. This is the condition I set, and it is met.

BLOCKER

None.

NOTE

LAND AFTER NAMED CHANGES

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant