Skip to content

Consolidate storage around native bcachefs and userland filesystems - #1152

Merged
Japabu merged 104 commits into
mainfrom
codex/storage-consolidation
Nov 5, 2026
Merged

Japabu merged 104 commits into
mainfrom
codex/storage-consolidation

Conversation

@Japabu

@Japabu Japabu commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

What this changes

Replace the private BCFS/BTND DATA format with the native bcachefs engine. Move the RAM-backed filesystem into the userland file server and remove the kernel VFS, tmpfs, pathname syscalls and associated file cache. Rust std and libc resolve files through the capability namespace; the kernel receives executable memory objects instead of opening paths.

The branch also preserves served descriptor ownership across spawn, obtains authoritative file metadata from the server, adds native DATA writes, sparse-bucket evacuation and reserve-protected reclaim (an ordinary transaction's keys go into the leaves it holds, as upstream bcachefs does; replayed keys and reclaim's own updates wait in pending runs), and provides an explicitly authorized disks format data --commit preparation path with a two-boot persistence test.

Why

Persistent DATA must use the format the crate claims to implement, and filesystem parsing and pathname policy belong in userland. This consolidates the existing storage paths rather than retaining both implementations.

Evidence

Round 4 (94fdd7faa, on the root-cause fixes 938f1e701..164f391a5) — https://github.com/ToyOSOrg/evidence/tree/18bed1cfe6cb3bd86e0aca385445df8be0680a60/storage-performance and https://github.com/ToyOSOrg/evidence/tree/b30cf0b9c0e2adbb8882674b8a306f543d4247b8/data-mixed-volume:

  • The root-cause fixes for the 11-02 DATA incident are integrated: a superblock copy another writer overwrote is refused (Overwritten), checksum-none metadata is refused, mkfs erases its copies' reserves and the tail, replay tests cover the benches' states. cargo run -- --ci host at 164f391a5: exit 0, 109 steps.
  • bench_data_formatted (disks format data --commit) at 164f391a5 passed on both benches: each found the old contents, destroyed them and mounted the new bcachefs volume. Both benches' DATA now hold this branch's format.
  • Performance defect, fixed. On a host model of the T14's 170 GiB DATA (no device latency), osbench's persist and fs.disk calls at a tenth of their repetitions took ~500 s against 0.47 s on main: pending, bounded only by the journal, reached 326k entries, and every transaction rebuilt nodes over it, re-summed accounting, rescanned it on every flush. Now: 7.3 s, back to back with main's 0.46 s. Metadata rows are at or under main's; the remainder is data writes (each updates seven btrees), measured CPU only. Levelled runs (<= 3 per lookup) inside the old design reached 16.4 s, so the design changed (commit message). Whether the remainder shows at NVMe latency is for the bench comparison staged below.
  • cargo test --release -p bcachefs at 94fdd7faa: exit 0, 186 passed; clippy (no default features, -D warnings) exit 0; cargo run -- --ci host: exit 0, 109 steps. Nine negative controls, each applied and restored by script: eight red, one (a truncation cursor) green, which showed that code redundant; it was removed before the commit.

Earlier rounds:

