Skip to content

fileserver: a write refused after allocating gives back the blocks it took - #1120

Merged
Japabu merged 2 commits into
mainfrom
disk-path-writefail
Nov 1, 2026
Merged

Japabu merged 2 commits into
mainfrom
disk-path-writefail

Conversation

@Japabu

@Japabu Japabu commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Found in review of #1118. DATA's write allocates every page past the file's extents, then writes the pages through the cache. When a page's cache write, or the read of a page written in part, failed, the call returned the error with the pages it never wrote still in the file's extents, past its length. Those blocks held what their last file left there.

A later extending truncate, or a write beyond them, brought them under the file's length. allocate zeroes only from the extents' end, which already included them, so a read returned a deleted file's bytes: one file's contents shown to another's reader. This predates #1118.

Change

DataVolume::write's allocation and its copy feed one result. Any refusal drops the extents back to their length before the call (keep_blocks) and frees the rest (free_extents), as a refused allocation already did. Pages written over in place keep what the call wrote, as before. The record #1118 added is deleted as fixed.

Checks

Evidence: https://github.com/ToyOSOrg/evidence/tree/fd1fd531843b009e0d6eb58be1ce2d8522ec19ee

🤖 Generated with Claude Code

@Japabu

Japabu commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

Review of #1120 at 41efb19 (base disk-path-alloc af6d088)

Architecture

The fix belongs in DataVolume::write. That function owns both the extents grown by Mounted::allocate and the cache writes that fill them, so the decision to roll back has to be made there. The change does not add a new mechanism. It routes copy failures through the rollback a refused allocation already used: keep_blocks(covered) then free_extents. This is the exit the deleted record specified. Production code grows by about 7 lines. The test adds about 40 lines, and the issue file removes 30.

Failure points traced

  • Range overflow (u32::try_from): this refuses before allocate runs. keep_blocks returns nothing and the free is a no-op.
  • Allocation refusals: these include alloc_first_fit running out of space mid-loop after earlier runs were pushed, a short run (the loop simply continues), a refused bitmap mark (set_range_used undoes its own bits, and reserve records nothing in fresh or the counters), and a refused hole-zeroing write in allocate. In each case the extents hold only appended runs past covered, and all of them are dropped.
  • file_entry_fits refusal: handled as before.
  • Copy-phase refusals (whole-page cache.write, the partial-page cache.read, the partial-page cache.write): these are the new coverage. Every page past covered returns to the allocator.
  • Tree update: write performs none. persist and update_metadata run later inside atomic, which restores the root and frees what it took. An entry is never written naming blocks that were rolled back.

Rollback exactness

  • allocate changes the extent list only through push_extent, which either appends or extends the last extent. Both are append-only in page order.
  • keep_blocks(extents, covered) therefore restores exactly the old list. When the old last extent was merged into, it is split back at its original count.
  • The dropped runs are exactly the blocks this call reserved. No block from before the call is dropped, so a double-free is impossible. That matters because set_free has no guard of its own.
  • free_extents runs with no operation open, so it goes free_range → give. Every dropped block is in fresh, because nothing between reserve and the rollback calls seal(). They are freed at once, which is correct because no commit names them.
  • If give is itself refused, the affected blocks leak: their bits stay set and the extents no longer name them. Nothing is exposed. This is the same "record first, free second" outcome as before.

Crash consistency

  • The bitmap is written in place. A kill between reserve and the rollback leaves bits set that no committed tree names. That is the leak already recorded in a-crash-leaks-the-blocks-of-its-last-commit, not an exposure.
  • After a rollback, the root and free_blocks are back to their earlier values. same_commit ignores next_alloc, so a following sync may legitimately skip the superblock write and still flush the restored bitmap.
  • Data that already reached the cache for freed blocks is flushed to unnamed blocks. A later owner's write or zeroing of those blocks goes to the same cache slot as a newer version.

Other paths

  • Extending truncate changes only size, and reads past the extents are zero.
  • A shrinking truncate zeroes the tail of the last kept page before dropping the rest.
  • Sparse writes are zeroed in allocate.
  • A partial new page is zero-filled.
  • The format's own write_data runs inside atomic, which frees what it took on refusal.

With the fix, the extents cover no more than ceil(size) pages on every path I traced. I found no other way to reach a stale block. A refused write can still leave its own bytes in a covered page beyond size. That predates this PR and only ever shows the same file's data.

Evidence

  • fix.diff matches the commit's data.rs diff exactly.
  • The control failed with exit 101, "page 1 reads 0xab". It fails at data.rs:1472, which is the head's assert line (1479) minus the 7 production lines the fix adds. That corroborates the control tree, although the log does not name its revision.
  • cargo test -p fileserver --release: 91 passed.
  • --ci host at 41efb19 (ci-host.rev): exit 0, 105 steps green.
  • A host test is the right level for a refused disk read.

Findings

