Repository navigation
Conversation
Review r1 of #1123 at ae980c4 (memory stage 7a)I read both commits, the whole diff against the merge base 49559dd, and every changed file in full. I also read the callers and boundaries: Architectural assessmentThe split of responsibilities is right. The kernel owns the frames, the index, the one size, the waits and revocation; the server owns policy, write-back and eviction. Pager, owner and holder are separate
Lock order also holds:
R2 holds by inspection. There is one problem with the representation. Each file costs a Size of the change: about +3.25k production lines (kernel ≈1.3k, fileserver ≈0.7k, SDK/ABI ≈0.5k) and ≈0.86k test lines, counting inline test modules. Deleted: BlockersB1. A pager can panic the kernel with a supply offset.
B2. The object-id counter wraps into a kernel panic.
B3. The file server runs out of handles after about 4,000 distinct files.
B4.
B5. The pager's core is not held as resident (R6, swap-track amendment).
B6. The 96 MiB interim's justification is out of date, and replacing #1099's sizing goes back to the orchestrator.
B7. The SDK wraps an unchecked handle as a
B8. New private page-size copies.
B9. Evidence gaps for material claims.
Suggestions
Answers to the brief's checks
SEND BACK |
Review r3 of #1123 at dd88215 (focused re-review)Scope: I checked r1's B1–B9 and r2's R1–R5 at dd88215. I also read the two fix commits (265581d, dd88215) in full against the files they touch: Architectural assessmentThe fixes keep the r1 split of responsibilities. The kernel owns the frames, the index, the waits and revocation. The server owns eviction and write-back. Addressing objects by id removes the per-file server handle without adding a mechanism. Slot ids remove the counter. One consequence of addressing by id was missed. An owner call no longer holds a reference on its object, so any owner call that drops the pager lock and comes back must resolve its object again. Earlier findings at dd88215
BlockersB1. A pager's owner read that races its own release panics the kernel.
B2. A module header is false since R3.
B3. The file-server page module's header contradicts the code since r1's S3.
B4. An unsafe precondition comment is false since
Suggestions
SEND BACK |
Review r4 of #1123 at 8153b4b (focused)Scope: r3's B1–B4 at 8153b4b; the #1120 port fe05b82 ( AssessmentThe B1 fix is the right shape. An owner read now holds a reference, as a holder does, so the slot's lifetime has one rule. The port keeps #1120's rule across the reserve/write-back split without a second mechanism. One part of the split is still not carried across. Main's DATA write read a partly written page's old bytes only where the file had held a block for that page. The split reads every page below the old size. For a page in a hole, that page's block is the one the reserve just allocated, and it still holds stale bytes from the file that last owned it. r3 findings at 8153b4b
BlockersN1. A partial write into a hole below the old end reads another file's stale bytes into the file (DATA).
N2.
N3. The syscall-profile guard no longer guards the ABI's last number.
fe05b82 otherwiseCorrect on the refused path. In DATA's
The pager path's The default Rebase resolutions
Suggestions
SEND BACK |
`reopen_read_4k` opens the 4 KiB file `open_close` opens, reads it whole and closes it: what a file read again after its last close costs, which a server that keeps a closed file's pages makes a read of memory. It is a row of its own so a change to what the server keeps can be judged against a base that measures it the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ithout a request (memory stage 7a) Memory design §7's paged object, without mappings. A pager process supplies one object per file; its holders read it through SYS_PAGED_READ, which copies pinned frames and never asks the file server for a page it holds. The file server's block cache loses its data half: a file's bytes are in its object, and the cache keeps the volumes' structures alone. Kernel (kernel/pure/paged.rs, kernel/src/object/paged.rs): three kinds. A process's one Pager, made only by a process started as one, neither duplicates nor transfers, and its last handle ends every object it serves. A PagedOwner per object supplies by copy into fresh frames, writes under write pins, withdraws, fails, resizes and writes back. A Paged holder reads, waiting in its own request record, linked on its pager, for a supply, a failure, a shrink, the pager's end or a kill; past the pager's report time it is counted and logged overdue and waits on. A pager's process is refused a holder's read. The index is one open-addressed table of the pager's capacity, so nothing it does allocates and its cost is in proportion to the capacity. The kernel holds the one size, and a write grows it as its bytes land. Authority: Rights::PAGER on a SysCap named by SpawnArgs.pager starts a child as a pager. The supervisor holds it and confers it on a row the signed system.toml marks `pager`, the file servers' alone; no syscap name maps to it, and ROOT's server, whose files are immutable, is none. File server (system/fileserver/src/pages.rs): each DATA, LOG and BOOT file has an object, by its volume identity, which holds the volume's node for its life and outlives its last close until room is wanted. An open to read is answered with a holder. A request is read off the volume and supplied; a write reserves on the volume, then lands dirty in the object, a page in part merged with what it held below the old end; a sync writes the dirty pages back before the volume's own sync. A truncation empties the object before the volume frees the blocks. Data passes the block cache by (BlockDev::read_data, write_data), which a counted disk write keeps durable at the next flush; fat32's write splits into reserve and write_back. Every role's cache keeps 16 MiB of structures, in place of #1099's eighth of physical memory for DATA: the file pages that eighth was for are the pager's, within its fixed capacity, and #1099's re-read test moves to the paging layer. SDK: toyos::paged wraps the calls; a served descriptor keeps its holder, and a read at its local offset or a pread goes through it without the capability's turn, the server answering where the holder cannot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…zed as #1099 sized the cache A pager addresses its objects by id through its one handle (B3): owner calls take the pager handle and the object id the create answered, the PagedOwner kind is gone, and an object is let go by PagedOp::Release. The server holds one handle however many files it keeps; a create the pager refuses for want of room drops the oldest file nobody holds and asks again. Holders keep a handle each, counted to their client. An object's id is its table slot, so no counter can wrap (B2). Every offset, length and size a pager names is checked against the last page a u64 can end at before the object sees it (B1). The core is kept for the pager's life (B5): kernel::paged::Core counts it, and a frame that leaves while the core would hold less is held aside for the next supply rather than given back and promised again. A pager's capacity is the caller's, bounded by what its job is charged for the index (B6). The index and the object table are kept in pieces of at most 1 MiB (kernel::chunked): a capacity of an eighth of memory asked the kernel heap for one block past its largest allocation, which panics. The file servers split an eighth of physical memory as #1099 did, 16 MiB each for LOG and BOOT and the rest for DATA. The SDK takes a received handle as a holder only once a read of nothing through it answers as a holder's does (B7), and refuses anything else as Malformed. One page constant (B8). linkcase's file-server row is a pager (B4), and the build refuses a file-server row that is not. A holder's wait is reported overdue from its own start and stops being counted when it ends (S1). The withdrawal test bounds its probes, so its control fails rather than hangs (S2). A truncation, and a truncating open, change the volume first and the object after, so a refusal drops no dirty page (S3). A request the server cannot supply is failed (S6). pagerprobe, a pager row testcases' test-runner launches, ends its pager under a helper parked on a page it never supplies: the helper answers Gone, and the pager is refused a holder's read of its own object. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d, and no removal grows a list A holder's read resolves its handle by kind, so a handle a peer sent as a holder and is not one is refused InvalidArgument rather than faulting the caller; the SDK takes such a handle as no holder, and one whose object ended as none either (R1). A release checks the object live, lets its pages go and clears it in one hold of the pager's lock, as the pager's end does, so a second release or one racing the end finds it released (R2). Every removal gives each frame back as the index answers it, and the pager's end frees what it holds aside one by one: no list grows with an object's pages under the lock, which past 512 MiB in one object asked the kernel heap for more than it allocates at once (R3). Write-back ranges move from each object to one table per pager, which shrinks every object's entry, and DATA keeps an object for every 16 pages of its capacity, at most 65,536. A table's pieces are 1 MiB less 8 KiB, so two share a superframe, and a list of pieces past one is refused. An open the server refuses an id for undoes its count (R4). The page-range contracts say they are clipped (R5). The SDK tries a file's holder inside the one descriptor lookup a read makes: the extra lookup slowed every kernel-file read. DATA keeps a name's length and time current as its open file changes, so a listing no longer looks each name up among the open nodes the retained objects keep. pagerprobe holds more than 512 MiB in one object, withdraws it whole, releases it, is refused a second release and leaves another as large for its end; its helper asks for a pipe end as a holder and lives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erve took #1120 made DATA's write give back every block it allocated when the copy that followed was refused. With 7a the allocation is `Volume::reserve` and the copy is separate (the paged object's write, or `Writes::write`'s write-backs), so the rebase onto #1120 carries its rule across that split: - `reserve` answers a `Reserved`: the size and the pages the file held blocks for before it. - `Volume::unreserve(node, reserved, landed)` returns a refused write to that size, or to where its bytes landed, and gives back the blocks past both. The default is the cut back to that size, which suffices on a format without holes (FAT). DATA keeps blocks only up to what the file held, since a write into a hole below the old end takes a block a cut to the old size would keep. - `Files::write` and `Writes::write` call it on a refusal; `Files::write` no longer truncates for it. DATA's unreserve records no entry: a file's entry is written only by its own calls (close, rename, truncate, sync), none of which runs inside a write, so nothing named the reserve's blocks. #1120's test is back, through `Writes::write`, beside one for the hole: both fail with DATA's unreserve deleted, and with `Writes::write` giving nothing back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…omments say what the code does - B1: an owner call names its object by id since r1 and holds no reference on it, so `owner_read`, which drops the pager's lock between chunks and around each copy, found its slot emptied by a release from another of the pager's threads and panicked at `State::entry`. It now takes a reference in the hold that resolves the id, as a holder's read has, and a `Ref` guard gives it back when the read ends. `read`'s contract says the caller holds one. The entry stays until then; a released object reads as gone. - B2: the kernel module header no longer claims removals collect lists. - B3: the file server's page module header drops the truncation order it no longer has; `Files::truncate` states its own. - B4: the SDK read's SAFETY says the closures that run run one after another, each slice dead before the next. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Files::write` gives back a refused write's room through `Volume::unreserve`, the same call `Writes::write` makes, but only the direct path had a test of a write into a hole. This one writes a page into a hole the file was extended over, inside its size, with every supply refused: the room is as before, the size unchanged, and the hole reads zeros off a disk whose every block held 0xAB. With `Files::write` cutting back by `truncate` again, as before the port, it fails on the kept block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…file held a block N1: DATA's write on main read a partly written page's old bytes only where the file already held a block for it. The reserve/write-back split read every page below the old size, so a write in part into a hole below the old end (a file truncated up, then written inside) read the block its reserve had just taken, which still held a deleted file's bytes, and supplied them dirty around the written ones. `Reserved::contents` is where the old bytes on the volume end: the size, or the end of the pages the file held blocks for where that comes first. `Files::write` and `Writes::write` read a page in part below it and zeros past it; the supply's length still runs to the old size. FAT's `Reserved` names a block for every page of its size, so its contents are its size. Tests on the all-0xAB image, one through DATA's `Writes::write` and one through `Files` (a holder's read and the volume after write-back): each fails with its path reading below the old size again. N2: `Entry.refs` names the owner read's reference. N3: the profile guard names the ABI's highest number, SYS_PAGED_NOTICES. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rebase onto main brings #1123's paged objects, which free a frame with `pmm::free_drawn` as the shared regions now do, and #1104's clock page, which reads its object's one page: both take the commit's types. SYS_SHM_COMMIT is 160, past #1121's SYS_MESSAGE_RECV. The guest tests map with a protection, as main's `shm_map` asks, and a connect's answer carries main's three bells. The kernel's `Stat` size assertion, left from the removed `committed` field, goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rebase onto main brings #1123's paged objects, which free a frame with `pmm::free_drawn` as the shared regions now do, and #1104's clock page, which reads its object's one page: both take the commit's types. SYS_SHM_COMMIT is 160, past #1121's SYS_MESSAGE_RECV. The guest tests map with a protection, as main's `shm_map` asks, and a connect's answer carries main's three bells. The kernel's `Stat` size assertion, left from the removed `committed` field, goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Draft. Eight commits on main c1218e8 (after #1104): the osbench row (the A/B's base), 7a, review r1, review r2, the port of #1120 across the reserve/write-back split, review r3, the paged path's hole test and review r4. The metal A/B at r2 is in and judged (below); the rebase onto main and r3 are verified on the host and QEMU, and the metal testcases boots at 8153b4b passed on both benches; a testcases boot at the r4 head is staged.
Problem
A cached read of a file on DATA cost a round trip to its file server: 9.5–11 µs for
read_4k_atagainst the kernel/tmp's 1.2–1.4 µs and Linux's 0.5–1.1 µs (fs-rtt r1, r2). The file server's block cache held file data in the server's own memory, so only the server could answer.Change: memory design §7, stage 7a
The owner approved this stage plan, and two design reviews converged on revision 3. A file's pages live in a kernel paged object that its file server supplies. A reader holds a handle to the object and reads it through
SYS_PAGED_READwithout asking the server. 7a builds no user mappings; those come in 7b.Kernel (
kernel/pure/paged.rs, host-tested;kernel/src/object/paged.rs;kernel/src/syscall/paged.rs):Pageris a process's one pager, made only by a process started as one. It neither duplicates nor transfers, and its last handle ends every object it serves. A pager names its objects by id: every owner call takes the pager handle and the idSYS_PAGED_CREATEanswered, so a server holds one handle however many files it keeps. An object goes byPagedOp::Release.Pagedis a holder of one object, which can be narrowed and sent; each counts against its client's handle table, as any open file does.InvalidArgument, never a fault of the reader. Every offset, length and size a pager names is checked against the last page au64can end at before the object sees it.kernel/pure/chunked.rs): two share a superframe, and the kernel heap panics on one allocation past 2 MiB less 4 KiB, which a capacity of an eighth of memory asked for (found by the linkcase run). A list of pieces past one piece is refused.requested,no_holders) read withSYS_PAGED_NOTICES. The pager handle reads ready while one is owed. Ano_holdersnotice is given only while it is still true.Authority:
Rights::PAGERon aSysCapnamed bySpawnArgs.pagerstarts a child as a pager.system.tomlmarkspager = true: the file servers' rows in all 16 configs, which the build now requires of a row with roles, and testcases'pagerprobe.syscapname maps to it, and a package row is never a pager (both tested inlib/manifest).File server (
system/fileserver/src/pages.rs, host-tested against a model of the kernel object):Reserved::contents: the old size, or the end of the pages the file held blocks for where that comes first. A page in a hole reads zeros, never the block its reserve just took. A write refused part way is cut back to what it put, and the room its reserve took past that is given back (Volume::unreserve; below).writeback_begin, owner read,Volume::write_back,writeback_end), then runs the volume's own sync.Gone, and the SDK falls back to the server, which answersStaleas before.NoSpaceonly when every one is held open.visitis deleted. Data passes it by throughBlockDev::read_data/write_data, which meet a block the cache holds in the cache. A data write outside a flush is counted, so the next flush still asks the disk to keep it: the oldunflushedflag would have lost one that landed during a flush. The limits shrink to 16 MiB clean and 8 MiB dirty.toyos-fat32's write splits intoreserveandwrite_back, and its data I/O uses the data calls.SDK:
toyos::pagedwraps the calls with typed refusals.Dir::opencarries the holder, which the SDK takes as one only once a read of nothing through it answers as a holder's does; anything else is refusedMalformed, and a holder this process may not read, or whose object ended, is read through the server.pread, goes through the holder within the one descriptor lookup a read makes, without taking the capability's turn, so a reader waiting on a page holds up no other thread's request. Where the holder cannot answer, the server does.Charging (the orchestrator's ruling R6): the pager's core is committed at once and kept for its life, resident by kind: the dirty ceiling and up to 512 pages of page-in headroom.
kernel::paged::Corecounts it; a frame leaving while the core would hold less is held aside for the next supply, never given back and promised again, so the allocator cannot take the core under pressure. Clean pages above the core are promised one at a time as they are supplied. The index and object table are charged with the pager, and a capacity the job cannot be charged for is refused. The capacity itself is the server's; the eighth of memory is the interim bound, recorded with its exit.Records:
process-memory-in-4-kib-pages-still-copies-each-programs-text.mdandstorage-is-layers-and-a-role-is-a-filesystem.md.a-sequential-read-of-a-file-as-large-as-datas-clean-cache-reads-the-disk.mdis updated: its exit is now the server's eviction over its objects, and it stays open.a-pager-holds-a-fixed-capacity-until-the-memory-service-reclaims-its-clean-pages.md(the capacity no other job can take back, and its exit);a-read-and-a-write-of-a-paged-file-can-tear-across-pages.md(the XSH 2.9.7 gap);an-overdue-paged-read-is-no-wait-cause-in-the-roster.md.Rebased onto #1108 and #1120:
SYS_CLOCK_NOW), soPagerandPagedare 19 and 20 and the paged calls 153–158 (SYSCALL_PROFILE_BINS160; the profile's compile guard namesSYS_PAGED_NOTICES). That renumbering is the only change from the rebase onto clock: the TSC keeps CPUID 15H's rate or one the HPET refines; a step the RTC confirms is slewed, a second demotes it #1104, so the metal boots at 0992ed8 still apply. The cache's flush is blockring: the arena holds only what is on the wire, and a flush keeps many writes there #1113's parallel one; its runs count into the samewrittenmark data writes do.reserveand the bytes come after, so its rule now crosses that split:reserveanswers aReserved(the size and the pages the file held blocks for), andVolume::unreserve(node, reserved, landed)returns a refused write to that size, or to where its bytes landed, and gives back the blocks past both. The default cuts back to the size, which is enough without holes (FAT). DATA keeps blocks only up to what the file held, because a write into a hole below the old end takes a block that a cut to the old size would keep.Files::writeandWrites::writecall it. It records no entry, because only the file's own calls write its entry and none of them runs inside a write.A new osbench row,
reopen_read_4k(open, read 4 KiB, close), is its own first commit, so the base measures it the same way.Checks
At 82237ea (rebased onto #1104) (evidence):
cargo run -- --ci hostexit 0, 108 steps green.At 0992ed8 (review r4) (evidence):
cargo run -- --ci host: exit 0, 107 steps green.cargo test -p fileserver: 107 passed.data::tests::a_write_in_part_into_a_hole_reads_zeros_around_itandpages::tests::a_write_in_part_into_a_hole_supplies_zeros_around_itboth run on an image whose every block holds 0xAB. Each writes one byte at 6000 into a hole that a truncate up left, and the page then reads zeros around that byte: through a holder and again after write-back. Each fails when its path reads below the old size again (controls D and E, rc=101: "page 1 reads 0xab", "a holder reads another file's bytes").pages::tests::a_write_refused_in_a_hole_gives_back_the_block_it_took. The room is unchanged and the hole reads zeros. It fails whenFiles::writecuts back withtruncateagain (control C).Reserved.At 8153b4b (rebased, r3) (evidence):
cargo run -- --ci host: exit 0, 107 steps green.cargo test -p fileserver104 passed. fileserver: a write refused after allocating gives back the blocks it took #1120'sa_write_refused_after_allocating_leaves_no_old_bytes_past_the_endis back, now throughWrites::write, besidea_write_refused_in_a_hole_leaves_the_hole_reading_zeros.unreservedeleted, so the default cut to the size runs, both tests fail (the hole's block is kept); withWrites::writegiving nothing back, both fail.--debug:a_pager_s_end_answers_its_waiting_holders_gone,a_served_file_reads_through_its_paged_object,served_file_offset,std_fsandfile_mtime, each exit 0, andpagerprobe: okin the boot log.Refguard gives it back. Like a holder, it can then never meet an empty slot. No test drives two pager threads, because today's servers are single-threaded.Metal A/B at r2 (base 28a8aac, head dd88215 on main 49559dd; T14 and AMD, base, head, base, head; every boot passed; p50 and per-boot results). Judged as at r1: a row counts as moved only where both heads lie outside both bases.
fs.disk.read_4kfs.disk.pread_4kfs.disk.read_4k_atfs.disk.reopen_read_4kfs.disk.open_closefs.disk.seq_read.4096fs.disk.readdir.1000fs.mem.read_4kfs.mem.read_4k_atfs.mem.seq_read.4096seq_read.4096rises from 430 to 3,640 MB/s on the T14 and from 463 to 5,450 MB/s on the AMD.open_closeandreopen_read_4kfall too, since a retained object keeps its node.fs.mem.read_4k, +12% at r1, is now 4% below base. AMDfs.disk.readdir.1000, +28% at r1, now lies within its bases (691/677 → 693/682 µs).fs.memdifference remains, measured and unexplained. These reads never reach a server.fs.mem.seq_read.4096reads 3–9% below base on both benches. On the AMD,fs.mem.read_4kandread_4k_atread +4–5%, while on the T14 they read the same as base or lower. This is far inside the bug guard and may be code layout. r2 already removed the second descriptor lookup.stat,fstat, thedirectrows). Thefs.memrows show no such spread. The AMD'sfs.diskdeltas above are therefore ranges and not points.a_served_file_reads_through_its_paged_object,served_file_offsetanda_pager_s_end_answers_its_waiting_holders_gonepassed; so did the T14's proctreecase boot, includingapp_view(exit 0).Earlier, at dd88215 on 49559dd (evidence):
pagerprobecovered the >512 MiB object, the double release and the pipe end as a holder.a_served_file_reads_through_its_paged_object,served_file_offsetanda_pager_s_end_answers_its_waiting_holders_gonepassed on both, judged offline from the readbacks with--metal-readback(rc=0, 3 passed). The benchcase A/B was not rerun on the new main.Unresolved and risks
main.rs, which no host test reaches;Files::closed, which it calls, is tested.🤖 Generated with Claude Code