Integration checkpoints f32a98839, db6cab94a (main e3a421563), 7987307ff, e7f2a85b0 (#934 merged in) f14f79e50 (geometry fix) and c12721aa1 (R1 fix, 16 KiB floor, review r2 S1/S3) (sanitized logs, README per file: https://github.com/ToyOSOrg/evidence/tree/181e5b9460dc61805cf2b216cecd20fd27f7dd22/storage-integration):

  • cargo run -- --ci host: 109 steps green at each checkpoint; the latest, exit 0, at c12721aa1.
  • cargo run -- --ci image (x86-64 and AArch64): exit 0 at f32a98839, db6cab94a, 7987307ff and e7f2a85b0.
  • cargo test -p bcachefs --lib at c12721aa1: exit 0, 180 passed; no-default-features clippy exit 0.
  • libc_children (QEMU x86-64) at e7f2a85b0: exit 0.
  • The changed metal-only guest binaries (process_tree, lseek_past_eof, fs_truncate_persist, spawn_from_tmp, abuse_elf_loader) build for x86_64-unknown-toyos with no warnings; none is booted by a run above.
  • cargo test -p bcachefs --lib at 4e829b244 (reclaim integrated): exit 0, 169 passed; cargo clippy -p bcachefs --lib --no-default-features -- -D warnings: exit 0.
  • libc_children (QEMU x86-64, tests/images/libccase.toml) at 25f19a076 plus the then-uncommitted served-stdout case, identical to 48316fc0e's: exit 0, every member including the new spawn_served_stdout. Negative control: toyos::fd::copy refusing a served file builds, and only spawn_served_stdout fails, at system, errno EOPNOTSUPP.
  • fileserver's a_write_a_gibibyte_past_the_end_leaves_a_hole (in the host run): passes on a 64 MiB volume, which no bridged gap could fit.

Earlier evidence, by checkpoint (not rerun; raw logs in local scratch, not yet published):

  • cargo run -- --ci host at 63783da5d: exit 0, 109 steps.
  • Both benches, formatter checkpoint cff9c3ebd: explicit native DATA formatting, write/sync, reboot and read-back; bench_data_formatted and bench_data_survives_reboot green. Upstream bcachefs-tools v1.39.7 fsck -n -K exit 0 on both benches' DATA. Plain and encrypted host artifacts passed upstream fsck and independent FUSE reads, including 1,600 retained sparse files.
  • Both benches at 605c7c3cb: 14 Rust/C file, descriptor and pager tests plus launch, badge and app-view checks, 17 green.
  • Reclaim commits on their own branch (0249f7f23 content): whole-removal, metadata-full and DATA-full reopen, low-journal-headroom crash oracle (plain and encrypted, 8 cut points × 4 variants), the floor-disabled control failing at removal 865, and provenance negative controls. Independent delta review found no further confirmed defect. Integrated here as 11632e8b6 and 4e829b244, with two conflict hunks (a doc comment and can_free's terms).

Landing boots at 57dedf1, judged member by member

Evidence: https://github.com/ToyOSOrg/evidence/tree/ea7487da78572a00b9436afe13e3a4260b225f35/storage-integration/r15-member-judging. The coordinator's proctreecase, testcases and testcases-watchdog boots on both benches, judged by the staging command run again with the readbacks in place: T14 270 passed, 6 failed; AMD 269 passed, 7 failed. test_rs_pipe_other_end_gone, test_rs_readdir_bound and 207_libc_names exit 0 on both. The reboot pair's evidence at 9fe8f779e stands (only tests changed since).

Every remaining red is cross-bench or pre-existing, each red on the K4 baseline 8ac4f2187 too:

  • T14: idle_entered, amdvi_translates_the_boot_controller, amdvi_remaps_the_boot_controller, amdvi_usbd_drives_its_own_entry (AMD rows) and console_uart_driven (no COM1, and that AMD row's panic) — issues/a-metal-row-names-no-machine-so-another-benchs-boot-carries-it.md; i219_messages_on_its_msi_vector — issues/i219-msi-passes-only-when-the-network-sends-it-a-frame.md.
  • AMD: acpi_server_events, battery_read, usbd_drives_the_type_c_controller, i219_messages_on_its_msi_vector (the T14's rows) and console_uart_driven — the same cross-bench record and issues/toyos-runs-the-machine-in-acpi-mode-and-interprets-its-aml.md; claim_reuses_its_remapping_entry and counters (its judge requires an HWP declaration the AMD bench has not) — issues/a-metal-row-names-no-hardware-and-a-bench-without-it-reds.md.

Round 13: two testcases members

Evidence: https://github.com/ToyOSOrg/evidence/tree/ac3e8b1969e08e85ff63e58ea82d9426e43c0e09/storage-integration/r13-testcases-members. The landing boots at 9fe8f779e passed as boots, but two testcases members were red on both benches and not on the K4 baseline (6fd7151b6 fixes both tests):

  • test_rs_pipe_other_end_gone: this branch had replaced the kernel file handle (removed) with a memory object for "an object with no other end". A memory handle has no WAIT right, so the kernel refuses the watch PermissionDenied before asking whether anything could answer it; the test expected NotSupported. It now uses an event's wait side (toyos::wake::pair()'s Bell, 57dedf1f3), which is watchable and readable and is no pipe end. The kernel's order is right: a holder without WAIT learns nothing about the object.
  • 207_libc_names: readdir of /tmp gave DT_UNKNOWN for regular files under the kernel's tmpfs; the file server's memory volume carries each entry's kind, so libc now reports DT_REG. That is right: POSIX leaves d_type to the implementation, and Linux (tmpfs, ext4) reports DT_REG. The case names DT_REG and expects it.
  • Not a judge gap: verdict.txt is the boot's own verdict by design; the members are judged by running the staging command again with the readbacks in place, which reds both (offline judgement in the evidence).
  • The shared members do not run as filtered QEMU rows, so the guest runner cannot reproduce them; the restaged testcases boots judge the fixes.
  • readdir_bound also failed the judge on both benches ("never ran"). This branch's f916f41ed had moved it off its own boot onto the testcases arm but left it in RUST_SKIP and out of that arm's jobs, so nothing ran it; the K4 baseline gave it its own testcases-readdir boot, which the baseline run at 8ac4f2187 did not include either (test_rs_readdir_bound appears nowhere in its T14 or AMD testcases log), so the baseline has no result for it. Since the test now cleans up after itself on the served /tmp, 275a9e6b8 makes it a discovered member of the shared boot again (its skip entry and explicit row removed).

Round 12: review r8, a take's whole credit granted

Evidence: https://github.com/ToyOSOrg/evidence/tree/c00a7606e1de3ea0be69a5d0b7342c7f1dade0b7/storage-integration/r12-review-r8. cargo run -- --ci host at 9fe8f779e: exit 0, 108 steps. AArch64 rows at c4d1db6e2 (runner run 41947682423): 5 passed, all green; rerun at 9fe8f779e: run 41953629015.

  • r8 B1 (9fe8f779e, the reviewer's design): admission holds a metadata credit apart from the journal bound (Mount::transact_holding; transact is it at its bound's bytes), counted in granted_bytes. A take that opens a bucket is granted its whole credit, so data_space's bucket check sees it and a refusal follows grant's settled rule; the in-transaction check is gone. The in-memory take from the open bucket stays judged by room_for for its whole credit.
  • Evidence: in tests, after every take, everything promised still fits (room_for(0, FOOTPRINT)). A near-full test fills the 64 MiB image (32 KiB buckets, eight-block runs) with 32 KiB and 16 KiB reservations and writes every block back; red when the grant holds one transaction's credit for a take. Not shown by a control: reverting the in-memory judgement to one credit stays green on this image; fills tried ended credit-bound with the open bucket full, or data-bound with credit to spare, so no built state put an in-memory take across the boundary. That path's judgement is room_for of the take's whole credit, the same rule grant applies.
  • Suggestion: extended_from's doc.

Round 11: review r7

Evidence: https://github.com/ToyOSOrg/evidence/tree/f091fd2a11bb0304b37e82a5c90812e97650106d/storage-integration/r11-review-r7. cargo run -- --ci host at c4d1db6e2 (main eae2c7eb7, #1180, merged cleanly): exit 0, 108 steps.

  • B1 (79cd415d4): the journal-size refusal sized mkfs's ring before the shape refusals, so an 8 KiB-bucket volume reached Writer::new's assert in the file server. It is now the last refusal; the shape test has an 8 KiB-bucket case (it panicked before the move).
  • B2: Mount::extend is whole or refused: on any error it cuts the reservation back to where it started. Test near full, red without the cut.
  • B3: superseded by round 12 (r8 found the transaction path's bucket check unjudged and the assert circular).
  • B4: room_for is above grant's doc again. Suggestion: extended_from has its own doc line.
  • The host model's write costs are unchanged by these (4 KiB calls 0.9 + 4.1 us a page; scattered overwrites 30.8 us), and the normal path does the same work, so no further osbench round: the changes bite only near full or on a refusal.
  • AArch64 rows at a1f07627e (runner run 41941136592): 5 passed; the job was red only on a leftover in its $TMPDIR (rustix_test_can_compile, a build-script probe), which build: bootstrap gets a $TMPDIR of its own, gone when it returns #1180, now merged, addresses. Rerun at c4d1db6e2: run 41947682423.

Where it stands against main, and why it lands anyway

osbench at a1f07627e on both benches (coordinator's runs; osbench_persist exited 0 on both), against the same nightly as before (main dc1a8642e on its private format, Linux ext4). p50, one run a side. Evidence: https://github.com/ToyOSOrg/evidence/tree/760ea485db3b1b2016f7c1748901c352c146449a/storage-integration/r10-known-gaps.

Behind main:

  • write_4k_at 2.35x (T14) / 2.52x (AMD): each 4 KiB overwrite is a copy-on-write replace transaction (30.6 us of host CPU, spread with no single cost). Recorded: issues/a-4-kib-overwrite-on-data-costs-about-2-4-times-mains.md.
  • readdir.1000 3.15x / 2.87x: a listing reads every entry's inode with its own lookup (416 of 534 us on the host model) and each READDIR request lists the whole directory again. Recorded: issues/a-data-directory-listing-reads-every-entrys-inode-alone.md.
  • tree.100 1.53x / 1.41x, fstat and direct.fstat 1.22-1.26x, stat and direct.stat 1.17-1.20x, and on the AMD seq_write.4096 0.78x (164 against 211 MB/s; the T14 is 1.22x). Recorded together: issues/smaller-data-rows-trail-main-by-up-to-half.md.

Why it lands anyway: reads through the paged object are 8-13x faster than main (read_4k, pread_4k, pread_8, read_4k_at 0.07-0.15x of main's time; seq_read.4096 9.6x and 15x the throughput), metadata is near main (open_close 0.92-1.09x, rename and mkdir_rmdir at or under, create_write_unlink 0.79-1.06x), and sequential writes are at or above main (seq_write.1048576 1.30-1.32x; seq_write.4096 1.22x on the T14).

fs.disk at a1f0762 (p50)

T14

row unit Linux main (dc1a864) #1152 r1 (94fdd7f) #1152 (a1f0762) /main /Linux
fs.disk.create_write_unlink ns 11206 161348 195115 128800 0.80 11.49
fs.disk.direct.fstat ns - 8450 11824 10639 1.26 -
fs.disk.direct.pread_4k ns - 9430 10909 10152 1.08 -
fs.disk.direct.read_4k_at ns - 17753 20595 18712 1.05 -
fs.disk.direct.seek ns - 8348 9588 8497 1.02 -
fs.disk.direct.stat ns - 10657 49134 12493 1.17 -
fs.disk.fstat ns 224 8719 12207 10857 1.25 48.47
fs.disk.mkdir_rmdir ns 15176 84955 116600 81333 0.96 5.36
fs.disk.open_close ns 1252 26222 67881 24185 0.92 19.32
fs.disk.pread_4k ns 590 9584 945 964 0.10 1.63
fs.disk.pread_8 ns 377 8959 687 644 0.07 1.71
fs.disk.read_4k ns 539 9576 951 952 0.10 1.77
fs.disk.read_4k_at ns 728 10012 1245 1211 0.12 1.66
fs.disk.readdir.1000 ns 273843 908345 2751006 2863176 3.15 10.46
fs.disk.rename ns 3624 87717 77669 45751 0.52 12.62
fs.disk.reopen_read_4k ns - - 68950 24899 - -
fs.disk.seek ns 120 264 284 243 0.92 2.02
fs.disk.seq_read.1048576 MB/s 10016 7016 6737 6779 0.97 0.68
fs.disk.seq_read.4096 MB/s 7784 427 3992 4125 9.66 0.53
fs.disk.seq_write.1048576 MB/s 346 349 43 452 1.30 1.31
fs.disk.seq_write.4096 MB/s 624 133 39 162 1.22 0.26
fs.disk.stat ns 584 11631 50658 13581 1.17 23.26
fs.disk.tree.100 ns 1350872 13957113 32735460 21385348 1.53 15.83
fs.disk.write_4k_at ns 722 10195 18335 23941 2.35 33.16

AMD

row unit Linux main (dc1a864) #1152 r1 (94fdd7f) #1152 (a1f0762) /main /Linux
fs.disk.create_write_unlink ns 28270 115431 189896 125866 1.09 4.45
fs.disk.direct.fstat ns - 9748 11532 11862 1.22 -
fs.disk.direct.pread_4k ns - 10761 11482 11331 1.05 -
fs.disk.direct.read_4k_at ns - 20179 21110 21400 1.06 -
fs.disk.direct.seek ns - 9509 9478 9898 1.04 -
fs.disk.direct.stat ns - 11542 37681 13876 1.20 -
fs.disk.fstat ns 1188 9919 11882 12082 1.22 10.17
fs.disk.mkdir_rmdir ns 37904 80384 111409 77475 0.96 2.04
fs.disk.open_close ns 3728 21672 53841 23704 1.09 6.36
fs.disk.pread_4k ns 1803 11191 1413 1382 0.12 0.77
fs.disk.pread_8 ns 1324 10330 741 902 0.09 0.68
fs.disk.read_4k ns 1813 11142 1183 1202 0.11 0.66
fs.disk.read_4k_at ns 1919 11532 1703 1693 0.15 0.88
fs.disk.readdir.1000 ns 442635 693862 1983029 1991264 2.87 4.50
fs.disk.rename ns 10117 63282 68218 42240 0.67 4.18
fs.disk.reopen_read_4k ns - - 54973 24756 - -
fs.disk.seek ns 804 200 191 190 0.95 0.24
fs.disk.seq_read.1048576 MB/s 11397 4789 8927 8953 1.87 0.79
fs.disk.seq_read.4096 MB/s 5006 364 4892 5590 15.36 1.12
fs.disk.seq_write.1048576 MB/s 787 340 45 448 1.32 0.57
fs.disk.seq_write.4096 MB/s 742 211 28 164 0.78 0.22
fs.disk.stat ns 2163 12544 39384 14928 1.19 6.90
fs.disk.tree.100 ns 3455124 12733562 26612873 17939719 1.41 5.19
fs.disk.write_4k_at ns 2333 12183 23764 30758 2.52 13.18

Round 9 (49fe47993) took seq_write.4096 from 18 to 162/164 MB/s (6ff184a's rows in r9-small-writes/osbench-6ff184af8/, a1f0762's in r10-known-gaps/osbench-a1f07627e/); the other rows match 6ff184af8's within run-to-run spread.

Round 9: small writes, and r6's refusal

Evidence: https://github.com/ToyOSOrg/evidence/tree/91cd6d4b3ae9724e6c92912ad63b182e7c3c707b/storage-integration/r9-small-writes. cargo run -- --ci host at a1f07627e (main 116dde5de, #1179, merged cleanly): exit 0, 108 steps. AArch64 rows: guest runner, run 41941136592.

  • osbench at 6ff184af8 (format, then benchcase, both benches; coordinator's runs): osbench_persist exited 0, so the post-replay hang is gone on metal. Table below against the same nightly as round 5 (main dc1a8642e, Linux ext4). Metadata rows are now at or under main's (stat 1.17-1.18x, open_close 0.93-1.08x, rename 0.52-0.65x, create_write_unlink 0.79-1.06x); readdir.1000 stays about 3x main. Small writes were clearly worse: seq_write.4096 18 MB/s (main 133-211, 0.09-0.14x), write_4k_at 25-30 us (main 10-12, 2.5x).
  • Measured, then fixed (49fe47993). Host model, 4 KiB calls through the file server's reserve and dirty-page write-back: reserve 8.7 us a call (a transaction to take sectors) and write-back 23.6 us a page (each call its own reservation, so one transaction and extent per page), against 2.9 + 2.6 us per page in 1 MiB calls. Now a write that starts where the last write's reservation ends extends it (Mount::extend; sectors following on in the same bucket join the last run, up to the 64 KiB extent), and the open bucket's sectors are taken in memory when the grant's own room judgement holds. Same measurement: reserve 0.9 us, write-back 3.6-3.9 us a page. Scattered 4 KiB overwrites (write_4k_at's shape): reserve 0.8 us, the copy-on-write replace transaction itself 30.6 us, unchanged; the sample shows it spread over node key copies, the pending overlay and allocation, no single cost. Whether write_4k_at now nears main is for the staged osbench. Controls: no run merging (64 extents for 64 blocks), runs joining across buckets (fsck and read red), no cut of a refused extension (room not returned).
  • r6 B1 (cd639c3e6, by ruling): a read-write mount refuses a volume whose journal is larger than mkfs lays out (Unsupported("writing a volume whose journal is larger than mkfs lays out")), mkfs's sizing shared as mkfs::ring_buckets; read-only keeps the scan's floor. Test with control (the mount opened without the check). Suggestions: the 8 GiB test is now the 2 MiB-bucket wrap, replay and fsck test; each fetched block takes a fresh version.

fs.disk at 6ff184a (p50; one run a side)

T14

row unit Linux main (dc1a864) #1152 r1 (94fdd7f) #1152 (6ff184a) /main /Linux
fs.disk.create_write_unlink ns 11206 161348 195115 127853 0.79 11.41
fs.disk.direct.fstat ns - 8450 11824 10546 1.25 -
fs.disk.direct.pread_4k ns - 9430 10909 10841 1.15 -
fs.disk.direct.read_4k_at ns - 17753 20595 18552 1.05 -
fs.disk.direct.seek ns - 8348 9588 8433 1.01 -
fs.disk.direct.stat ns - 10657 49134 12527 1.18 -
fs.disk.fstat ns 224 8719 12207 10842 1.24 48.40
fs.disk.mkdir_rmdir ns 15176 84955 116600 81135 0.96 5.35
fs.disk.open_close ns 1252 26222 67881 24350 0.93 19.45
fs.disk.pread_4k ns 590 9584 945 894 0.09 1.52
fs.disk.pread_8 ns 377 8959 687 642 0.07 1.70
fs.disk.read_4k ns 539 9576 951 951 0.10 1.76
fs.disk.read_4k_at ns 728 10012 1245 1199 0.12 1.65
fs.disk.readdir.1000 ns 273843 908345 2751006 2879891 3.17 10.52
fs.disk.rename ns 3624 87717 77669 45347 0.52 12.51
fs.disk.reopen_read_4k ns - - 68950 24974 - -
fs.disk.seek ns 120 264 284 241 0.91 2.01
fs.disk.seq_read.1048576 MB/s 10016 7016 6737 6729 0.96 0.67
fs.disk.seq_read.4096 MB/s 7784 427 3992 4105 9.61 0.53
fs.disk.seq_write.1048576 MB/s 346 349 43 383 1.10 1.11
fs.disk.seq_write.4096 MB/s 624 133 39 18 0.14 0.03
fs.disk.stat ns 584 11631 50658 13729 1.18 23.51
fs.disk.tree.100 ns 1350872 13957113 32735460 21655863 1.55 16.03
fs.disk.write_4k_at ns 722 10195 18335 24953 2.45 34.56

AMD

row unit Linux main (dc1a864) #1152 r1 (94fdd7f) #1152 (6ff184a) /main /Linux
fs.disk.create_write_unlink ns 28270 115431 189896 122840 1.06 4.35
fs.disk.direct.fstat ns - 9748 11532 11641 1.19 -
fs.disk.direct.pread_4k ns - 10761 11482 11241 1.04 -
fs.disk.direct.read_4k_at ns - 20179 21110 21180 1.05 -
fs.disk.direct.seek ns - 9509 9478 9768 1.03 -
fs.disk.direct.stat ns - 11542 37681 13525 1.17 -
fs.disk.fstat ns 1188 9919 11882 11813 1.19 9.94
fs.disk.mkdir_rmdir ns 37904 80384 111409 76463 0.95 2.02
fs.disk.open_close ns 3728 21672 53841 23314 1.08 6.25
fs.disk.pread_4k ns 1803 11191 1413 1393 0.12 0.77
fs.disk.pread_8 ns 1324 10330 741 922 0.09 0.70
fs.disk.read_4k ns 1813 11142 1183 1202 0.11 0.66
fs.disk.read_4k_at ns 1919 11532 1703 1693 0.15 0.88
fs.disk.readdir.1000 ns 442635 693862 1983029 2006442 2.89 4.53
fs.disk.rename ns 10117 63282 68218 41167 0.65 4.07
fs.disk.reopen_read_4k ns - - 54973 24306 - -
fs.disk.seek ns 804 200 191 190 0.95 0.24
fs.disk.seq_read.1048576 MB/s 11397 4789 8927 8914 1.86 0.78
fs.disk.seq_read.4096 MB/s 5006 364 4892 5502 15.12 1.10
fs.disk.seq_write.1048576 MB/s 787 340 45 451 1.33 0.57
fs.disk.seq_write.4096 MB/s 742 211 28 18 0.09 0.02
fs.disk.stat ns 2163 12544 39384 14627 1.17 6.76
fs.disk.tree.100 ns 3455124 12733562 26612873 17757007 1.39 5.14
fs.disk.write_4k_at ns 2333 12183 23764 29997 2.46 12.86

Round 8: the journal ring is the replay bound

Evidence: https://github.com/ToyOSOrg/evidence/tree/770c5c18ca69e7e8317ddb0d0aa1a130ae5569b4/storage-integration/r8-journal-ring. cargo run -- --ci host at 6ff184af8: exit 0, 108 steps. AArch64 rows: guest runner, run 41934369401.

  • r5 B1 (6ff184af8): round 7's writer cap bounded the window but a mount still read and checksummed the whole ring mkfs laid out at a 128th of the device (1.3 GiB on the T14). mkfs now lays the ring out at a 128th, at least eight buckets and at most JOURNAL_BYTES (32 MiB), so the ring itself bounds what a mount reads and a replay holds. The writer's cap is deleted. The scan's floor stays: a ring another writer laid out (an upstream volume, read-only or not) can hold far more than any window. The ring sizing is a format change for volumes this engine makes, so each bench's DATA is reformatted first.
  • r5 B2: on an 8 GiB device (a 128th is 64 MiB) mkfs's ring is JOURNAL_BYTES, and after the writer has gone round it an unclean mount's window is within it; red with the ring sized by the device.
  • r5 suggestions: close asserts against the newest closed entry's last sequence; the scan test gains a stale entry in a later bucket (red without the at-insert drop); the scan's doc is reflowed.
  • Found and fixed on the way (4410a5664, the file server's block cache, on main too): with the small ring, the full-repetition host model's data writes took 179 s instead of 40 s. The eviction queue kept a stale entry for each block cleaned again, so blocks rewritten over and over (now the journal's) kept the queue past its compaction bound and every eviction recompacted it; the stale entry also evicted recently cleaned blocks. Entries now carry the version their block was cleaned at. Same run: 34.7 s; mount 0.20 s, first mkdir 0.024 s, reader peak 0.14 GB.

Round 7: r4's blockers, and the replay window at full scale

Evidence: https://github.com/ToyOSOrg/evidence/tree/c7e23b6741df00466ed59cafb0e824a3a60f5b66/storage-integration/r7-replay-window. Main merged twice (aecd87eca: #1176 and #1174, one modify/delete conflict, the swapped-binary issue this branch closes stays deleted; 62e24c902: #1178, clean). cargo run -- --ci host at 62e24c902: exit 0, 108 steps.

  • The replay window (r4 B1). Round 6's 1.0 GB was osbench's sequence at a tenth of its repetitions. At full repetitions on the T14-sized host model, an unclean mount peaked at 3.74 GB resident and mounted in 12.5 s, and the journal (1/128 of the device, about 1.3 GiB on the T14) could hold several times that window. Both benches have 16 GB, so this was not comfortable, and the exit lands here (bdf062cd3): the writer holds live journal entries to 32 MiB of the ring (JOURNAL_LIVE), and the scan keeps records only for entries that can be in the window (an entry behind a good flush entry's last sequence keeps none; a window reaching one is refused). Full scale now: peak 0.20 GB, mount 1.10 s, first mkdir 0.018 s. The record closes by its exit. Tests with red controls: a writer held to 3 of 8 buckets behaves like a 3-bucket ring; a scan keeps only the window's records.
  • r4 B2 and suggestions (f433570e3): the doc comment is back on its test; the name cache charges each entry ENTRY_BYTES beyond its path; MAX_KEY <= K_TX * 8 is a compile-time assert; the superblock test's doc is reflowed.
  • AArch64 rows (virt_jobs_at_el2, virt_smp, virt_user_mode, virt_readonly_copyout, a_bent_supervisor_is_refused_and_the_other_slot_boots) through --ci rows at aecd87eca and 62e24c902: 5 passed, 0 failed each time, but HOST NOT QUIET (2.2 CPUs of other work, the desktop's own processes), so no verdict.

Round 6: the post-replay hang, and r3's blockers

Evidence: https://github.com/ToyOSOrg/evidence/tree/9dfdd8af0ef226d304024aa61a9b3781fbed0c37/storage-integration/r6-replay-hang. cargo run -- --ci host at a0098f4d4: exit 0, 108 steps. QEMU x86-64 rows (the DATA designation, nvme_disk_boots, the host disk left alone, kernel_objects_refused_alone, the image spawn) at a043c5e4b: exit 0, 5 passed.

  • osbench_persist hung on both benches at 45283fbc5 in its first DATA mkdir after the reboot. Reproduced on a host model of the T14's DATA: osbench's call sequence, dropped unclean, replays about 1.5 million journal keys, most of them accounting deltas at a handful of positions. The first transaction's leaf drain transferred them one at a time, each transfer walking every entry at its position (about 0.45 ms a delta). c836688ce: a node takes a position's deltas in one pass; the same image's first mkdir now takes 0.33 s. Test: the first transaction after a replay looks at no more than 16 pending entries per replayed one (the old transfer: 262 657 for 1 551, red).
  • B2, the split bound (same commit): a node takes from pending at most once a transaction, within the room its own records leave of K_TX, so the "two transactions more" MAX_GROUPS assumes holds as written. Test: renames over files and creates after a replay grow no node past K_TX * 8 bytes (draining for every key: 6 872, red; a room of the whole bound: 4 224, red).
  • B1 (af6ae8293): a reserved write's extent points at the block holding its offset. Test on 16 KiB blocks at 4096, neighbour unchanged, fsck clean; the old sector reads as Checksum("extent"), red.
  • B3 (dfa6e2166): a whole superblock copy of another volume refuses the volume as Overwritten at its offset. The test case is a real other volume's copy (before the fix it opened); the flipped byte is relabelled a damaged copy.
  • B4 (537af08e7): issues/a-reserved-data-block-holds-a-transactions-metadata-credit-until-written.md and issues/a-small-bcachefs-journal-makes-reclaim-write-nodes-after-nearly-every-transaction.md, each with evidence, owner and exit. issues/a-replayed-bcachefs-node-past-seven-nodes-is-refused.md closes: since 11632e8b6 replayed keys materialize in bounded ranges. The bcachefs track records that checksum-none volumes are refused.
  • Suggestions taken (c74862d03): the DATA name cache forgets a path by its sorted range and is bounded by 256 KiB of path bytes.
  • The judge gap (a0098f4d4): the hung boots' verdict.txt read passed, since the boot judge read only the boot record and the supervisor's stop. bootlog::verdict now refuses a log carrying the test runner's job-deadline line.
  • Found: the T14-sized replay peaks the file server at 1.0 GB resident at a tenth of osbench's repetitions; round 7 fixes it.
  • The hot path changed (apply's drain), so osbench is restaged, after a data-format boot on each bench.

Round 5: write-back by extent, and the served path's lookups

Both flagged causes from the bench table below are fixed; the boots that judge them are staged at 45283fbc5. Evidence: https://github.com/ToyOSOrg/evidence/tree/f43cc46dac51c8ec0f7326433f302afa20d47d2c/storage-performance. cargo run -- --ci host at 45283fbc5: exit 0, 108 steps. Engine suite 189 passed, file server 109; new negative controls each red (unwritten blocks leaked, runs of one block, whole-run allocations closing the bucket, no bound on old extents, the refused suffix reported from 0, names never forgotten).

  • Write-back by extent (f56cc636b). A reservation is runs of contiguous sectors of at most 64 KiB (upstream's default extent), and a write puts one extent in each run it crosses; the file server keeps one reservation per write and writes each run of pages back at once. The per-page path is gone. An allocation takes what the open data bucket has left (where the extent ends) instead of closing it, which a whole-run allocation did, and which made evacuation and a refused write chase each other forever. Each transaction still removes at most two old extents, so an overwrite of fragmented data goes in parts. Host model, per MiB of a 64 MiB file in 1 MiB calls: 6.8 ms before, 0.66 ms now (main 0.18 ms); 4 KiB calls with no pager stay a transaction each.
  • The served path (45283fbc5). Measured first, on a probe (QEMU/KVM): one STAT is one request from the client in both trees, but the server resolved the same DATA path four times through the engine, from the root (11 dirent lookups + 4 inode reads, 41.7 us of a 58.4 us stat). The DATA volume now remembers what each path it looked up names (unlink, rmdir and rename forget what they move), and STAT and OPEN look the name up once. Server STAT 41.7 -> 5.2 us, stat p50 58.4 -> 24.3 us; OPEN 52.5 -> 11.0 us, open_close p50 78.4 -> 36.4 us; fstat, the bare round trip, 17.1 us. Evidence: storage-performance/served-path.
  • 8f52abe99: nvme_disk_boots expected the private format's line ("bcachefs, "); it failed at f1b2f3e38 on jan-pc having seen DATA served.
  • libc_children's tty_interrupt stalls at the nested shell's line at f1b2f3e38 and at main 4610c48b3 alike: main's issues/libc-childrens-tty-interrupt-can-stall-at-the-nested-shells-line.md, not this branch's.
  • Known limit: a reserved block holds one transaction's metadata credit (4 KiB) until written back (issues/a-reserved-data-block-holds-a-transactions-metadata-credit-until-written.md).

fs.disk on the benches (94fdd7f)

One osbench benchcase boot a bench at 94fdd7faa, against the newest main osbench on the same benches (nightly 2026-10-31-2316: main dc1a8642e, private DATA format) and that nightly's two Linux runs (ext4, p50 median). p50 everywhere; one run a side, so a trend. Evidence: https://github.com/ToyOSOrg/evidence/tree/19709a9e5f559251bf8a661db7fc07b5ac54aad1/storage-performance/osbench

T14

row unit Linux main (dc1a864) #1152 (94fdd7f) #1152/main #1152/Linux
fs.disk.create_write_unlink ns 11206 161348 195115 1.21 17.41
fs.disk.direct.fstat ns - 8450 11824 1.40 -
fs.disk.direct.pread_4k ns - 9430 10909 1.16 -
fs.disk.direct.read_4k_at ns - 17753 20595 1.16 -
fs.disk.direct.seek ns - 8348 9588 1.15 -
fs.disk.direct.stat ns - 10657 49134 4.61 -
fs.disk.fstat ns 224 8719 12207 1.40 54.50
fs.disk.mkdir_rmdir ns 15176 84955 116600 1.37 7.68
fs.disk.open_close ns 1252 26222 67881 2.59 54.22
fs.disk.pread_4k ns 590 9584 945 0.10 1.60
fs.disk.pread_8 ns 377 8959 687 0.08 1.82
fs.disk.read_4k ns 539 9576 951 0.10 1.76
fs.disk.read_4k_at ns 728 10012 1245 0.12 1.71
fs.disk.readdir.1000 ns 273843 908345 2751006 3.03 10.05
fs.disk.rename ns 3624 87717 77669 0.89 21.43
fs.disk.reopen_read_4k ns - - 68950 - -
fs.disk.seek ns 120 264 284 1.08 2.37
fs.disk.seq_read.1048576 MB/s 10016 7016 6737 0.96 0.67
fs.disk.seq_read.4096 MB/s 7784 427 3992 9.35 0.51
fs.disk.seq_write.1048576 MB/s 346 349 43 0.12 0.12
fs.disk.seq_write.4096 MB/s 624 133 39 0.29 0.06
fs.disk.stat ns 584 11631 50658 4.36 86.74
fs.disk.tree.100 ns 1350872 13957113 32735460 2.35 24.23
fs.disk.write_4k_at ns 722 10195 18335 1.80 25.39

AMD

row unit Linux main (dc1a864) #1152 (94fdd7f) #1152/main #1152/Linux
fs.disk.create_write_unlink ns 28270 115431 189896 1.65 6.72
fs.disk.direct.fstat ns - 9748 11532 1.18 -
fs.disk.direct.pread_4k ns - 10761 11482 1.07 -
fs.disk.direct.read_4k_at ns - 20179 21110 1.05 -
fs.disk.direct.seek ns - 9509 9478 1.00 -
fs.disk.direct.stat ns - 11542 37681 3.26 -
fs.disk.fstat ns 1188 9919 11882 1.20 10.00
fs.disk.mkdir_rmdir ns 37904 80384 111409 1.39 2.94
fs.disk.open_close ns 3728 21672 53841 2.48 14.44
fs.disk.pread_4k ns 1803 11191 1413 0.13 0.78
fs.disk.pread_8 ns 1324 10330 741 0.07 0.56
fs.disk.read_4k ns 1813 11142 1183 0.11 0.65
fs.disk.read_4k_at ns 1919 11532 1703 0.15 0.89
fs.disk.readdir.1000 ns 442635 693862 1983029 2.86 4.48
fs.disk.rename ns 10117 63282 68218 1.08 6.74
fs.disk.reopen_read_4k ns - - 54973 - -
fs.disk.seek ns 804 200 191 0.95 0.24
fs.disk.seq_read.1048576 MB/s 11397 4789 8927 1.86 0.78
fs.disk.seq_read.4096 MB/s 5006 364 4892 13.44 0.98
fs.disk.seq_write.1048576 MB/s 787 340 45 0.13 0.06
fs.disk.seq_write.4096 MB/s 742 211 28 0.13 0.04
fs.disk.stat ns 2163 12544 39384 3.14 18.21
fs.disk.tree.100 ns 3455124 12733562 26612873 2.09 7.70
fs.disk.write_4k_at ns 2333 12183 23764 1.95 10.19

Flagged:

  • seq_write: 0.12-0.29x main, 28-45 MB/s (Linux 346-787). Engine CPU: the file server writes back page by page, each page its own single-block reservation, so every 4 KiB is one transaction and one extent, and truncating the file later one transaction per extent. A host microbenchmark puts it at ~7 ms of engine CPU per MiB against main's 0.17-0.33. Fix: multi-block reservations and write-back of contiguous runs as one extent (file server and engine interface). Not done.
  • write_4k_at 1.8-2.0x main; create_write_unlink 1.2-1.7x; tree.100 2.1-2.4x: the same per-page transaction cost.
  • stat 3.1-4.4x main (~40-50 us), direct.stat 3.3-4.6x; open_close 2.5-2.6x; readdir.1000 2.9-3.0x: not engine CPU (the engine's stat is ~2 us on the host model); the served path's cost, to be located.
  • rename is level with main (0.89x T14, 1.08x AMD); paged: a holder's read is atomic against its pager's write; an fsync commits its own file #1141's AMD +19.8% does not reproduce as a regression against main here. Reads (read_4k, pread, seq_read.4096) are 8-13x faster than main through the paged object.

Regressions this branch had introduced, fixed

  • Spawn working directory. The kernel had judged a spawn's cwd against its own mounts; with those gone it took any text, and a child given an empty, relative or over-long one panicked at its first file call. SYS_SPAWN now refuses it InvalidArgument (kernel::cwd::spawnable, host-tested with a negative control; abuse_spawn_argv asks it through the syscall).
  • The supervisor could block on /tmp. Swap staged binaries on /tmp and the supervisor read them from its loop; the SDK waits on a file server without bound. Now swap lends the bytes in a memory region, the supervisor verifies its own copy and starts the service from it at every start. No file server is asked, and issues/a-swapped-binary-lives-where-any-process-can-rewrite-it.md closes by its "bytes the supervisor hands over" exit shape. std's CommandExt::boot_image becomes CommandExt::image, accepting the caller's own unchanged region too. The owner delegated that choice to the orchestrator, who accepted it. Swap has no behavioural test before or after.
  • DATA's exact check. The owner's 2026-09-01 ruling (a volume copied onto a device of another size does not mount) was lost with the private format; Mount::spans_its_device restores it at the DATA probe, with a negative control. issues/probe-mounts-on-a-checksum.md is rewritten against the native code.
  • Guest tests on the removed spawn ABI. abuse_spawn_argv and abuse_elf_loader spawned without an image, so their refusals held vacuously and their started-child cases could not pass; both carry real images now.

Process-depth test

process_tree's chain ran each link from a copy on /tmp. With /tmp served, every link kept its own /tmp connection, so on the T14 at 0507cbe36 the chain met the file server's 32-client share bound at link 32 before the kernel's depth refusal at 65. Each link now starts the next from this program's immutable image through CommandExt::boot_image, endowing it a copy, so no link asks a file server for anything. The depth-65 and subtree-teardown assertions are unchanged. The cap is not raised; the limit /tmp now shares is recorded in issues/four-shares-take-a-file-server.md. /tmp spawn coverage stays (spawn_from_tmp, fs_truncate_persist, B's malformed-ELF refusal).

What is unsure

Draft: not ready to land.

  • Writable geometry. 8e117f19e and f14f79e50 (from the reclaim branch's aaa71f0bb, 825b9ba7a) end every clean unmount in one terminal publication; the owner kept one-block nodes writable (issues/upstream-cannot-check-written-bcachefs-volumes-of-one-block-nodes.md). Its review's blocker (an empty appended bset on production-sized volumes) is fixed (248db3d49), nodes are written down to 16 KiB (d5158bd3d), and a mount now judges the superblock it actually runs on (c12721aa1, with a device answering differently on its second read).
  • Not yet run at this candidate: besides the staged boots above, final runtime-reclaim validation on both benches and regenerated upstream fsck of the integrated writer. The Linux comparison must check fs.disk.rename specifically: paged: a holder's read is atomic against its pager's write; an fsync commits its own file #1141's last A/B flagged it +19.8% on the AMD, unexplained.
  • Run at 57dedf1f3: the proctreecase, testcases and watchdog boots on both benches, judged above; the reboot pair at 9fe8f779e. Format at 6ff184af8 and osbench at 6ff184af8 and a1f07627e passed on both; the volume format has not changed since 6ff184af8, and the engine changes since a1f07627e touch only refusals and near-full judgement. A main-based image with DATA rows must not boot on either bench until this lands: main's format would mount nothing sane from the native volume.
  • AArch64 rows: green at 9fe8f779e (run 41953629015); only tests changed since.
  • Small volumes reclaim per transaction (issues/a-small-bcachefs-journal-makes-reclaim-write-nodes-after-nearly-every-transaction.md).
  • /tmp's file-server cost against the kernel tmpfs it replaces is unmeasured (issues/a-tmpfs-metadata-call-costs-toyos-up-to-six-times-linuxs.md).
  • Syscall numbering: main has 168 (SYS_YIELD) and 169 (SYS_LOG_LAST_WORDS); SYS_NAMESPACE_NAMES is 170 with 172 bins. ObjectKind loses both File (this branch) and Console (K4), renumbered densely (SysCap 9 .. DeviceSet 19). Main was merged in rather than rebased (f450182c6): a linear rebase replays paged: a holder's read is atomic against its pager's write; an fsync commits its own file #1141's and paged: a holder's fstat is its object's, without a request (FS stage 3a) #1144's merged-in history onto a main it was never written for and stopped at the sixth of 69 commits; the merge resolves each conflict once against the final states (its message lists them). f1b2f3e38 gives this branch's two DATA images K4's UART and logkeeper rows. cargo run -- --ci host at f1b2f3e38: exit 0, 108 steps.

Anything a reader of main must not miss

🤖 Generated with Claude Code

Japabu and others added 30 commits November 2, 2026 00:13
As persist.write_4k_fsync, each iteration first writing 1 MiB over
another 1 MiB file and leaving it unsynced: an fsync of one file that
also makes another file's write durable pays for it here. It is the A/B
base for a per-file fsync (issues/an-fsync-of-a-served-file-syncs-its-whole-volume.md),
which asks for one file's fsync measured beside the volume's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A holder's SYS_PAGED_READ copied a pinned chunk of 16 pages at a time
while the file server's write landed page by page in the same frames, so
a read beside a write of several pages could return pages from before
and after it, or a page torn at the copy (memory stage 7a's recorded
gap).

Every write of an object's now runs as a write span on it
(kernel::paged::Object): each write call and each dirty supply begins
one before its first byte lands and ends it after its last, and a resize
moves the object's version. A holder's read begins only while no span is
open and counts its bytes only where the version has not moved since; a
read a write met answers WouldBlock, and the SDK, which already asks the
server whenever a holder read fails, reads through the server, whose
loop orders the read against the write.

A server write takes many calls when a page is absent, in part or at the
dirty ceiling. A call that does not take all it was given leaves the
span open (held) until the new PagedOp::EndWrite, and later calls nest
in it, so the file server's whole write is one span. The server's first
call of a write is always a write: one that takes everything is the
whole write at no extra syscall; otherwise the server ends the held span
on every path, the refused one included.

Holder reads never wait on a write: the pager is never made to wait on
its readers, and a read that met one costs one round trip.

Tests:
- kernel::paged: a read counts only where no write began or was under
  way; a short call holds the write until its end.
- fileserver pages: a write of many calls (an absent page supplied, the
  dirty ceiling written back, a last page in part) is read whole or not
  at all at every call boundary, and ends whether it lands or is refused.
- guest a_served_files_read_never_tears_against_its_write: a writer puts
  400 whole generations of a 64-page served file while a reader on its
  own description reads it whole through its holder; red on a torn read.

Closes issues/a-read-and-a-write-of-a-paged-file-can-tear-across-pages.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FSYNC synced the whole volume: every dirty page of every file written
back and every open file's entry written before the format's commit. An
fsync now makes durable the file it names and only what the volume's
commit cannot leave out:

- Volume::carried names the open files the next commit carries whatever
  it is asked: on DATA one whose entry was written since the last commit
  (a rename, a shrink) or whose reserve took blocks since, which the
  commit's allocator holds as taken; on FAT every changed open file,
  since a cache flush writes every chain the cache holds.
- Files::fsync writes back the named file's pages and those of every
  carried file, then Volume::commit writes the named entry and each
  carried one and commits. A carried file whose pages are refused
  refuses the commit: its entry would name blocks without its bytes.
- So after a stop no entry names a block holding an earlier tenant's
  bytes. A write over blocks a commit already named is carried by
  nothing, and waits for its own fsync or the volume's sync.

A directory's fsync commits the carried files only: DATA writes every
name change into the tree at once. SYNC and the write-back timer still
sync the whole volume, and a per-file fsync leaves the timer due.

Found while doing this: a sync commits the entry of a file whose pages
were refused (issues/a-sync-commits-the-entry-of-a-file-whose-pages-were-refused.md).

Tests (fileserver pages): an fsync makes its file durable across a stop
and leaves another file's overwrite unwritten; a carried file's pages and
entry go with another's fsync, so after a stop on a volume of stale
blocks it reads its own bytes; a directory's fsync makes a new name
durable.

issues/an-fsync-of-a-served-file-syncs-its-whole-volume.md stays open
until its measurement, osbench's persist.write_4k_fsync_beside_1m base
against head on metal, is recorded.

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

- R1: the holder's answer WouldBlock is documented on paged_read, the
  boundary a holder implements against; PagedOp::Write keeps only the
  EndWrite rule.
- R3: a carried file's test no longer claims no block is held that no
  entry names: DataVolume::commit commits past a refused carried entry,
  whose taken blocks then leak after a stop. Filed with S3, one carried
  file's refused write-back refusing every fsync on the volume
  (issues/a-refused-carried-file-costs-every-other-files-fsync-or-leaks-its-blocks.md).
- S2: the tear member's generations are its bytes mod 256.

R2 is in the fsync commit: its issue stays open until the A/B records
the exit's measurement. S1 (the version move at a write's end is
redundant) is declined for this round: it changes the kernel the staged
A/B images were built from.

Comments, ABI docs and issue records only: no code changes, so the
staged images at b4edede still measure this head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The volume-wide sync an fsync used to run cleared the write-back the
server's loop keeps due. The per-file fsync never did: after it, the
server waited every turn with a deadline, which every park and idle entry
programs into the CPU's timer, and 2 s later walked the volume in a sync
with nothing left to write.

The volume now says whether an open file's entry is changed past what its
last commit made durable (Volume::owes), and Files adds its dirty pages
(Files::owes). An fsync that leaves neither answers the write-back as a
sync does. One that leaves another file's write keeps it due from that
write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A served file's fstat was a file server round trip. A paged object now
publishes what a holder's fstat needs that can change: the kernel's own
size and write-span version, and the mtime its pager sets.
SYS_PAGED_STAT(holder) answers {size, mtime, version} under the
object's lock, WouldBlock while a write is under way (atomic as a read
is), and the SDK then asks the server. Kind, node, volume and nlink come
from the open's reply, which no change to the file moves.

The owner sets the mtime as a word it already sends: PagedArgs.time on
Write and Resize, paged_create's mtime, and PagedOp::Stamp for a utimens.
The file server hands the object the volume's own value after each
change (reserve, truncate, an open that truncates, a set time, a refused
write's cut back), at the volume's resolution. The version is the
object's data generation; there is no second counter.

FAT's mtime was its entry's last level, not its last write
(issues/a-fat-files-mtime-through-its-handle-is-its-last-level.md),
so it could not be published. A write or resize now records its even
second, which the handle answers until the level stamps it.

Tests:
- kernel::paged: a stat publishes no version while a write is held.
- fileserver pages: the object's size and mtime are the volume's after
  an open, a write, a refused write, a truncate and a stamp.
- fileserver fat: a file's mtime is its last write's through its handle,
  off the disk a sync later and after a reopen (replaces the test that
  pinned the stamp to the flush).
- guest a_held_files_fstat_is_its_servers_stat: the held fstat agrees
  with the server's stat of the path after each change.

Closes issues/a-fat-files-mtime-through-its-handle-is-its-last-level.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The orchestrator's ruling on the stage-3 split. The client name cache
comes on mapped paged objects, with file slots, since stat by path of an
existing file is the row that matters. Built before mappings, its slots
would be read by syscall and it would need a names-only pager for SYSTEM
and a dirty allowance for BOOT: an interim mechanism the mappings would
replace. 3a, a held descriptor's fstat, is this PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Preserve the published #1141 and #1144 commits as dependencies. Resolve
the syscall-number collision with firmware-window by assigning PAGED_STAT
165 and extending the profile to include both calls.

These dependencies remain under their existing review; this merge does not
claim approval or land them on main.
Save in-progress implementation before integrating paged-object fixes.
Preserve write-span publication and mtime semantics for anonymous objects.
Remove the kernel pathname and file-offset syscall declarations and SDK
fallback. Keep generic object reads, writes and metadata queries. File
metadata comes from the server so unlink and link counts stay authoritative;
paged reads retain their atomic offset publication.

Share runtime cwd through the descriptor owner's SDK ABI, initialized only
once from spawn metadata. Derive synthetic read-only root and ancestor
directories from the names actually held by the immutable namespace, exposed
by a bounded generic namespace inspection operation.

Host validation: SDK fd-table check and clippy, 5 files tests, 49 ABI tests,
and all 106 libc module tests pass. Combined guest compilation follows the
kernel filesystem removal and shared Rust reexport update.
Serve /tmp and diskless DATA roots through the fileserver namespace with
one retained anonymous paged object per regular inode. Keep names and
open descriptions independent, release unlinked bytes only after the last
description and holder, and read sparse holes without allocating frames.

Delete kernel VFS, tmpfs, pathname syscalls, file cache and file objects.
Executable loading now takes its supplied immutable SharedImage directly.
Keep bootstrap cwd transport; SDK routing removal is coordinated separately.

Add owner-only advisory pager quota headroom, explicit memory grants, and
host plus guest coverage for retention, sparse reads and quota refusal.
Consolidation checkpoint: routes DATA through the on-disk engine and removes the provisional format and its synchronous adapter. Holds inode identities and per-page reservation tokens. Engine token APIs and userland memory changes are integrated in following commits; this checkpoint is not a validated landing.

The partition signature test passes with actual bcachefs recognition.
Resolve the volume contract in terms of inode identity and adapt memory reservations to explicit old content bounds. Validation follows combined SDK and engine integration.
Includes generic namespace name inspection, explicit namespace ancestors and authoritative file-server metadata. Preserves existing paged I/O improvements.
Write checksummed or authenticated file blocks through the existing journal
and bucket allocator, retaining checksum slices of overwritten extents.
Reserve concrete unpublished data slots and conservative metadata capacity
before accepting dirty pages; keep pinned slots safe through bucket emptying.
Resume on-disk logged truncates, clear truncated tails, and reclaim orphan
extents. Keep read epochs alive through file-data I/O.

Validation: eight focused host tests pass, including torn/reordered write
recovery, encrypted version reservation, capacity refusal followed by
reserved writes, and a pinned bucket's generation transition. no_std clippy
passes with warnings denied. Metadata reservation bounds remain conservative;
broader regression and pressure checks continue in the integration task.
Correct readdir continuation, retain paged descriptions across unlink, release unheld orphans, and deduplicate sync failures. Adapt fsync tests to real bcachefs transactions: dirty lengths are not published before data. The outstanding large-write capacity regression remains under repair.
Use the existing fileserver EXPORT protocol for inherited file descriptors,
including readable stdin. Transfer each local offset to the server before
export, and use that shared offset thereafter. Ordinary std Command spawns
export a fresh connection each time; forwarding an already exported
connection is explicitly one-shot.

POSIX file-action dup2 aliases carry a process-local group in their fd label
so the child adopts the protocol connection once and shares one descriptor
object. Independent launch refuses ambiguous reuse of one raw exported
connection instead of attaching it twice.

SDK host tests (153), clippy with fd-table, and libc module tests (106)
pass. Extend served_file_offset with guest checks for repeated input spawns,
dup2 reads/seeks/writes, exact offset sharing and open-unlinked metadata.
Guest execution follows the combined storage integration build.
Store the final partial sector as inline data with its suffix zeroed,
retaining earlier checksummed slices and removing all later extents. This
keeps shrinking possible after ordinary data capacity is exhausted and makes
logged-truncate recovery independent of obtaining a fresh data block. Enable
the format's inline-data feature and account inline sectors in the inode.

Refuse overflowing reservation offsets before adding the block size. Add
coverage for full-capacity truncate, imported extent slicing, reserved I/O
failure, and the maximum-offset boundary. All twelve focused data tests pass,
including plaintext/encrypted torn-write recovery. The per-token metadata
reservation cost remains an unresolved integration blocker.
All ToyOS filesystem paths now use the process namespace in userland. Remove the platform dispatch reexport of the deleted kernel-versus-served routing predicate.
Box the mounted probe result, use a concrete flush-future type, and clean affected file-server lint findings. Empty direct writes take no reservation, and metadata failure cancels an accepted reservation.

Validation: fileserver library clippy with warnings denied passes.
The file stays readable after unlink and description close while a holder remains, and its pages and inode are released after the final holder notice.
Replace kernel pathname probes with ordinary SDK filesystem operations. Root
hierarchy checks use controlled namespaces, copyout probes use generic objects,
and directory pagination checks cover complete served listings. Remove the
retired backing-revocation actuator registration and its oracle.

Keep process cwd within the bootstrap path bound and refuse oversized input
before normalization. Include a complete-listing probe in the shared boot now
that it no longer exhausts a kernel filesystem inventory.

Validation: SDK 153 host tests, metal 81 unit tests, SDK fd-table clippy,
focused path boundary tests, and host guest-harness compilation passed.
Guest compilation and execution require the consolidated sysroot/image.
Served fstat now reads authoritative filesystem metadata, so paged objects no
longer need a timestamp, Stamp operation, or holder metadata syscall. Remove
that path from the kernel, ABI, SDK and filesystem mirror while preserving
size, atomic read versions, write spans and authoritative Volume timestamps.

Pager create now takes backing kind after size. Namespace syscall 166 and the
remaining paged operation numbers stay stable. Keep size-coherence tests and
remove only assertions about the obsolete timestamp mirror. Include three
mechanical file-server binary lint fixes uncovered by all-target clippy.

Validation: SDK 153 and ABI 49 tests, paged 18 tests, both bare-target kernel
clippy checks, SDK fd-table clippy, file-server binary check and all-target
clippy passed. File-server tests 99 passed; its existing 64-page capacity test
still fails with NoSpace before touching the pager. No guest image built here.
Allow an in-memory directory to replace an empty directory. Update each shared directory description once during rename, retaining removed destination descriptions and moving source aliases together. Migrate the identity guest test from implicit kernel directories to served directory lifetimes.

Validation: file-server memory and description host tests pass. Guest execution follows the integrated image build.
Let the file server enforce dirty-page limits; cp no longer forces fsync every megabyte. Keep the final sync before destination replacement to report delayed write failures. Update the memory benchmark label and sparse test-device description for userland storage.

Validation: toybox copy host tests pass.
The removed suites use only the deleted BCFS/BTND API. Real bcachefs operations, I/O failures, reservation pressure, and crash recovery are tested in engine/tests and the served DATA tests. Keep all upstream-generated format fixtures.
Capture adjacent siblings as one bounded rewrite, publish their replacement
pointers after all bytes complete, and transfer later changes by key range.
Keep still-underfull replacements queued until their siblings can compact.
Collapse a one-child root through the journal and ignore obsolete higher
levels during replay after collapse.

Expose the same relocation path for metadata bucket evacuation and share
the minimum fill calculation with capacity admission. Existing conservative
reclaim reserve assumptions remain separate integration work.

Validation: four host tests cover repeated create/delete churn, clean-node
relocation, changes between merged writes and pointer publication, and
torn/reordered crash recovery through merges and root collapse.
Prepare shared capacity accounting by maintaining non-structural leaf bytes,
BTREE bucket count and imported non-root node debt across applied changes.
Allocation index and metadata-backpointer positions have finite bounds from
device geometry; the other records are charged from their encoded lengths.
Report space excluding outstanding metadata promises.

This prepares the occupancy-based reservation ledger; admission still uses
the earlier conservative per-token bound pending rebalance and bucket
evacuation integration. no_std cargo check passes.
Hold exact data sectors while pending pages charge their enforced journal
growth bound. Prepay geometry-bounded allocator records and account live
non-structural records; include imported underfull nodes until rewritten.
Sparse closed metadata buckets can move through the existing node writer.

Keep a reservation until transaction application succeeds so failed
preparation retains the accepted page's capacity. Authenticated version
reservation needs no additional leaf-key growth.

The 64 MiB pressure test accepts 1200 pending pages and writes all of them
after allocation refusal. Twelve data tests and no_std clippy pass.
Worst-case shared reclaim sizing and GC pressure integration remain under
active review; this checkpoint does not claim that physical proof complete.
Japabu and others added 2 commits November 5, 2026 00:22
…as cleaned at

Found measuring r5's ring change: with DATA's journal ring at 32 MiB
instead of 1.3 GiB, osbench's sequence at full repetitions on the host
model took 179 s of data writes instead of 40 s. The sample put the time
in Inner::evict's VecDeque::retain. A block cleaned again was queued
again while its older entry stayed, and retain kept every entry of a
clean block, so blocks written over and over (the journal's, now that
the ring is small) left the queue longer than its compaction bound after
each compaction, and every eviction compacted it again. The stale entry
also evicted a block that had been cleaned since.

An entry now carries the version its block was clean at; only the entry
matching the block's current version is live, so each clean block has
one, compaction leaves the queue the size of what it orders, and the
oldest place evicts. The same run now takes 34.7 s.

Test: block 1 cleaned after block 2 was read survives the eviction block
3 causes, and 100 000 rewrites of two blocks leave the queue within its
bound; red when an entry is live by its block's cleanness alone.

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

r5 BLOCKER 1. Holding live entries to 32 MiB bounded the replay window,
but a mount still read and checksummed the whole ring mkfs laid out at a
128th of the device: about 1.3 GiB on the T14's DATA, 32 GiB on a 4 TB
disk. mkfs now lays the ring out at a 128th of the device, at least eight
buckets and at most JOURNAL_BYTES (32 MiB), never under what reclaim's
shares need. The ring itself is then the bound on what a mount reads and
what a replay holds, and the writer's own cap (Writer::hold_live_to, its
`live` count and the mount's call) is redundant on every volume this
engine formats; it is deleted.

What stays for volumes another writer laid out: the scan's floor. An
upstream ring can be far larger than any window, and a read-only mount of
such a volume would otherwise hold every stale entry's records. Its doc
now says so. A ring another writer laid out larger is used whole by this
writer when mounted read-write, as that writer sized it.

r5 suggestions: close asserts each entry's last sequence against the
newest closed entry's, not only the newest written one, so the invariant
the scan's refusal relies on fails at its source; the scan test gains a
stale entry in a later bucket than the window, which takes the at-insert
path; the scan's doc is reflowed.

Tests: on an 8 GiB device, whose 128th is 64 MiB, mkfs's ring is
JOURNAL_BYTES, and after the writer has gone round it an unclean mount's
window is within it, fsck clean; red with the ring sized by the device.
The wrapped scan case is red without the at-insert drop. The 171 GiB
geometry test asserts the bound where it used to assert a 128th.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

Review r6: #1152 62e24c902..6ff184af8

Read-only follow-up. I ran no tests or builds. I read 4410a56 and 6ff184a in full, along with the whole of system/fileserver/src/cache.rs, journal::scan, the Writer, Mount::from's journal and reclaim-share setup, journal_buckets, Geometry::shaped, the read-write refusals (mount/mod.rs:300-347), spans_its_device, and the small-journal record. I also read the r8 evidence at 770c5c18c (README, all four full-scale logs, the three mutations and the host CI log) and the handoff.

Architecture

The orchestrator's ruling is implemented as stated. Now that the ring itself is the bound, the second mechanism is gone: live, hold_live_to, the ring_free subtraction and the mount's call are deleted, and slots() is the whole ring again. That also removes r5's edge case, where a crash-time window wider than live gave ring_free = 0. Production code shrinks slightly. The closed_last field and assert, and the 4-line mkfs change, are the only additions.

Sizing. mkfs.rs:432-433 computes max(min(max(n/128, 8), JOURNAL_BYTES/bucket), journal_buckets), in that order.

  • The minimum is applied before the cap. MAX_BUCKET is 2 MiB, so the cap is at least 16 buckets and cannot cut below 8.
  • journal_buckets is applied last, so it can never be cut.
  • JOURNAL_BYTES is "at most" except when journal_buckets is larger. The comment at line 430 states that precedence. The 171 GiB test shows journal_buckets <= 16 at 2 MiB buckets.

Reclaim cannot starve or deadlock on a small or a big volume. Verified.

  • journal_buckets provides at least ceil(8s/3) slots, where s is the share that covers RECLAIM + FOOTPRINT nodes. So floor(slots * 3/8) >= s, and the mount's by_journal >= reclaim_room + FOOTPRINT check (mount/mod.rs:452) passes for every ring mkfs lays out.
  • limit and reclaim_entries are then derived from slots() exactly as before.
  • On small volumes, n/128 * bucket <= 32 MiB, so the ring is unchanged from r5.
  • On large volumes, the physical ring now equals r5's live. ring_free over 16 physical buckets is the same quantity r5 computed by subtracting ring.len - live. Reclaim's capacity is identical to the reviewed r5 state, and r5's liveness analysis carries over unchanged.

The scan's floor stays as before. Its doc (journal.rs:174-177) now gives the reason it matters. That reason, a foreign large ring, is accurate. Volumes this engine formats also leave stale entries behind a wrap, but those are bounded by 32 MiB.

closed_last (S1): resolved. close asserts against max(durable_last, closed_last) and then records the new value. closed_last starts at 0 each mount, and durable_last covers the replayed window. This is the invariant the scan's Corrupt arm relies on, and it now fails at its source.

The cache fix (4410a56): correct.

  • No dirty eviction. live requires !s.dirty, and evict removes a block only through a live entry.
  • Every clean block keeps a live entry. A fetch pushes (at, 0) with its slot's version 0. write_run pushes (block, version) only when slot.version == version, which is exactly the version it leaves clean. If the push reservation fails, it drops the slot rather than keeping an unordered clean block. A write makes the old entry stale.
  • No entry leaks. versions increases strictly from 1, so a written version never repeats. In FIFO order, a block's older entries are always popped before its live one. Compaction keeps only live entries, at most clean_limit + DIRTY_LIMIT of them, so after one compaction the queue sits well under the 4 * (clean_limit + DIRTY_LIMIT) trigger and compaction is amortised again.
  • The old retain kept every entry of a clean block, so the queue never fell back under its trigger. That matches the sample's VecDeque::retain and the 179 s to 34.7 s change.
  • The test covers both claims:
    • In the first half, block 1's stale place no longer evicts it. The unstamped control is red there.
    • In the second half, 100 000 rewrites of two blocks stay within the bound. Under the old rule every entry stays live and the queue grows without bound, so this half is red by inspection too.

Findings

BLOCKER 1. The stated gap is recorded only in the commit message. The relevant code is mount/mod.rs:388-390 (slots() over the whole ring) and the read-write refusals at mount/mod.rs:300-347.

  • 6ff184a's message says: "A ring another writer laid out larger is used whole by this writer when mounted read-write". The PR body's round-8 paragraph talks only about the scan's floor.
  • Nothing refuses read-write on such a volume:
    • The refusals check version, features, block and node shapes, but not ring size.
    • spans_its_device checks the bucket count, not the ring.
  • So DATA formatted with upstream tools on its own partition mounts read-write, and its replay window grows to whatever ring that writer laid out. That is r4 B1's unbounded replay memory, now for foreign volumes. The commit's own scan doc says such a ring "can hold far more than any window".
  • Under "No silent debt", a compromise that is known and kept needs an issues/ record. Required: either
    • file it with evidence, owner and exit, or
    • remove it with one more read-write refusal ("writing a volume whose journal is larger than mkfs lays out"), matching the existing Unsupported arms.
  • Which of the two is the orchestrator's choice. Do not reinstate the writer cap: it was ruled out.

SUGGESTION 1. tests/journal.rs:300-331: the mount half's assertion cannot fail.

  • The window's entries lie in the ring, and line 311 has already asserted that the ring is JOURNAL_BYTES. So bytes <= JOURNAL_BYTES at line 330 holds by construction.
  • The by-device control goes red at line 310, before any mount. The same sizing fact is already held by the cheaper pure geometry test at mkfs.rs:788-790.
  • The mount half does have real value: it is the only test that wraps the writer and replays it on production 2 MiB buckets, followed by fsck. Keep it if you like, but make its doc and assertion say that, rather than claiming a window bound that the ring makes impossible to break.

SUGGESTION 2. cache.rs:214-215: fetched blocks all share version 0.

  • write_run's allocation-failure arm (cache.rs:456) removes a slot without popping its older entries. If the block is fetched again, an old (b, 0) entry from an earlier fetch can become live again. The block then has two live places and is evicted at the older one.
  • The only consequence is eviction order under memory pressure, not correctness.
  • Fix: give a fetch inner.versions += 1 like a write, and the "one live entry" invariant in order's doc holds unconditionally.

Also checked:

  • S2 (wrapped scan): resolved. Entries 3 and 4 fill bucket 0 exactly (gap = bucket_blocks - used), so stale entry 1 is read from bucket 1 after the floor is known. The no-insert-drop control is red at line 359.
  • S3 (reflow): done.
  • The three r5 comments: the JOURNAL_LIVE doc, live's doc and hold_live_to's doc are all deleted along with their code. The new JOURNAL_BYTES doc (mkfs.rs:68-72) claims reading, memory and work, and the ring now does bound all three.
  • The small-journal record (issues/a-small-…:16-19) is accurate.

Evidence

  • Host CI ci-host-6ff184af8.log reports "108 step(s), all green". It contains the 8 GiB test, the wrapped scan case, the 171 GiB geometry test and the cache test, all ok.
  • Full scale at 6ff184a:
    • Data writes 34.73 s, exit 0.
    • Mount 0.20 s, first mkdir 0.024 s, reader peak 136 MB.
    • The before-cache-fix logs match the README: 179.35 s, with the same mount and peak.
    • These are CPU-only host-model numbers.
  • Controls: by-device is red at journal.rs:310, no-insert-drop at :359, unstamped at cache.rs:571. All three diffs are the stated one-line mutations.
  • Open, as the handoff says:
    • The aarch64 guest-runner run 41934369401 at 6ff184a is still queued.
    • Metal format, osbench, reboot and boots are staged at 6ff184a and not run.
    • Format must run first, because the ring size changed.
    • osbench at NVMe latency is still the judge of the smaller ring's write-side cost. The host model improved, 34.7 s against r7's 39.9 s, but only after the cache fix.

SEND BACK

Japabu and others added 5 commits November 5, 2026 00:50
BLOCKER 1, by the coordinator's ruling: a ring another writer laid out
larger than mkfs would for the same device was used whole by this writer,
so an unclean mount of, say, a DATA formatted by upstream's tools would
replay whatever that ring held. writable() now refuses it read-write by
name, "writing a volume whose journal is larger than mkfs lays out", with
mkfs's sizing factored into mkfs::ring_buckets so both read one rule.
Read-only mounts keep the scan's floor.

Test: a 64 MiB volume whose superblock names one ring bucket more is
refused read-write by that name and still read; red with the check
removed (the mount opened).

Suggestions: the 8 GiB test is now what it can show, the writer going
round a ring of 2 MiB buckets and an unclean mount replaying it clean
(the window bound held by construction); each fetched block takes a fresh
version, so only its newest place in the eviction order is live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… reserving takes no transaction in an open bucket

osbench at 6ff184a: seq_write.4096 18 MB/s on both benches (main
133-211, Linux 624-742), write_4k_at 25-30 us (main 10-12). Measured on
the host model of the T14's DATA before fixing, 4 KiB calls through the
file server's reserve and its dirty-page write-back:

  reserve 8.7 us a call, write-back 23.6 us a page (1 MiB calls: 2.9 + 2.6)

Every call was its own reservation: a transaction to take its sectors,
and at write-back one transaction and one extent per page, since pages
under different reservations are written apart. Now:

- Mount::extend reserves the blocks past a reservation's end into it;
  where the new sectors follow the last run's in the same bucket, they
  join that run (up to the 64 KiB extent), so a write-back of both is
  one extent. Mount::reserve is an empty reservation extended.
- Sectors of the open data bucket are taken in memory when the grant's
  own room judgement (Mount::room_for, what grant refuses NoSpace
  without) holds; a new bucket, or a judgement that may wait or refuse,
  is still a transaction's.
- The file server's DATA volume extends the last write's ticket when a
  write starts where its reservation ends; a refused write cuts the
  extension back (Mount::cut), and an unreserve that landed nothing
  leaves the ticket's earlier pages alone.

Same measurement now: reserve 0.9 us a call, write-back 3.6-3.9 us a page.
Scattered 4 KiB overwrites (write_4k_at's shape): reserve 0.8 us, the
overwrite's transaction itself 30.6 us; the latter is the copy-on-write
replace of the old extent, unchanged here.

Tests: extending a reservation block by block writes back as at most one
extent per bucket of four blocks (64 extents without merging, red) and a
cut gives back the last extension; on 2 MiB buckets a run never joins
across into the next bucket (red without that condition: the harness
had hit "data placed in a bucket its alloc key holds otherwise");
cancelling a write that extended the last one gives back its room (red
without the cut). The partial write-back test writes its tail first so
its two pages stay under two writes' capacity, as it states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #1179 (every guest test runs on the guest runner). No conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
By the coordinator's decision #1152 lands with these recorded, not fixed.
osbench at a1f0762, both benches, against nightly 2026-10-31-2316:

- write_4k_at about 2.4x main: the copy-on-write overwrite transaction,
  30.6 us of host CPU an overwrite, spread with no single cost.
- readdir.1000 about 3x main: each listing reads every entry's inode
  with a lookup of its own (416 of 534 us on the host model), and every
  READDIR request lists the whole directory again.

Each has its evidence, owner and exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
By the coordinator's ruling under no silent debt: tree.100 (1.41-1.53x),
stat, fstat and their direct variants (1.17-1.26x), and seq_write.4096
on the AMD (0.78x) at a1f0762, with what is known of their causes, an
owner, and the exit "within 1.1x of main on both benches, or each row's
cause shown to be inherent".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Review r7: #1152 6ff184af8..a1f07627e

Read-only follow-up. I ran no tests or builds. I read cd639c3 and 49fe479 in full, and around them: writable, Mount::from's journal setup, Writer::new, journal_buckets, splits_fit, the superblock member checks, extend/cut/write_reserved, data_space, the allocator's next_data/cancel_data/keeps/transient, grant and transact, and the file server's reserve/landed/unreserve/write_back with the Writes and Files::write callers. I also read the evidence at 91cd6d4b3 (README, all four mutation diffs and logs, and the host CI log).

Architecture

Both changes sit in the right owners. The refusal lives with the writer's other read-write refusals, and mkfs and the mount now read one sizing rule (ring_buckets). For small writes, the engine extends a reservation and the file server decides when a write continues the last one. Neither adds a mechanism outside the data path.

Taking sectors from the already-open bucket without a transaction is sound in principle. The transaction it replaces wrote no keys: the closure only took sectors through data_space's next_data. So a crash before write-back means the same in both versions: unreferenced sectors in a bucket that was already open. A new bucket still goes through take_bucket inside a transaction.

The diff adds about 180 production lines and 50 test lines.

r6 BLOCKER 1 and suggestions

  • The refusal itself is resolved. ring_buckets is the mkfs expression, unchanged. writable compares the summed ring against it, using the superblock's own nbuckets, bucket and block. Read-only Volume::open does not call writable, so it keeps the scan's floor. The test is red without the check (unrefused.log: None). However, the placement of the check introduces a new crash (B1 below).
  • S1 is done. The 8 GiB test now claims a wrap, a replay and a clean fsck. It asserts the wrap and the 2 MiB bucket shape.
  • S2 is done. A fetch now takes inner.versions += 1. The slot doc covers it.

Small writes: what holds

  • The bucket bound is complete. A merge requires four things: the same file position, sector contiguity, last.sector / bucket == sector / bucket, and ≤ longest. Every new piece stays inside its own bucket:

    • next_data returns got ≤ bucket_sectors - next;
    • a hole is one block;
    • data_space after take_bucket uses the same next_data.

    So when the piece's first sector shares the last run's bucket, the whole merged run lies in that bucket. longest ≤ 64 KiB keeps written: u128 in range.

  • The engine's cut returns exactly the extension. Reservation ends stay block-aligned, to is rounded up to a block, and kept is computed per run. So a merged run is trimmed back to its pre-extension length, and the cut blocks go to canceled and unpin through cancel_dropped. The file server cuts at max(extended_from, landed). That keeps a partly landed page's block and removes this write's later pages from incoming/pages with one release each. The landed == 0 → u64::MAX rule correctly keeps the copied pages of earlier writes that share the ticket.

  • There is no interleaving between the room check and the take. Nothing is awaited between room_for, next_data, pin and the run push. On the transaction path, finish is synchronous, so the pin follows transact with no yield either. The file server itself issues one request at a time.

  • Omitting held from room_for is acceptable. The term transient(held + nodes) in grant's admission paces physical node slots. The pending credit consumes no slot, and a later write-back's own grant still waits on held.

  • The controls are all real one-line mutations, and each is red at the claimed assertion:

    • no-merge: 64 extents;
    • cross-bucket: a short read at data.rs:633;
    • no-cut: room not returned at fileserver data.rs:871;
    • unrefused.
  • The merge a1f0762 is clean. git merge-tree --write-tree 49fe47993 116dde5de gives d1403ec14, which is a1f0762's tree.

  • Host CI at a1f0762: the log ends "108 step(s), all green", exit 0, and includes the four new tests.

Findings

BLOCKER 1. lib/bcachefs/src/engine/mount/mod.rs:323-328: the new ring check panics the file server on a volume with small buckets.

  • ring_buckets → journal_buckets → Writer::new asserts entry > HEADER + TAIL + 2*K_TX*8. That is 11 112 bytes, and the assert is at journal.rs:551.
  • The check runs before the shape refusals. Before cd639c3, those refusals made Writer::new unreachable for such shapes:
    • splits_fit needs nodes of at least 11 396 bytes, and a node is at most a bucket;
    • blocks must be at least 4 KiB.
  • read_superblock accepts, for example, 8 KiB buckets with 8 KiB nodes: members() requires only bucket ≥ node ≥ block. A foreign or crafted volume of that shape was refused Unsupported("…too small for reclaim's largest split"). It now reaches the assert in the file server. That breaks "external input is refused at its boundary".
  • Fix: move the check after the shape refusals, at the end of writable. Once those refusals pass, the bucket is at least 12 KiB with 4 KiB blocks, so Writer::new holds.
  • Cheapest test: add an 8 KiB-bucket case to small_blocks_and_a_journal_position_off_a_block_are_refused. Its existing cases keep mkfs's 16 KiB buckets, which is why the regression is unseen.

BLOCKER 2. mount/data.rs:139-188 and system/fileserver/src/data.rs:497-501: a refused extension keeps what it took.

  • extend updates reservation.bytes after each run and returns ? from the next one. A write over 64 KiB, or one that crosses the open bucket's end, can take the open bucket's remainder in memory and then get NoSpace on the new bucket.
  • reserve() is safe here, because its fresh Reservation drops. In the continued arm, though, the file server returns the error with the tail's range already grown, capacity.pages unchanged and no cut. Neither Writes::write nor Files::write calls unreserve when reserve fails.
  • The taken blocks then stay pinned and keep their per-block metadata credit until the earlier writes' pages are all released. A retry at the same offset no longer continues, because the range end has moved.
  • This contradicts Volume::reserve's contract, "whole or refused" (volume.rs:174), and the commit's claim that a refused write returns its extension. The no-cut control covers only the unreserve path.
  • Fix: make extend all-or-nothing by cutting itself back to its starting end on any error. The cut lives in the engine, so every caller gets it.
  • Test: a continued write refused NoSpace past 64 KiB leaves room() unchanged.

BLOCKER 3. mount/data.rs:158: the in-memory take checks one block's metadata credit and commits up to sixteen.

  • room_for(K_TX, FOOTPRINT) judges stable with one K_TX*8 added.
  • next_data(…, wanted, …, partial) then takes up to longest (16 blocks at 4 KiB). Reservations::pending charges K_TX*8 for each unwritten block. So each take can push pending up to 15 credits past what keeps judged.
  • The per-block charge is the real worst case: a run's pages can be written back separately, each in its own transaction.
  • Consequence: near full, the total promise can exceed capacity. Another reservation's write-back then judges keeps with this over-count (Draw::Reserved excludes only its own) and, once settled, is refused NoSpace. That breaks reserve's documented guarantee, "a later write into it cannot be refused for space" (data.rs:115). On the file server, the refused write-back is of dirty, already acknowledged data.
  • The transaction path (transact(K_TX, …) then data_run) has had the same gap since f56cc63. This commit copies it into the new path.
  • Fix: judge the credit the take adds, room_for(K_TX * wanted_blocks, FOOTPRINT), and apply the same amount on the transaction path, for example in data_space's check. Alternatively, refute this with the argument for why one credit per take suffices; the code does not show one.

BLOCKER 4. mount/mod.rs:1093-1103: room_for was inserted between grant's doc comment and grant.

  • grant's paragraph, "Grants class its request now if it fits…", now heads room_for's doc, and rustdoc concatenates the two paragraphs. grant is left undocumented.
  • Fix: move room_for and its doc above grant's doc.

SUGGESTION 1. system/fileserver/src/data.rs:43-47: one doc block describes two fields. Give extended_from its own line, for example "where the tail's reservation ended before this write extended it, until the write lands or is cut."

Note for the orchestrator. The PR body's sentence "Round 9 (49fe47993) took seq_write.4096 from 18 to 162/164 MB/s" has no source in the cited evidence 91cd6d4b3. That commit holds only the osbench at 6ff184a. Commit the a1f0762 osbench logs and cite them. That run also decides the remaining write_4k_at gap: 2.5x main at 6ff184a, with the copy-on-write replace transaction unchanged at 30.6 µs on the host model.

SEND BACK

Japabu and others added 2 commits November 5, 2026 02:08
… or refused, each take judged for its credit

BLOCKER 1: the journal-size refusal sized mkfs's ring, which builds a
journal writer for the volume's buckets, before the shape refusals; a
volume of 8 KiB buckets, which a reader accepts, reached Writer::new's
assert in the file server. The check now comes last in writable(), after
the refusals that rule such buckets out. The shape test gains an 8 KiB-
bucket case, refused by name (it panicked before the move).

BLOCKER 2: an extension refused partway kept the runs it had taken, pinned
with their credit, and the file server's tail range grew though the write
was refused. Mount::extend now cuts the reservation back to where it
started on any error: whole or refused. Test: near full, an extension of
256 KiB refused NoSpace leaves the reservation's range and the volume's
room as they were; red without the cut (the range grew by 96 KiB and the
room fell to 0).

BLOCKER 3: a take was judged for one transaction's credit and could
promise sixteen blocks, each owed a transaction's credit as each may be
written back alone. Each take is now judged for its blocks' credit: in
memory by room_for, and on the transaction path, whose grant judged one,
by room_for for the rest inside it, refused NoSpace where it does not fit.
An assert holds every take to the credit judged for it; with the old
judgement it fails in every multi-block reservation test. A near-full
test that would show an over-promise directly could not be built: near
full, the space judgement is node-granular and the binding limit is data
room, so the at most fifteen credits a single take over-promised stayed
under one node there.

BLOCKER 4: room_for and its doc sit above grant's doc again.

Suggestion: extended_from has its own doc line.

Unchanged cost (host model): 4 KiB calls reserve 0.9 us and write back
4.1 us a page; scattered overwrites 30.8 us.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #1180 (bootstrap gets a $TMPDIR of its own). No conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Review r8: #1152 a1f07627e..c4d1db6e2

This is a read-only follow-up. I ran no tests or builds.

What I read

  • 79cd415 in full.
  • Around it: extend, take_reserved, cut, write_reserved, room_for, grant, transact/finish, data_space, and the allocator's keeps/capacity/next_data.
  • The file server's reserve/landed/unreserve/write_back, with the Writes::write and Pages::write callers.
  • The merge c4d1db6, plus 92dcd86 and 8cee28a.
  • Evidence at f091fd2a1 storage-integration/r11-review-r7: the README, both mutation diffs and logs, and the host CI log. The log is at c4d1db6 and ends "108 step(s), all green", exit 0. It includes a_refused_extension_gives_back_what_it_took and small_blocks_and_a_journal_position_off_a_block_are_refused.

Architecture

79cd415 keeps every responsibility where r7 found it. The ring check stays with the other read-write refusals, the engine owns "whole or refused" for an extension, and the file server keeps only the decision to continue. It adds about 45 production lines and 30 test lines.

B3's fix judges the extra credit in two different places with two different rules:

  • on the in-memory path, room_for(credit);
  • on the transaction path, the grant's one K_TX plus a second, immediate room_for for the rest.

The second one leaves a gap, described below. One mechanism closes it: let the grant judge and hold the whole credit.

r7 blockers

  • B1 is resolved. The ring check is now the last refusal in writable (mount/mod.rs:349-356). The shape refusals before it (splits_fit, block ≥ 4 KiB) rule out the buckets that Writer::new asserts against. The 8 KiB-bucket case rewrites the node and bucket fields and confirms that a reader accepts the shape. It then expects the splits_fit refusal by name.

    • The README says the panic before the move was reproduced but not logged. That is an acceptable control for a reordering of this kind.
    • The new comment is short and states why the check comes last.
  • B2 is resolved. extend now wraps take_reserved and calls cut(reservation, at_end) on any error. at_end is block-aligned:

    • reserve passes a length that extend rounds up to a block;
    • every take adds whole blocks.

    So cut trims a merged run back to its exact prior length and leaves the earlier blocks alone. Its written assert cannot fire, because the blocks it cuts were just taken. In the file server's continued arm, a failed extend now returns before capacity.pages or tail changes, so a retry at the same offset continues again. The stale extended_from = Some(offset) is harmless: it is read only by unreserve, which is not called when reserve fails, and the next reserve overwrites it. The test is a real near-full case, and the no-self-cut control is red as claimed: the range grew by 96 KiB and the room fell from 819 200 to 0.

  • B3 is not resolved. See BLOCKER 1 below.

  • B4 is resolved. room_for and its doc sit above grant's doc, and grant's paragraph heads grant again.

  • S1 is done.

Can the transaction-path refusal fail a write that was already acknowledged? No.

  • take_reserved runs only from Mount::reserve/extend.
  • In the file server, those run only from DataVolume::reserve. Its callers, Pages::write (pages.rs:420) and Writes::write (volume.rs:275), call it before any byte is copied or the write is answered.
  • Write-back goes through write_reserved, which never takes.
  • The closure refuses before data_sector/data_run, so finish(apply=false) drops an empty transaction.

The refusal therefore fails only the incoming request, and B2's self-cut leaves the tail as it was.

Merges and intervening commits

Findings

BLOCKER 1 (r7 B3 continued). lib/bcachefs/src/engine/mount/data.rs:176-182, 192 and :541-545: on the transaction path, the take's extra credit and the new bucket are never judged together, and the assert that is meant to show they are is circular.

  1. The gap on the new-bucket path. Inside the transaction, room_for(credit - K_TX*8, FOOTPRINT) judges the extra credit against today's capacity. data_run → data_space then finds no open data bucket, which is the normal case near full.
    • data_space judges taking a bucket with stable = metadata_nodes(granted_bytes + pending). Its granted_bytes holds only the grant's single K_TX*8, so the extra credit - K_TX*8 is missing.
    • take_bucket then lowers capacity by per_bucket.
    • Neither check implies the post-condition the reservation promises, stable_slots(S(pending + blocks·K_TX·8)) + transient ≤ capacity - per_bucket.
    • This is the same over-promise r7 described, now confined to the path that opens a bucket. r7 named data_space's check as the place to apply the credit.
  2. The assert does not establish the claim. assert!(blocks * K_TX * 8 <= credit) compares the take with the local credit. It holds whenever got ≤ wanted, and next_data guarantees that on every branch. The one-credit control changes credit itself, so it fires the assert that reads credit.
    • A mutation that restores r7's actual defect at the judgement, room_for(K_TX * 8, FOOTPRINT) on line 174 with credit left intact, keeps the assert and every test green. So does deleting the in-transaction check.
    • The assert therefore fences an arithmetic identity, not "every take held to its judged credit".
  3. The stated reason a near-full test could not be built does not match this test geometry. This is my inference from the code; I did not measure it.
    • formatted(64 << 20) gives 32 KiB buckets and 32 KiB nodes, so per_bucket = 1, longest is 8 blocks and minimum_fill ≈ 8.8 KB.
    • A take's 7 extra credits (28 KiB) add about 3–4 fills, which is roughly 6–8 stable nodes and as many slots. That is not "under one node". The failure more likely came from the fill-until-refused loop taking single blocks, where the extra credit is zero.
  4. The in-transaction refusal does not follow the rule grant documents for NoSpace. grant refuses NoSpace only once settled, meaning no other grant is outstanding. room_for refuses at once, while other admitted transactions' worst-case granted_bytes still count. In the engine, a reserve concurrent with another ordinary operation can therefore be refused space that the grant would have waited for. The file server runs one block_on at a time, so there it is latent. It still contradicts the engine's own contract.

Fix. Use one mechanism. Let admission judge and hold the take's whole credit: grant/admit take a metadata credit separate from the journal bound, and that credit goes into granted_bytes. Every current transact caller passes bound * 8; take_reserved passes credit.

  • data_space's existing check then sees the whole credit.
  • The refusal follows grant's settled rule.
  • The in-transaction room_for and its comment go.

The in-memory path is sound as it stands. It takes from the open bucket, which is not counted in capacity, so room_for(credit) before next_data is the whole judgement.

Evidence. Replace the circular assert with the actual invariant after each take: the promise still keeps, room_for(0, FOOTPRINT). Exercise it in a near-full test whose fill takes multi-block reservations. That needs at least longest per reserve, so that takes open buckets with up to 7 extra credits.

  • The negative control is the judgement mutation above (room_for(K_TX*8, …), or the bucket check without the credit). It must go red.
  • If node granularity really hides it on every buildable image, show it with the arithmetic for that image rather than with an assert that cannot fail.

SUGGESTION 1. system/fileserver/src/data.rs:46-47. "until the write lands or is cut" is inexact. landed does not clear extended_from; it stays set until the next reserve overwrites it. "Where the tail's reservation ended before the current write extended it" is enough.

SEND BACK

r7 B3 continued. A take that opened a bucket went through a transaction
whose grant judged and held one transaction's credit; the take's extra
credit was then judged inside it against today's room, while data_space's
own check for the new bucket saw only the grant's one credit, so neither
judged both the take's credit and the bucket it lost; and the inside
check refused at once where grant waits until settled.

Now admission holds a metadata credit apart from the journal bound:
admit, grant, ungrant, prepare_pending and finish carry it, granted_bytes
counts it, and Mount::transact_holding grants a transaction its bound's
records and a credit of at least them. transact is transact_holding at
its bound's bytes. A take on the transaction path is granted its whole
credit, so data_space's bucket check sees it and a refusal follows
grant's settled rule; the inside check is gone. The in-memory take from
the open bucket, which costs no capacity, stays judged by room_for for
its whole credit.

Evidence: the circular assert is replaced by the invariant itself, in
tests, after every take: everything promised still fits
(room_for(0, FOOTPRINT)). A near-full test fills the 64 MiB image (32 KiB
buckets, eight-block runs) with 32 KiB and with 16 KiB reservations and
writes every block back; red when the grant holds one transaction's
credit for a take (the invariant fails). Reverting the in-memory
judgement to one credit stays green on this image: there a bucket is
eight blocks and so is a metadata bucket's worth of credit, and the
fills tried end credit-bound with the open bucket full, or, with blocks
written first to offset it, bound by data buckets with credit to spare;
no buildable state put an in-memory take across the boundary.

Suggestion: extended_from's doc says only where the tail's reservation
ended before the current write extended it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Review r9 of #1152 at 9fe8f77: B3 only

Scope. This round checks only r8's BLOCKER 1 (r7 B3 continued), at 9fe8f77: the fix diff, its callers and the r12-review-r8 evidence.

Architecture. The fix follows the one-mechanism design from r8. Admission now holds the take's whole metadata credit in granted_bytes, so three things judge the same quantity:

  • grant's keeps;
  • data_space's bucket check;
  • room_for, the check every concurrent in-memory take makes.

The in-transaction check is deleted. Production code changes by 25 lines added and 27 removed, and the commit adds one 38-line test. That is proportionate.

B3: resolved

Transaction path (data.rs:179, mod.rs:1114-1146, data.rs:531-548).

  • grant refuses or waits unless keeps(stable(pending + granted_bytes + credit), held + nodes) holds. On success it adds credit to granted_bytes.
  • Inside the transaction, data_space may find the open bucket. The grant has already judged the whole credit, so that take is covered.
  • Otherwise data_space checks keeps(stable(granted_bytes + pending), held, data = true). Here granted_bytes already contains this take's credit, and the check includes + per_bucket. take_data then lowers capacity and slots by exactly per_bucket.
  • finish removes credit, and pending gains blocks·K_TX·8 ≤ credit, because got ≤ wanted. During the transaction held ≥ granted ≥ FOOTPRINT.
  • Those steps imply room_for(0, FOOTPRINT) after the take, which is the promise the reservation makes. A refusal now comes only from grant's settled rule or from data_space's existing refusal before take_bucket.

In-memory path (data.rs:172). I agree with the orchestrator: inspection establishes it, and no further test is needed.

  • room_for(credit, FOOTPRINT) and next_data run in one expression, so no await separates them.
  • next_data changes only data and holes. capacity() and slots() depend on free, returning, pins with an emptying, metadata buckets and the open metadata bucket. They do not depend on the open data bucket or on a hole in a taken bucket.
  • pin on a hole's bucket does not change how many pins carry an emptying.
  • So the take leaves keeps' right-hand side and fits unchanged and raises pending by at most credit. metadata_nodes is monotone in its byte count.
  • Therefore room_for(credit, FOOTPRINT) before the take implies room_for(0, FOOTPRINT) after it.
  • No reachable state over-promises. The green memory-one-credit mutation shows only that the test cannot see a mutation the argument above already excludes; the argument holds without the commit message's geometric explanation. Requiring a red control here would mean adding a test for something inspection settles.

Lifecycle of the credit.

  • Grant. grant adds credit only on Ok(true). A NoSpace or ReadOnly refusal from admit adds nothing.

  • Release. Each successful admission is undone exactly once by one of:

    • the oversized retry, which ungrants and readmits with the same credit;
    • prepare_pending's failure arm;
    • finish, on apply and on drop alike, including the stopped arm, which ungrants before it returns ReadOnly.

    All three pass the same credit that admit was given.

  • No double count. While a take's credit sits in granted_bytes, its blocks are not yet in runs, so pending does not count them. finish moves them into runs in the same poll, with no await between, so they are never counted twice or left uncounted.

  • Refusal and B2's cut. On refusal, finish has already returned the credit before extend calls cut. cut touches only runs, and therefore pending, and cancels the blocks. Neither step involves granted_bytes.

  • Crash. granted_bytes exists only in memory and is zero at mount (mod.rs:486), so a crash leaves nothing to leak.

  • Reclaim. Reclaim's bound * 8 goes to arguments that Class::Reclaim ignores in both grant and ungrant, so the result is unchanged.

transact ≡ transact_holding(bound, bound * 8, …). Before this change, grant judged with journal * 8 and added it, and ungrant subtracted it. Every ordinary caller now passes credit = bound * 8 to the same three places. The new assert credit >= bound * 8 holds for take_reserved, because wanted covers at least one block. Behaviour is therefore identical for every existing caller.

Evidence.

  • ci-host-9fe8f779e.log ends with the line 9fe8f779e…, then 108 step(s), all green and exit 0. The new test passes.
  • grant-one-credit changes only the grant's credit and goes red at the real invariant (data.rs:207). The circular assert is gone. This is the negative control r8 asked for.
  • The invariant check is #[cfg(test)], and the drop(pool) before it is harmless in production.

Findings

None blocking.

SUGGESTION. lib/bcachefs/src/engine/tests/data.rs:658-665: the clause "(asserted in the take; red when the grant holds one transaction's credit for a take)" is mutation history, which belongs in the evidence. The doc reads as well without it.

LAND

Japabu and others added 2 commits November 5, 2026 03:28
Both were red on both benches at 9fe8f77 and not on the K4 baseline.

- pipe_other_end_gone: this branch replaced its kernel file handle, an
  object with no other end that answers being readable, with a memory
  object when the kernel's File object went. A memory handle has no
  WAIT right, so the watch is refused PermissionDenied before the kernel
  asks whether the object has an other end, and the test's NotSupported
  never comes. The case now uses an event's wait side: watchable,
  readable when raised, and no pipe end. The kernel's order (rights
  first, then whether anything could answer) is right: a holder without
  WAIT learns nothing about the object.

- 207_libc_names: readdir on /tmp gave DT_UNKNOWN for regular files under
  the kernel's tmpfs; /tmp is now the file server's memory volume, whose
  listing carries each entry's kind, so libc reports DT_REG. DT_REG is
  right: POSIX leaves d_type to the implementation, and Linux's tmpfs,
  ext4 and the rest report DT_REG for a regular file. The case prints
  DT_REG by name rather than "another type", and its expectation says it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The judge failed readdir_bound on both benches at 9fe8f77: "no exit
record, never ran". f916f41 moved it off its own boot (testcases-
readdir, needed while it filled the kernel's /tmp to its listing limit)
onto TESTCASES, but kept it in RUST_SKIP and never added its job to that
arm, so nothing ran it. On the K4 baseline 8ac4f21 it had its own boot,
which that run did not stage either. The test now cleans up after itself
on the served /tmp, so it needs no boot of its own: its RUST_SKIP entry
and its explicit row go, and it is a discovered member of the shared
boot, judged on its exit like the others. The Arm doc's example of a
test that must not share named it; it now says the rule without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Review r10 of #1152: 9fe8f77..6fd7151 (two testcases members)

Scope. This range changes three test files and nothing else (+15 −10). The metal reds at 9fe8f77 are recorded at evidence pr-1152@ac3e8b1969e08e85ff63e58ea82d9426e43c0e09 under storage-integration/r13-testcases-members/. On both the T14 and the AMD bench they show PermissionDenied where NotSupported was expected for the memory object, and entry: a another type where DT_UNKNOWN was expected.

Architectural assessment

1. test_rs_pipe_other_end_gone: checking rights first is the documented contract.

  • The ABI defines Rights::WAIT as "Block on it, or name it in an OP_WATCH" (sdk/abi/src/handle.rs:160).
  • process_watch resolves the handle with Rights::WAIT before arm asks pipe_end_watch anything (kernel/src/inbox/mod.rs:525-537). sys_handle_wait does the same (kernel/src/syscall/event.rs:58).
  • The kernel checks authority before it looks at the object. A holder who lacks the right to watch should not learn what the object would answer.
  • initial_rights gives SharedMem only DUP|TRANSFER|MAP[|WRITE], with no WAIT (kernel/src/object/ops.rs:44-47). This range does not change that, and Consolidate storage around native bcachefs and userland filesystems #1152 does not narrow it. The previous revision (origin/main..9fe8f779e) had swapped the removed kernel File (which carried WAIT) for a memory object, and that swap made the test check the wrong refusal.
  • EventWait carries BASE, which includes WAIT (ops.rs:59), and pipe_end_watch answers None for it (ops.rs:234). Both OTHER_END_GONE and READABLE|OTHER_END_GONE therefore reach Some(None) => NotSupported in arm (inbox/mod.rs:557-558).
  • The READABLE half has a real read watch on an event. The second case therefore still tests "refused whatever else it asks", which is the refusal the test exists for. The account holds.

2. 207_libc_names: DT_REG is correct.

  • libc maps KIND_FILE → DT_REG (sdk/libc/src/posix_io.rs:677). The values in sdk/libc/include/dirent.h match the Linux/glibc ABI (DT_UNKNOWN 0, DT_DIR 4, DT_REG 8).
  • Linux's readdir(3) lets a filesystem answer DT_UNKNOWN. Common Linux filesystems such as tmpfs and ext4 answer DT_REG for a regular file created with creat. Both answers are valid under the interface. Now that the served volume reports the entry kind, DT_REG is the accurate answer.
  • Adding the DT_REG arm, rather than only editing .expect, is right. The old ternary would have printed another type for a correct answer.

Findings

  • BLOCKER tests/guest/rust-tests/src/bin/pipe_other_end_gone.rs:78-82: unnecessary unsafe.

    • The test calls the raw syscall::event_create and adopts both handles with unsafe { OwnedHandle::from_raw(..) } and a SAFETY comment. The SDK already has the safe typed pair: toyos::wake::pair() -> (Waker, Bell), and Bell: AsHandle (sdk/toyos/src/wake.rs:25,113). Other guest tests already use it (libc_recv_beside_close.rs:139, cpal_drop_unreleased.rs:99).
    • The raw path adds an unsafe precondition, widens the toyos_abi::syscall import to {self, SyscallError}, and needs a manual drop of the signal side. None of that is needed.
    • Fix: let (_waker, bell) = toyos::wake::pair().expect("an event"); refused(bell.as_handle(), "an event's wait side");, then drop the import change.
  • SUGGESTION pipe_other_end_gone.rs:76-77: shorten the comment. "Would be refused for that instead" mostly records the previous revision's mistake. A shorter contract is enough, for example: // A watchable handle with no other end; a memory object lacks WAIT and is refused for that first. Deleting the comment is also acceptable.

  • Evidence gap, not yet a blocker of this range. The fix at 6fd7151 has not been measured. The evidence shows only the reds at 9fe8f77, and the README defers judgment to the restaged testcases boots at 6fd7151. Two points are inferred, not observed:

    • DT_REG is inferred from the mapping. The red shows only "another type", meaning neither 0 nor 4.
    • The event case is inferred from the kernel code.

    The cheapest sufficient check is the orchestrator's restaged boot:testcases on the T14 and the AMD bench at the fixed head. Both members should pass, with entry: a DT_REG / entry: b DT_REG in the readback. The wake::pair change above does not alter what the kernel sees, so that run can be done after the fix. LAND requires that run.

SEND BACK

The event's wait side came from the raw event_create, adopted under
unsafe; toyos::wake::pair() gives the same handles typed and safe, and
the Bell is the wait side the poller watches. The import change and the
manual drop go; the comment states only the contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Review r11 of #1152: 6fd7151..57dedf1 (test registration and one test)

Scope. Two commits change three test files, +8 −21. No production code changes.

Architectural assessment

readdir_bound is now an ordinary discovered member of the shared testcases boot. That is the right shape. The old reason it needed its own boot was that it filled the kernel's /tmp up to the VFS listing limit. That reason went away when f916f41 moved it to the served memory volume and made it delete what it creates. Keeping it on RUST_SKIP while giving it an explicit row whose arm never staged its job left the test with no run, and nothing reported that. Deleting both the skip entry and the row fixes this. File discovery now registers the test, which is the declared single path for shared members (discover_rust_tests, shared_metal).

1. The r10 blocker is resolved

tests/guest/rust-tests/src/bin/pipe_other_end_gone.rs:76-79 now does exactly what was asked:

  • It uses toyos::wake::pair() and refuses bell.as_handle().
  • It has no unsafe, no raw event_create and no manual drop.
  • The import is back to toyos_abi::syscall::SyscallError alone.
  • The comment is the suggested one-line contract (Bell: AsHandle, sdk/toyos/src/wake.rs:113).

_waker now lives until the end of test() where the signal side used to be dropped right away. Nothing later depends on that, so the change in lifetime is harmless.

2. readdir_bound (275a9e6)

Registration is consistent.

  • With the name off RUST_SKIP, discover_rust_tests returns it, and shared_metal stages it as test_rs_readdir_bound among the shipping members.
  • With the METAL row gone, registered sees the name once.
  • No literal test_rs_readdir_bound remains in tests/toyos.rs or tests/common/. So suite_split's driven-and-shared check does not flag it.
  • The only remaining literal is tests/metal/src/image.rs:163-168, where it is just an example job name for a config-derivation unit test. That is harmless.
  • The Arm::boot doc (tests/common/metal.rs:37-40) no longer cites it, and the rule it states is still true.

No other test is in the same unreachable state.

  • I checked every METAL row on TESTCASES that judges a job: wake_storm_cost, audio_tone, audio_tone_resampled, TONE_BESIDE_LOG_WRITE, volume_step, syscall_cost, spawn_cost, wake_onto_busy, job_fairness and claims::RECLAIM. Each judged job is in TESTCASES' job list (tests/toyos.rs:1871-1887). The other TESTCASES rows judge kernel or loader text and logs, not a job.
  • The TESTCASES_HELD rows that judge a job give it an explicit jobs:.
  • I also checked every RUST_SKIP name for a driver. The ten with no literal test_rs_<name> in the harness are each reached another way:
    • by an image config's args (e.g. tests/images/undeclaredcase.toml:29);
    • by a tests/common/* driver using format! (libcchildren.rs, launchstdio.rs, sessionlifecycle.rs, hostedclang.rs);
    • as a spawned child (disk_backtrace_child).

Cleanup and the shared boot.

  • /tmp is the memory role of the file server (lib/manifest/src/lib.rs:133,167). It is a single volume shared by every member of the boot.
  • On success, the test removes all 6,000 files, checks that the directory lists empty, and removes the directory (readdir_bound.rs:23-29). Nothing is left behind.
  • On a panic partway through, /tmp/readdir_bound stays with up to 6,000 empty files. This does not affect other members:
    • MemoryVolume has no entry or listing limit (system/fileserver/src/memory.rs), so leftover entries cannot cause later refusals.
    • The leftover is one subdirectory, so /tmp's own listing grows by one entry.
    • The one member that lists /tmp, 210_stat_identity.c:117, looks entries up by name.
    • No other test uses the path.
    • The leftover costs some file-server memory until reboot, and that run is already red.
  • The test is never retried in the same boot, so create_dir cannot hit its own leftovers.

It still claims what it exists for.

  • std's first listing buffer is 64 KiB (sdk/toyos/src/files.rs:399). The server window is 2 MiB (WINDOW_BYTES), so the caller's buffer is the binding limit.
  • 6,000 twelve-byte names are 72,000 bytes, more than that buffer, so the listing needs at least two batches or a regrown buffer.
  • The test asserts no duplicates, a count of exactly 6,000, and every expected name present. That catches truncation and repeats across the batch boundary. The header and the in-code comment are accurate.

Findings

  • SUGGESTION 275a9e6's message says "the test now cleans up after itself". The cleanup was added in f916f41, and this commit does not touch the test. It is history only and does not need to change.

  • Evidence gap, not a blocker of this range. The served-volume readdir_bound has never run on either target. The recorded verdict is "no exit record, never ran". Two things are therefore inferred, not observed:

    • that the test passes;
    • that its roughly 12,000 served create and remove calls fit inside the shared boot's deadline. It adds only RUST_MEMBER_MS = 300 ms to the allowance.

    The metal testcases boots at 57dedf1 on the T14 and the AMD bench answer both. Landing needs test_rs_readdir_bound and test_rs_pipe_other_end_gone exiting 0 on both benches, and 207_libc_names reading DT_REG (carried over from r10), with the boot ending inside its deadline. Host CI at 275a9e6 (exit 0) still covers 57dedf1: that commit changes only one guest binary's handle construction, which host CI does not run.

No blockers remain in this range. LAND depends on the metal testcases boots now running at 57dedf1 showing the members above passing.

LAND

@Japabu
Japabu marked this pull request as ready for review November 5, 2026 02:54
@Japabu
Japabu added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit e11b6a7 Nov 5, 2026
10 checks passed
@Japabu
Japabu deleted the codex/storage-consolidation branch November 5, 2026 03:48
Japabu added a commit that referenced this pull request Nov 5, 2026
…ock (S0-a)

Every user copy goes through `arch::uaccess`: on x86-64 `stac; rep movsb;
clac` (or `rep stosb`), on AArch64 unprivileged LDTR/STTR. A Ring 0 or EL1
fault at one of those accesses on a user address is served as the process's
own fault would be and the copy resumes, or the copy resumes at its fixup and
answers the bytes it did not copy, which `user_ptr` reports as `Faulted`
(`BadAddress`). Each routine clamps an address at or past the user half's top
to 0 without a branch (`cmovae`; `csel` plus `csdb`), so a mispredicted range
check loads no kernel byte.

No user copy runs under a spin lock: `user_ptr::gate` panics above the
syscall's preemption depth or with interrupts masked. The kernel has no
sleeping lock since #1152, so that is every kernel lock. Callers clone their
object out of the process's lock (`syscall::handles::object`) and copy after;
a device claim is described under it and copied out after (`DeviceRead`).

Pipes and messages share one claim protocol whose waiter records itself each
time it finds the claim held (`kernel::pipe::Bytes::turn`), so no wake is lost,
and an abandoned write announces its room. A pipe transfer of at most 4 KiB
and every message bounce through the caller's stack in the hold that looks the
handle up; a larger pipe transfer claims its bytes and copies straight between
frames and the user. The SDK's `lock_word` spins briefly before it sleeps.

SMAP and PAN being required, and the entry `clac`, are main's (#1154). The
kernel-file and console parts of the earlier rounds went with #1152.

Closes issues/a-user-copy-demand-pages-under-whatever-its-caller-holds.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 5, 2026
…ock (S0-a)

Every user copy goes through `arch::uaccess`: on x86-64 `stac; rep movsb;
clac` (or `rep stosb`), on AArch64 unprivileged LDTR/STTR. A Ring 0 or EL1
fault at one of those accesses on a user address is served as the process's
own fault would be and the copy resumes, or the copy resumes at its fixup and
answers the bytes it did not copy, which `user_ptr` reports as `Faulted`
(`BadAddress`). Each routine clamps an address at or past the user half's top
to 0 without a branch (`cmovae`; `csel` plus `csdb`), so a mispredicted range
check loads no kernel byte.

No user copy runs under a spin lock: `user_ptr::gate` panics above the
syscall's preemption depth or with interrupts masked. The kernel has no
sleeping lock since #1152, so that is every kernel lock. Callers clone their
object out of the process's lock (`syscall::handles::object`) and copy after;
a device claim is described under it and copied out after (`DeviceRead`).

Pipes and messages share one claim protocol whose waiter records itself each
time it finds the claim held (`kernel::pipe::Bytes::turn`), so no wake is lost,
and an abandoned write announces its room. A pipe transfer of at most 4 KiB
and every message bounce through the caller's stack in the hold that looks the
handle up; a larger pipe transfer claims its bytes and copies straight between
frames and the user. The SDK's `lock_word` spins briefly before it sleeps.

SMAP and PAN being required, and the entry `clac`, are main's (#1154). The
kernel-file and console parts of the earlier rounds went with #1152.

Closes issues/a-user-copy-demand-pages-under-whatever-its-caller-holds.md.

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