Repository navigation
Data-loss burn-down: write-back retry, FAT shrink order and stop recovery, shell redirects, NVMe geometry - #1185
Conversation
A write-back that came due was spent before its sync ran, and a sync the volume refused whole (the cache's flush or the device) returned before anything recorded it, so the dirty blocks the cache still held waited for the next client write or sync. A sync refused for one file stayed due, but was retried and said every two seconds for the rest of the boot. Now every refused sync, whole or for one file, client's or write-back's, keeps the write-back owed: tried again after WRITEBACK, then at an interval that doubles to REFUSED_MAX (64 s), and reset by the first sync that lands. Host test: `writeback::tests::a_refused_sync_stays_due_at_a_backed_off_interval`. Closes issues/a-write-back-the-device-refused-waits-for-the-next-write.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…DATA `issues/a-reallocated-extent-on-data-keeps-the-deleted-files-bytes.md` measured, in 2026-09, a new file whose first extent on the device still held a deleted symlink's bytes while the guest read it right. DATA was then the kernel's BCFS volume; #1152 (e11b6a7) replaced it with native bcachefs, whose file data passes the block cache straight to the device. Nothing had looked again. The host test its exit asks for: a file takes 60% of a 64 MiB volume, is deleted and synced, and a second as large is written, which cannot fit without the freed blocks; the volume is stopped and mounted again, so the read comes from the device, and the second file reads back whole. Negative control: `Cache::write_data` answering Ok without writing fails it: the read is refused (`Io`), the extent's checksum not matching what the device holds. The case of a refused write-back before a stop is issues/a-sync-commits-the-entry-of-a-file-whose-pages-were-refused.md's, which no longer points at the closed file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`set_len` to a shorter length freed the chain's tail at once and recorded the new size only at the caller's next `flush_meta`. A stop between the two left an entry naming more bytes than its chain held, and a shrink to zero an entry naming a free first cluster, which the next allocation handed to another file. `shrink_chain` now writes the entry's shorter size, and for a shrink to zero its cleared first cluster, before the first free. A refused entry write is undone like any call's; once it lands, the free only goes forward. A stop anywhere leaves at most the freed clusters orphaned, which `a_stop_at_any_write_leaves_only_the_named_windows` now holds for both truncation scenarios with their `own` arms deleted. Negative control: the old `shrink_chain` against the stricter test fails at `truncate: stopped at write 2, Refused: /dated.log: DIR_FileSize is 2560 bytes, which needs 5 clusters, and the chain holds 2`. Closes issues/a-shrink-frees-clusters-before-the-entry-stops-naming-them.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`apply_redirect` printed `cannot open: <path>` for a `>` or `>>` target it could not open and its callers spawned the command anyway, its output going to the shell's own; a `2>` target or a pipeline's `<` file that did not open was dropped silently. `echo secret > /refused/path` wrote `secret` to the terminal and answered the command's status. Now every redirection is opened before the spawn. One that does not open is said with the error its open answered, the status is 1, and nothing is run; in a pipeline the stages already started are waited for, ending on the pipe the refused stage would have read. `2>&1` onto a file shares stdout's open file rather than opening the path a second time, whose own offset wrote over stdout's output. `spawn_system` (libc_children) popens `echo unsent > /tmp/no-such-directory/file` and finds no `unsent` on the stream and a non-zero status. Closes issues/the-shell-runs-a-command-whose-redirect-it-could-not-open.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t drive `Controller::open` read three geometry fields partly: - the LBA format index from `FLBAS` bits 3:0 only; bits 6:5 are its upper bits on a namespace of more than 16 formats (NVM Command Set 1.0 §4.1.5.1); - no format's metadata size (`MS`): an extended-LBA format moves more than `2^LBADS` bytes per sector, and a separate-buffer one needs an `MPTR` this driver never sets; - `CC.MPS` written 0 and `MDTS` read in 4 KiB units without `CAP.MPSMIN` (Base 2.0 §3.1.4.1). Now the index is all six bits and checked against `NLBAF`, a format with metadata is refused, and so is a controller whose smallest page is not 4 KiB, each by name as `Refusal::Geometry`. Host tests over modelled Identify Namespace pages and CAP values hold each case. Closes issues/the-nvme-driver-reads-a-namespaces-format-and-page-size-partly.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reset, panic or power loss inside a `toyos-fat32` call leaves what its repair module's open windows name: orphaned clusters, one FAT entry whose copies differ, one partial long-name run, and the growth window's chain past its size. The repair lived in memory and nothing on the mount path took any of it back, so every unclean stop leaked for good and left the free count a stale hint (T14, #971). `Fat32::recover`, which the file server runs on a writable FAT mount before its first write: - every mirror's entry that differs takes the active FAT's value, a state the stopped call or its repair passed through; - the tree is walked from the root, marking each directory's chain and each file's up to its size; a long-name entry no short entry completes is freed, an empty file's cluster is dropped from its entry, and a chain past its size is ended there; - every allocated cluster nothing reached is freed, and the free count is the one that scan finds; - then a sync. Each step only moves towards consistency, so a stop inside it leaves no worse a volume. A cluster two entries reach, a link that is not one, or a chain short of its size is counted and left as it is. It runs on every writable mount rather than on fatgen103's ClnShutBitMask, as the record proposed: the bit would cost a dirty and a clean FAT write around every sync (logkeeper syncs every round), while a scan of the LOG volume's FAT and its small tree costs a few hundred kilobytes read once a boot, and a clean volume is left untouched (`Recovered::nothing`). Tests: - `a_recovery_after_a_stop_at_any_write_leaves_a_consistent_volume` (`lib/fat32/tests/refused_writes.rs`): every scenario stopped at every write, each way, is recovered and then clean under `toyos-fat32-check` with the FATs agreeing, but rename's own cross-link (issues/a-rename-that-stops-between-its-writes-leaves-a-chain-two-entries-reach.md), which it leaves; a second recovery does nothing; and each of the four repairs met a stop that needed it. - The file server's stopped-append test now also mounts each stopped state writable: the checker finds nothing and the file reads as the read-only mount read it. Negative controls, each applied and restored: skipping the lost-cluster free, the mirror copy, the long-name free or the chain end each fails the first test, at `append` write 2, `append` write 1, `extend-dir` write 6 and `append` write 4 respectively. Closes issues/a-stop-mid-append-leaves-a-fat-volume-s-leak-for-ever.md and issues/nothing-reclaims-what-a-stop-leaves-on-a-fat-volume.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
f34bd07's wait on the pipeline's started stages is the first use of `children` that does not fix its element type before `child.wait()`, and the ToyOS build of `shell` refused it (E0282). `cargo run -- --ci host` does not build `shell`, whose manifest names every host as failing; the image build does: `cargo run -- --build-only --arch aarch64` exit 0 with this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
T14 metal at
AMD testcases is still pending. Evidence: https://github.com/ToyOSOrg/evidence/tree/b06d62de9c5125008e84d2eb4dca7682186af061/r2-metal-t14 |
Review r1 of #1185 at f8db19fAssessment. Each fix sits with the component that owns it: write-back policy in the file server, write order in The problem is recovery. It decides what is unreachable from a tree walk that can be incomplete, and it writes before it knows whether it understood the volume. Required1.
In each case the code adds 1 to Concrete cases:
So "counted and left as it is" (module header lines 14–16, the PR's "Cross-links and broken links are counted and left", and "its writes are leak-shaped") is false. LOG is on the boot stick, which macOS mounts read-write, so a FAT that ToyOS did not write is the expected case. This is what the brief means by repairing a FAT it does not understand. 2.
Fix for 1 and 2. Classify the volume read-only first. Write nothing unless everything found is a state a stop can leave. Concretely:
Otherwise refuse by name and leave the volume untouched. I would also make the mount read-only in that case, but how to handle it beyond "write nothing" is your call. This is the step the closed The tests only start from states that ToyOS's own stops produce, so nothing exercises a volume recovery must refuse. Add host cases to the cheapest place, the
Each should assert that recovery changes no byte of the medium. The current code is the negative control for the first two. 3. It prints Suggestions
Evidence
SEND BACK: findings 1–3. |
…g it cannot explain Review r1 of #1185 found that recovery decided what was lost from a walk that could be incomplete and wrote before it knew it understood the volume: a directory it could not walk had every cluster under it freed while its entries still named them, and FAT 0 was copied over a differing mirror with no bound, before anything was checked. LOG is the stick a Mac also writes, so a FAT this crate did not write is the expected case. `Fat32::recover` now examines first and writes nothing on its way: - every directory is walked whole, and every file's chain followed to its size; - each FAT entry whose copies differ is noted. Anything no stop of this crate leaves refuses the recovery by name (`Recovery::Refused`), with every byte left as it was: - a link that is not one; - a directory that does not read whole; - a cluster two different entries reach; - a chain short of its size, or one ending in a link that is not one; - an empty file naming a cluster; - a directory with no `..`; - FAT copies that differ where a copy does not read whole. Only then does it write, each step moving towards consistency. Split copies are a stop's only where each copy reads whole, not where they differ in at most one entry. The file server's cache writes a flush's FAT blocks of both copies in any order, so a stop inside that flush splits every entry of the blocks it had not written. The file server's stopped-flush test met exactly that once recovery was bounded at one. Each copy is then the volume before or after the flush, which the walk over it reads whole (`walk_copy` reads the mirror as the active copy). A copy torn or written by something else does not read whole, and neither copy is written over. The file server serves a FAT volume recovery refused, or could not finish, read-only, and says why. Its recovery line names what each count counts: `ended` is files ended at their size, and the clusters past them are `lost`. The rename a stop leaves half done is resolved here too, by the coordinator's ruling: the order stays insert, erase, repoint. Erasing first would open a window in which neither name resolves, and recovery would free that chain as lost. An entry equal in every field but its name to one the walk already met, reaching the same chain, is the rename's second name. It is erased, so the file keeps its old name or its new one. Each directory's `..` is set to the parent its kept entry is in. Two entries that differ in any other field refuse the recovery. Tests (`lib/fat32/tests/refused_writes.rs`): - `a_recovery_after_a_stop_at_any_write_leaves_a_consistent_volume`, rename no longer exempt: - every scenario is stopped at every write, each way; - each is recovered, then clean under `toyos-fat32-check`; - a second recovery is `Recovered::default()`; - each of the six repairs met a stop that needed it. - Refused, each asserting the medium is byte-for-byte unchanged: - `a_directory_with_a_broken_link_is_refused_and_left`; - `a_file_cross_linked_into_a_directory_is_refused_and_left`; - `a_cross_link_between_two_files_is_refused_and_left`; - `copies_that_differ_where_one_does_not_read_whole_are_refused_and_left` (FAT 0 torn, then the mirror). - `copies_that_differ_where_each_reads_whole_are_recovered`: 128 differing entries, recovered to a clean volume with the files intact. - The file server's stopped-flush test now passes with recovery bounded by readability rather than a count. - `a_volume_recovery_refuses_is_served_read_only_and_untouched`: no write reaches the disk. Negative controls, each applied, run red and restored: - refusing nothing for a broken directory, for a cross-link, or for a copy that does not read whole fails its refusal test; - each of the six repairs skipped fails the stop test or the split test; - serving a refused volume writable fails the file server's test. Closes issues/a-rename-that-stops-between-its-writes-leaves-a-chain-two-entries-reach.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review r1 of #1185: when a pipeline's last redirect does not open, the shell waits for the stages it already started, and `sleep 1000 | cat > /bad` waited on children the interrupt path did not know. They are now the foreground line (`waiting_for`, `waited`), as the ordinary wait's are. Also the blank line between the file server's DATA tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
r2 at
|
Review r2 of #1185 at
|
… two names, a flush apart Review r2 of #1185: a write-back device such as the file server's cache orders nothing between two flushes. Three calls put two writes that must land in order into one flush: - `remove` (the erase and the chain's free; `release_replaced` is a remove); - a shrink (the shorter entry and the free past it); - `rename` (the new name and the old one's erase). A stop that landed the second write without the first left: - a live entry over a freed chain, which recovery now refuses, so one reset during logkeeper's sweep left LOG read-only for good, and on main it mounted writable and later cross-linked; - for a rename, a file under neither name, which recovery would then free. Each now flushes between the two, as growth already keeps its chain a flush before its entry. `crate::repair`'s header states the rule. Evidence, the file server's stopped-flush test generalised (`stopped_anywhere`): an unlink, a shrink and a rename into another directory, each stopped with every earlier flush epoch on the disk and any subset of one epoch's writes. In every state: - a writable mount recovers rather than refuses; - the checker then finds nothing; - the file is whole under one name, or its old or new length, and its neighbour is untouched. Negative controls, the current ordering without each flush, applied and restored: - unlink: recovery refused at epoch 4 writes 0b10; - rename: the file under neither name at epoch 3 writes 0b1; - shrink: recovery refused at epoch 3 writes 0b10. Also from the review: - `recover.rs`'s claim that a stopped flush leaves each copy before or after it was false; the comment now states what keeps a copy reading whole (an entry a flush after its chain, an erase a flush before its free). - `splits` compares every mirror in one pass over an entry, so a volume of more than two FATs counts and rewrites a cluster once. - A rename's second name is matched on every field but the name, now without byte 12, the short name's case, which `insert_entry` does not copy. - The header is rewrapped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Guest run 42023343796 at `33291dbf0` reds `nvme_disk_boots`. LOG's server mounted at 1.983 s and the runner was ready at 2.004 s. The row read the boot log only up to the ready marker, and LOG's `serving` line comes after `Fat32::recover` has read the 34 MiB volume whole (19 ms on the T14's stick, more under QEMU). Being served before the runner is ready is no part of the row's claim, that every role is served off the NVMe disk, so it now waits on the guest for each role's line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
r3 at
|
Review r3 of #1185 at
|
…luded Review r3 of #1185: once the row waits past the runner's READY for each role, its cleanliness check, which read the boot log only up to READY, no longer covered LOG's mount and recovery. It now checks the whole console it accumulated. (A LOG volume recovery refused would also miss the role's marker, `Log serving /log — FAT32, `, which a read-only mount says as `FAT32 read-only, `.) `recover.rs`'s comment points at `crate::repair`'s ordering rule instead of repeating two of its three cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
r4 at
|
|
Guest run 42036235415 at |
Review r4 of #1185 at
|
|
Fresh metal is staged for this head from a clean tree. The coordinator runs it.
|
|
T14 metal at
This is the clean-stick case: the reset left no stop state, and each boot reflashes LOG. The host tests cover the repairs and refusals. AMD testcases has not run; the bench did not answer. I'll judge it once it has. Evidence: https://github.com/ToyOSOrg/evidence/tree/59439f381cc68973bccd6272ba4ce5d57825daf4/r5-metal-t14 |
|
The evidence branch
|
|
AMD metal at
With the T14 boots, both benches pass at this head. Evidence: https://github.com/ToyOSOrg/evidence/tree/7408fab3cd3fdc958780436231dfa5b0aea6ca9b/r6-metal-amd (the machine line is masked). |
Problem
These open
severity: [data-loss]records describe defects that still exist in the current code. Each commit fixes one, in the component that should own the invariant, and deletes its record.a-write-back-the-device-refused-waits-for-the-next-writea-reallocated-extent-on-data-keeps-the-deleted-files-bytesa-shrink-frees-clusters-before-the-entry-stops-naming-them958cc39cb)a-stop-mid-append-…,nothing-reclaims-…,a-rename-that-stops-…Fat32::recoverruns on every writable FAT mount (LOG) and first reads the volume whole without writing: every directory walked, every file chain followed to its size, the FAT copies compared. Anything a ToyOS stop does not leave makes it refuse by name and write nothing; the file server then serves that volume read-only. Otherwise it repairs, in this order: split FAT entries take the active copy, a half-done rename's second name is erased and..repointed, partial long names are erased, chains past their size are ended, and unreached clusters are freed.f8db19fc2, a type annotation the ToyOS build needed)the-shell-runs-a-command-whose-redirect-it-could-not-open2>&1onto a file shares stdout's open file instead of opening the path a second time.the-nvme-driver-reads-a-namespaces-format-and-page-size-partlyNLBAF. It refuses, by name, any format carrying metadata and any controller whoseCAP.MPSMINis not 4 KiB.Decisions
ClnShutBitMask. That bit would add a dirty write and a clean write to FAT[1] around every sync, and logkeeper syncs every round. A full scan of the LOG volume costs a few hundred KiB of reads once per boot. A clean volume is left untouched.Checks
r3 (
e41116092,258e9197d):--ci host: exit 0, 109 steps.--build-only: exit 0.stopped_anywherecovers an unlink, a shrink and a rename: every stop is recovered and checker-clean, and no file is lost. The negative control removes each flush and turns its test red.r2 (
33291dbf0):cargo run -- --ci host: exit 0, 109 steps.cargo run -- --build-onlyandcargo run -- --build-only --arch aarch64: both exit 0.cargo clippy -p toyos-fat32 --all-targets -D warnings: exit 0.lib/fat32/tests/refused_writes.rs):a_volume_recovery_refuses_is_served_read_only_and_untouched.r1, unaffected by r2 (r2 changes only recovery, the file server's FAT mount, the shell's interrupt registration and a blank line):
a_refused_sync_stays_due_at_a_backed_off_interval.a_file_in_a_deleted_files_blocks_holds_its_own_bytes_on_the_device. Negative control: dropping data writes fails it.a_stop_at_any_write_leaves_only_the_named_windows, with both truncateownarms deleted. Negative control: the old order fails attruncatewrite 2.Guest: run 42030077569 is queued at
258e9197d(libc_children,nvme_disk_boots,the_log_lands_on_the_boot_stick).33291dbf0:the_log_lands_on_the_boot_stickpassed andspawn_systempassed.nvme_disk_bootswas red on a race with READY, which258e9197dfixes.tty_interruptwas red too. It reds the same way onkernel-no-tls, so it is a sighting independent of this branch.Metal at
4937c2bdc: T14usbload+testcases2 passed (https://github.com/ToyOSOrg/evidence/tree/4bcf3c4d6243234fc2b10ede9744614ef6529562/r5-metal-t14); AMDtestcasespassed (https://github.com/ToyOSOrg/evidence/tree/7408fab3cd3fdc958780436231dfa5b0aea6ca9b/r6-metal-amd). Both drives up under the geometry checks; LOG served writable after recovery, both sticks clean.668160b1e(r1's recovery): both rows passed. The NVMe geometry checks accepted the T14's drive.668160b1eis still queued.Risks
Fat32::recoverwrites to a stick at mount, but only after reading the whole volume and finding nothing but stop states. Host tests cover every stop state the crate's calls leave, a stopped cache flush, and four kinds of volume it must refuse unchanged. The T14 boots at668160b1eran the r1 recovery on a clean stick only, and show no repair on metal. r2 needs fresh metal.MPSMINis not 0, is now refused instead of mis-driven. No bench drive is expected to have either; the metal boots confirm it.Not fixed here
The open
severity: [data-loss]records left inissues/are not in this PR. The logkeeper and held-file rulings are in #1190, which is stacked on this one.🤖 Generated with Claude Code