No blockers.

  • SUGGESTION system/fileserver/src/data.rs:1473 — assert!(v.write(..).is_err()) also passes if the write is refused before it allocates, for example on a future limit check. In that case page 1 is never given a block and the test passes without exercising the fix. assert_eq!(.., Err(Error::Io)) would pin the path. Adding assert_eq!(v.room(), before) around the refused write would also check by test that exactly the new blocks come back, with no leak and no double-free. Today that rests on inspection.
  • SUGGESTION system/fileserver/src/data.rs:1456-1458 — the 512-page size and its comment tie the test to SPREAD and the current placement. If placement changes, page 1 could land in a block that was never used and the test would pass without testing anything. Filling the volume with 0xAB until NoSpace, as fragmented() loops, then deleting it, would make every block stale. The test would then hold regardless of placement and the comment could go.
  • Unrelated, pre-existing, recorded only: DataVolume::write with empty data still runs open.size = open.size.max(end). A zero-length write past EOF therefore extends the file, where POSIX says it has no effect. Nothing stale is exposed, because reads past the extents are zero.

LAND

Japabu and others added 2 commits November 1, 2026 03:20
… took

DATA's write allocates every page past the file's extents and then writes
the pages through the cache. When a page's cache write, or the read of a
page written in part, failed, the call returned the error with the pages it
never wrote still in the extents, past the file's length. Those blocks held
what their last file left there. A later extending truncate, or a write
beyond them, brought them under the file's length, and since `allocate`
zeroes only from the extents' end, a read returned the deleted file's bytes:
one file's contents shown to another's reader.

The allocation and the copy now feed one result, and any refusal drops the
extents back to their length before the call and frees the rest, as a
refused allocation already did. Pages written over in place keep what the
call wrote, as before.

The host test deletes a 512-page file of 0xAB, then writes half of a new
file's page 0, whose read the disk refuses, and page 1, allocated first. It
extends the file and reads zeros in page 1. Before the fix page 1 read 0xab.

Found in review of #1118 (`issues/a-write-that-fails-after-allocating-...`,
deleted here as fixed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ming back

The test now asserts the write is refused with `Io`, the refused read's
word, and that the volume's free room is the same before and after it. A
refusal before allocating, or a leak or a double free, fails it. Every block
of the test disk holds 0xAB before the volume is formatted, so whatever
block page 1 is given holds stale bytes; the 512-page file and its tie to
the allocator's placement are gone. Undoing the drop-back fails the room
check.

The review's unrelated finding is recorded: a zero-length write past the
end extends a DATA file, against POSIX.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu
Japabu force-pushed the disk-path-writefail branch from dacf972 to 185a190 Compare November 1, 2026 02:20
Base automatically changed from disk-path-alloc to main November 1, 2026 02:46
@Japabu
Japabu changed the base branch from disk-path-alloc to main November 1, 2026 02:46
@Japabu
Japabu marked this pull request as ready for review November 1, 2026 02:46
@Japabu
Japabu enabled auto-merge November 1, 2026 02:46
@Japabu
Japabu added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit 3add613 Nov 1, 2026
5 checks passed
@Japabu
Japabu deleted the disk-path-writefail branch November 1, 2026 03:05
Japabu added a commit that referenced this pull request Nov 1, 2026
…erve took

#1120 made DATA's write give back every block it allocated when the copy
that followed was refused. With 7a the allocation is `Volume::reserve` and
the copy is separate (the paged object's write, or `Writes::write`'s
write-backs), so the rebase onto #1120 carries its rule across that split:

- `reserve` answers a `Reserved`: the size and the pages the file held
  blocks for before it.
- `Volume::unreserve(node, reserved, landed)` returns a refused write to
  that size, or to where its bytes landed, and gives back the blocks past
  both. The default is the cut back to that size, which suffices on a
  format without holes (FAT). DATA keeps blocks only up to what the file
  held, since a write into a hole below the old end takes a block a cut to
  the old size would keep.
- `Files::write` and `Writes::write` call it on a refusal; `Files::write`
  no longer truncates for it.

DATA's unreserve records no entry: a file's entry is written only by its
own calls (close, rename, truncate, sync), none of which runs inside a
write, so nothing named the reserve's blocks.

#1120's test is back, through `Writes::write`, beside one for the hole:
both fail with DATA's unreserve deleted, and with `Writes::write` giving
nothing back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 1, 2026
…erve took

#1120 made DATA's write give back every block it allocated when the copy
that followed was refused. With 7a the allocation is `Volume::reserve` and
the copy is separate (the paged object's write, or `Writes::write`'s
write-backs), so the rebase onto #1120 carries its rule across that split:

- `reserve` answers a `Reserved`: the size and the pages the file held
  blocks for before it.
- `Volume::unreserve(node, reserved, landed)` returns a refused write to
  that size, or to where its bytes landed, and gives back the blocks past
  both. The default is the cut back to that size, which suffices on a
  format without holes (FAT). DATA keeps blocks only up to what the file
  held, since a write into a hole below the old end takes a block a cut to
  the old size would keep.
- `Files::write` and `Writes::write` call it on a refusal; `Files::write`
  no longer truncates for it.

DATA's unreserve records no entry: a file's entry is written only by its
own calls (close, rename, truncate, sync), none of which runs inside a
write, so nothing named the reserve's blocks.

#1120's test is back, through `Writes::write`, beside one for the hole:
both fail with DATA's unreserve deleted, and with `Writes::write` giving
nothing back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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