Skip to content

fileserver: a volume's cache keeps an eighth of physical memory clean - #1099

Merged
Japabu merged 5 commits into
mainfrom
disk-path-cache
Oct 31, 2026
Merged

Japabu merged 5 commits into
mainfrom
disk-path-cache

Conversation

@Japabu

@Japabu Japabu commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The file server's cache kept at most 16,384 clean blocks (64 MiB). It drops the block read longest ago and doesn't move a block it hits. fs.disk.seq_read.4096 rereads a 64 MiB file, and that file's blocks plus the tree nodes an open reads came to one more than the cache kept. Each fetched run then evicted the blocks the reader needed next, so every pass came off the disk whole. Before this change the AMD bench read 151–163 MB/s, against Linux's 4,960–5,054 MB/s.

A host test that reads the way the benchmark does showed the cause before any change: write 64 MiB in 4 KiB calls, sync, then open, read whole and close in rounds. At the old limit, every round after the first read 16,385 blocks off the disk.

Change

  • The limit is the server's. Each Cache is made with its clean limit, which DataVolume::probe/format and FatVolume::mount pass in.
  • The servers' caches together keep at most an eighth of physical memory clean. The log and boot roles keep 16 MiB each (SMALL_CLEAN_BYTES), enough for their FAT and directories; little of what logkeeper writes is read again. DATA keeps the rest of the eighth (data_clean_limit).
    • Reads and writes both fill a cache, since a flushed block stays as a clean one.
    • This is an interim bound: nothing gives a clean block back to another job, because nothing reports memory pressure. The new record issues/the-file-servers-clean-blocks-are-given-back-to-no-other-job.md holds it, owned with the memory track. Its exit: clean blocks dropped under the memory service's reported pressure, and CLEAN_SHARE deleted. no-physical-memory-fairness.md points to it.
  • A clean block is kept whole or not at all. Before, Box::new could not fail, so a cache miss while another job had exhausted memory aborted the server, losing whatever was still dirty. Now the cache reserves the block's place in the eviction order (order.try_reserve(1)), the largest single allocation the server makes, and then the block (Vec::try_reserve_exact). If either is refused, the read is answered from the disk's bytes and nothing is cached. A block a flush cleans reserves its place too; if refused, the block, already on the disk, leaves the cache (review r3).
    • ToyOS's allocator returns null where vm_reserve grants no mapping, and memory is committed at reservation, not at a fault.
    • Four allocations still cannot be refused, and the record names them: the block map's node, a small one every few inserts; a dirty block; a fetch's staging buffer; a flush's dirty list and run buffer. The module doc names the map node, the one left in keeping a block read in.
    • No host test: refusal needs an allocator that refuses, and the function is four lines.
  • toyos::system::memory_total() replaces three copies of the sysinfo decode: the file server, netstack and the compositor.
  • osbench: persist and the disk's cached rows run on ToyOS's DATA, the bench's NVMe #1097's cliff record is updated. It named CLEAN_LIMIT, which is gone, and now names the server's limit. It stays open: the eviction order is unchanged, so a file larger than DATA's limit still misses on every pass.
  • metalprobes-usb-read-is-answered-from-fileservers-cache.md named CLEAN_LIMIT and is updated.

Checks

Evidence: https://github.com/ToyOSOrg/evidence/tree/5d0acb50932854107ebbcb8ea031db961763ab0d

Coordination

🤖 Generated with Claude Code

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1099 at f436c15 (base 370d7d9, against origin/main e4bdb0a)

Architecture

Passing the clean limit into Cache::new is the right seam. The cache no longer hardcodes a policy, and the server that owns the cache chooses the bound. With no pressure signal, a self-imposed bound in the file server is the only interim owner available. The kernel must not own it, and the memory service that should drive reclaim does not exist yet. The change is also small: about +30/−15 production lines, mostly signature threading, and about +50 test lines.

The policy is weaker than the PR says. The precedent's own rationale is "an eighth leaves seven eighths for the kernel, the other daemons, and the clients' own heaps" (system/compositor/desktop/src/budget.rs:10). That rationale does not stretch to three more eighths. The compositor and netstack caps bound client requests that are rarely reached. These caches fill through ordinary use. A block that is written also stays: a flush pushes it onto order as a clean block (cache.rs:301), so the log server keeps everything logkeeper writes, up to an eighth of memory. The memory is clean and could be reclaimed in principle, but nothing reclaims it. So with the caches full, an ordinary job is refused memory while up to 3/8 of RAM holds clean blocks. That is the reverse of the accepted direction, where the memory service drops clean file pages first. A per-process eighth is an acceptable interim for DATA. The 3/8 total is neither justified nor recorded.

Findings

BLOCKER 1. issues/a-sequential-read-of-a-file-as-large-as-datas-clean-cache-reads-the-disk.md:10,28-31 (on main since #1097): the record goes stale on merge.

  • osbench: persist and the disk's cached rows run on ToyOS's DATA, the bench's NVMe #1097 has landed, so this PR is "whichever lands second". The merged tree still says "CLEAN_LIMIT clean blocks, 16,384 of 4 KiB (64 MiB)", and its exit counts reads over "CLEAN_LIMIT + 16 blocks". After this PR, neither the constant nor that size exists.
  • Fix: rebase onto main and rewrite those lines to "the limit the server gives the cache, an eighth of physical memory". The FIFO cliff above that limit stays open, as the PR says.

BLOCKER 2. system/fileserver/src/main.rs:109-115 (CLEAN_SHARE): the interim is unrecorded, and its justification and the PR's account of it are inaccurate.

  • Not recorded. No issue records the interim's owner or exit. issues/no-physical-memory-fairness.md lists the eighth caps as instances of its class and does not mention the file server's caches. a-sequential-read-... covers only the eviction order.
  • False reason. "the eighth netstack and the compositor take" cites a precedent whose stated basis (seven eighths left for everyone else) is broken by three such shares.
  • False growth claim. The PR's "A cache only grows with what is read" is false: flushed writes stay as clean blocks (cache.rs:296-302).
  • Missing consequence. Box::new at cache.rs:174 and :249 cannot fail. Under exhaustion by another job, the server's next miss aborts it (code 134, as a-64-mib-write-...-runs-its-file-server-out-of-memory.md shows). It then loses whatever is still dirty. The server now asks the kernel for new frames until it reaches an eighth, where at 64 MiB it reused its own freed blocks.
  • Fix.
    • Record the interim, with the file server and memory tracks as owners. A new issue or a bullet in no-physical-memory-fairness.md will do. The record states the total, that writes as well as reads fill it, and that nothing gives it back to other jobs. Exit: clean blocks are dropped under the memory service's reported pressure, and CLEAN_SHARE is deleted.
    • Either keep the file servers' total within the precedent's eighth, or state in that record why 3/8 is acceptable. One option: DATA takes the eighth and the log and boot roles keep a small limit, since the log role rereads little of what it writes.
    • Correct the comment, so that it does not lean on a rationale it breaks.

BLOCKER 3. "No other row moved beyond the base's spread" (the PR body, and the handoff's A/B judgment) is false, and a regression is uninvestigated.

  • From metal-amd/ab.md at evidence 6707308b:
    • mem.anon.map: A 201, 202, 203, 207 ns/MiB against B 382, 388 (1.90×). Both sets are tight, and they do not overlap.
    • proc.spawn_wait.large: A 155–186 µs against B 208, 211 (1.23×).
  • The only code between A (069cbbe) and B (34409c9) is bcachefs: a sync with nothing uncommitted writes no superblock #1098's bcachefs sync and this PR. Both rows are kernel memory paths: a 32 MiB mmap, and a spawn of a 32 MiB image from tmpfs. A file server that now keeps every block it has read or written is the more plausible cause. That is an inference, not shown.
  • Cheapest check: one AMD boot of B with only CLEAN_SHARE reverted to a 64 MiB limit. Or one boot of fileserver: a volume's cache keeps an eighth of physical memory clean #1099 without bcachefs: a sync with nothing uncommitted writes no superblock #1098. Either shows whether these rows follow the cache.
  • Until then, the PR body's "no regression" claim must not stand. Note that mem.anon.map runs in system, before persist. So whatever moved it was already resident at that point, which argues for the boot role's or DATA's mount-time retention.

SUGGESTION. system/fileserver/src/data.rs:1378-1383: the doc's last clause, "what an open reads of the tree evicts none of the file", claims more than the test can show. With a limit of twice the file, nothing could be evicted. The test establishes that open, read and close neither bypass the cache nor drop its blocks. Say that, or drop the clause. The "red control" is a different input (the old limit) and not a mutation of what the test guards. It is a fair reproduction of the cause, but it controls neither clean_limit() nor the volume path. clean_limit() is inspectable arithmetic, so no test is owed for it.

SUGGESTION. system/fileserver/src/main.rs:376-383: this is the third copy of the decode of memory_total from sysinfo, after compositor/src/session.rs:2170 and netstack/src/main.rs:~150. A toyos::system::memory_total() in sdk/toyos/src/system.rs would remove all three and keep toyos_abi decoding inside the SDK.

SUGGESTION. A clean block is the one allocation the server can safely skip. Allocating clean slots fallibly, and not caching on refusal, would remove the abort in Blocker 2 for reads. That might belong in the record's exit instead of this PR.

Evidence checked

  • Host: host/ci-host.log shows exit 0 at 9d00924ae. git diff 9d00924ae f436c156a deletes only one issue file, so the evidence is valid for the head. The new test ran green.
  • Repro: repro-on-main.log, exit 101, [16385, 16385, 16385] on 370d7d9. This is genuine and shows the cause.
  • Metal, AMD: seq_read.4096 rose from 151–163 to 371 and 459 MB/s, confirmed from the raw B-run1 lines. T14: still queued, as stated.
  • The 11× residual is attributed to the round trip by arithmetic only. The PR labels it as an inference, which is acceptable.

SEND BACK

@Japabu

Japabu commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2 of #1099 at 156e709 (rebased onto f407918)

Round-1 blockers

  1. Cliff record. Resolved. a-sequential-read-... now names the server's limit, says when 64 MiB was measured, and stays open.
  2. Unrecorded interim, and its justification. Resolved.
    • The three servers' total is now within the precedent's eighth: log and boot take 16 MiB each, DATA the rest.
    • the-file-servers-clean-blocks-are-given-back-to-no-other-job.md records the interim. It says writes fill the cache too and that nothing gives clean blocks back. Its owner and exit are acceptable, and no-physical-memory-fairness.md points to it.
    • The CLEAN_SHARE comment no longer leans on the seven-eighths rationale.
  3. False "no other row moved". Partly resolved. The PR body now states both rows. The regression is still unexplained (below).

Taken from the suggestions: toyos::system::memory_total() replaces the three copies cleanly. The test's doc now claims only what the test shows.

--build-only and --ci host were run at 156e709 with exit 0, per the PR body. Those runs cover the r2 code.

Findings

BLOCKER 1. system/fileserver/src/cache.rs:176-178 and the new record's third paragraph: the refusal path covers the block but not the rest of what caching it allocates.

  • boxed makes the 4 KiB block fallible. But inner.slots.insert (a BTreeMap node every few inserts) and inner.order.push_back still cannot fail. Both are on the same clean-caching path.
  • order is a VecDeque holding at least one 8-byte entry per clean block, and it reallocates whole when it doubles.
    • At DATA's limit on a 32 GiB machine (about a million blocks) that is 8 MiB or more in one allocation. Stale entries can make it up to four times larger before compaction.
    • It is the largest allocation the server makes. Under another job's exhaustion it is the likeliest to be refused.
  • So a miss under exhaustion can still abort the server on the path that the module doc ("refused, the read is answered from the disk's bytes and nothing is cached"), the record and the PR body say is safe. The record's list of what still cannot be refused names only "a dirty block and a fetch's staging buffer".
  • Fix, either way:
    • make the clean insert all-or-nothing: order.try_reserve(1) before boxed, with the BTreeMap node stated as the remaining exception; or
    • leave the code and state every remaining abort site in the record and the module doc.

BLOCKER 2. The AMD mem.anon.map 1.90× and proc.spawn_wait.large 1.23× must be explained before landing. I agree with the coordinator, with two points on what "explained" needs.

  • Attribution is the bar, not a fix.
  • The landed code has not been booted.
    • Both measured treatments (34409c9, and the queued cc47fc9) carry round 1's eighth per server, on the old base.
    • Main has since changed kernel/pure/frames.rs and kernel/src/mm/pmm.rs, the code these rows exercise.
    • The queued limit-revert boot answers the mechanism question on the old base and is worth having. The control for what lands is the whole change against its own base: 156e709 against f407918 on the AMD bench. Reuse existing AMD boots of f407918 if osbench: files reads read_4k_at's parts, fstat, and the file server's client alone #1103's evidence has them, plus one or two boots of the head.
    • The seq_read.4096 gain needs no new boot. By inspection, DATA's limit on that bench is still far above the 64 MiB file.

SUGGESTION. system/fileserver/src/main.rs:383-389: clean_limit(Role) needs an unreachable! for Role::System only because it takes the whole Role. fat_on could pass SMALL_CLEAN_BYTES and the DATA paths a data_clean_limit(). The unreachable state would then not be representable.

For the orchestrator (PR body). The body cites a-file-server-request-costs-ten-times-a-kernel-file-call.md, which main renamed to a-file-server-request-costs-three-to-twelve-times-a-kernel-file-call.md.

SEND BACK

Japabu and others added 4 commits October 31, 2026 17:01
The cache kept at most 16,384 clean blocks, 64 MiB, and drops the oldest
read first without moving a block it hits. osbench's fs.disk.seq_read.4096
rereads a 64 MiB file: its blocks and the tree nodes an open reads come to
one more than the cache kept, so each fetched run evicted the blocks the
reader reached next and every pass came off the disk whole. #1097's AMD
readback has 158 MB/s p50 against 4,960-5,054 under Linux. Why one round
of five read 426 MB/s is not shown; dirty blocks are outside the clean
count, so a round run before the write-back would fit.

A host test written as the benchmark reads (write 64 MiB in 4 KiB calls,
sync, then open, read whole and close in rounds) read 16,385 blocks off the
disk in each round after the first at the old limit. It now runs on a cache
holding twice the file and reads none.

Each cache is made with its limit, and the server gives it an eighth of
physical memory: the share netstack and the compositor take for the same
reason, that nothing signals memory pressure, so the bound is all that keeps
a cache from holding the machine. The eviction order is unchanged, so a file
larger than that still misses on every pass, which #1097's
a-sequential-read-of-a-file-as-large-as-datas-clean-cache-reads-the-disk.md
records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each server took an eighth of physical memory, so the DATA, log and boot
servers together could keep three eighths clean, and writes fill a cache
as well as reads: a flushed block stays as a clean one. The eighth
netstack and the compositor take rests on leaving seven eighths to
everyone else, which three such shares do not. Now the log and boot roles
keep 16 MiB each, their FAT and directories, and DATA the rest of one
eighth. The interim, that nothing gives a clean block back to another job,
is the-file-servers-clean-blocks-are-given-back-to-no-other-job.md, owned
with the memory track: its exit is clean blocks dropped under the memory
service's pressure and CLEAN_SHARE deleted.

A block read in is cached only if the allocator gives it room. Box::new
cannot fail, so a miss under another job's exhaustion aborted the server
and lost what it held dirty; a refused clean block is now just not kept,
and the read is answered from the disk's bytes.

The total-memory decode lives in the SDK (toyos::system::memory_total)
for the file server, netstack and the compositor, which each had a copy.

#1097's record of the eviction cliff named CLEAN_LIMIT, which is gone;
it now names the server's limit, and stays open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… limit is computed

Reserving the block's place in the eviction order before allocating it
makes the clean insert refusable but for the block map's node, a small
allocation every few inserts; the module doc and the record now name it
and the other two allocations that still abort, a dirty block and a
fetch's staging buffer. The order queue was the largest single allocation
the server makes, and so the likeliest to be refused.

The log and boot roles pass SMALL_CLEAN_BYTES and DATA data_clean_limit(),
so no limit is asked for ROOT, which has no cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mem.anon.map read 1.90x on the AMD bench after this PR's first round. The
landing control and a limit-revert boot show the slow mode in a boot of
the base, f407918, without this change, and the fast mode in both boots
of this head and in the revert boot. So it follows the boot, not the cache.
It is recorded as a kernel finding with its evidence (pr-1099 ec19d8d3,
r4/), owned by the address spaces.

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

Japabu commented Oct 31, 2026

Copy link
Copy Markdown
Collaborator Author

Review r3 of #1099 at 963531f (rebased onto main 0c7e62c)

Architecture

The design is unchanged from r2 and still fits. Each server owns its cache's bound. The three servers total an eighth, the interim is recorded with an owner and an exit, and memory_total() keeps the ABI decode inside the SDK. Production code is about +60/−40 lines and tests about +50, unchanged in substance since r2.

Round-2 blockers

  1. The allocation-abort sites. Resolved on the read path, but not on the flush path (Blocker 1 below).
    • visit now reserves the block's place in order and then the block, and keeps neither if either is refused (cache.rs:178-183).
    • The module doc's claim is scoped to "a block read in", and that is true: only the map node is left.
    • Where the reservation is checked: memory is promised at vm_reserve (kernel/src/mm/space.rs:359-366, pmm::promise). So a reservation that succeeds is not killed by a later fault, and the refusal path means something.
  2. The regression attribution. Resolved. I read r4/anon-spawn.txt, slow-boots-comove.txt, count-landing.txt and the README at evidence 1def8a84.
  3. The rebase. Correct.

The new kernel finding: keep it on this PR

issues/a-32-mib-anonymous-reserve-… should stay here.

  • It is an issue file only, and no code depends on it.
  • The evidence that motivates it is this PR's attribution. A reader who sees the 1.9x in this PR's A/B needs the record that explains it.
  • Splitting it out would cost a separate review and merge-queue run for no benefit.
  • Its format is valid (finding/open, with an exit and an owner), and its citations check: LEN is 32 MiB, there are 16 leaf tables, and ec19d8d3 holds r4/anon-spawn.txt.

Findings

BLOCKER 1. system/fileserver/src/cache.rs:317 and issues/the-file-servers-clean-blocks-are-given-back-to-no-other-job.md:25-31: a flushed block still enters the eviction order without a reservation, and the record's list of abort sites leaves that out.

  • When a flush cleans a block it calls inner.order.push_back(block) with no reservation. order is the allocation r2 named the largest the server makes. With stale entries it can reach 4 * (clean_limit + DIRTY_LIMIT) entries before compaction, which is about 33 MiB on a 32 GiB machine, and it reallocates whole when it grows.
  • In a server that mostly writes, such as the log role or DATA under writes, this push is the only way order grows. If that reallocation is refused, the server aborts in the middle of a flush and loses the dirty blocks of the runs not yet written. r1 named that loss as the consequence to avoid.
  • So the PR body's "Clean-block caching is fallible, with the order queue reserved first" is true only for blocks read in.
  • The record says "Three allocations still have no refusal path". That list omits this push, and it also omits the flush's own dirty list and buf (:284-285).
  • Fix, either way:
    • Make the flush path all-or-nothing as well. If order.try_reserve(1) is refused after slot.dirty = false, remove the slot, since the block is now on the disk. Then name the flush's buffers next to the fetch's in the record.
    • Or name the push and the flush's buffers in the record's list, and narrow the PR body's claim.
  • The first option is two lines and removes the worse of the two aborts.

BLOCKER 2. issues/metalprobes-usb-read-is-answered-from-fileservers-cache.md:13: "keeps clean blocks up to an eighth of physical memory" is false for /log. Since r1, the log role keeps SMALL_CLEAN_BYTES, 16 MiB. The record's conclusion still holds, because the probe's file is under 8 MiB (usb.rs:44, BYTES * 2 < 16 MiB). Fix: "keeps up to 16 MiB of clean blocks".

BLOCKER 3. issues/the-file-servers-clean-blocks-are-given-back-to-no-other-job.md:10-11: the record cites "system/fileserver/src/main.rs, clean_limit". r2's restructure removed that function. The limits are now SMALL_CLEAN_BYTES and data_clean_limit, so cite those.

For the orchestrator (PR body and handoff).

  • The body says the module doc names all three remaining allocations. It names only the map node, which is accurate for its scope.
  • The handoff's "Risks / open" still says "three eighths" in the worst case. That stopped being true at r1.

Evidence checked

  • Evidence pr-1099 at 1def8a84: r4/README.md, anon-spawn.txt, slow-boots-comove.txt, count-landing.txt, verdicts.txt (every boot passed), rebase/ci-host.rev (1782ad0, exit 0), and the tail of rebase/ci-host.log (105 green).
  • Trees: cc47fc9 against 34409c9, b3f9af2 against 1782ad0 on fileserver: a volume's cache keeps an eighth of physical memory clean #1099's hunks, and 1782ad0..963531f, which is the issue file only.
  • None of the three blockers requires a new boot or a new host run beyond compiling, if the flush fix is taken. That fix is a refusal-only path, so it is inspectable without a test, by the same argument the PR makes for boxed.

SEND BACK

…pped

A flush cleaned a block and pushed it onto the eviction order with no
reservation. That queue is the largest allocation the server makes, and in
a server that mostly writes, this push is the only thing that grows it. A
refusal there aborted the server mid-flush, losing the dirty blocks of the
runs not yet written. Now the place is reserved first; if it is refused,
the block, which the disk already holds, leaves the cache.

The clean-blocks record cites `SMALL_CLEAN_BYTES` and `data_clean_limit`
rather than the removed `clean_limit`, and lists the flush's dirty list and
run buffer among the allocations that still cannot be refused. The
metalprobes record says `/log` keeps 16 MiB of clean blocks, not an eighth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Oct 31, 2026
This change is now stacked on #1099, whose cache allocates a block it reads
in fallibly (`boxed`). Shared, that block goes into an `Rc` as its own box,
which an `Rc` of the block itself could not do on stable Rust. So
`SharedBlock` keeps its box, and its comment names that site instead of
claiming an owner none had.

The clean-blocks record's list of allocations with no refusal path follows
the flush as it now is: its runs, the writes it keeps in flight, and each
run's shares, plus a block's share count. A flushed block whose place in
the eviction order is refused is dropped in `write_run`, as #1099's r3 did
in the loop it replaces. #1113's cache tests pass the clean limit #1099's
constructor takes.

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

Japabu commented Oct 31, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator: r3's three blockers checked against 963531f..fe6d7d2. A flushed block whose eviction-order slot is refused is now dropped (the disk holds it), and the doc says so. Both records are corrected. fileserver tests pass (87); the merge queue runs the full host gate. Landing.

@Japabu
Japabu marked this pull request as ready for review October 31, 2026 17:50
@Japabu
Japabu added this pull request to the merge queue Oct 31, 2026
Merged via the queue into main with commit 49559dd Oct 31, 2026
10 checks passed
@Japabu
Japabu deleted the disk-path-cache branch October 31, 2026 18:13
Japabu added a commit that referenced this pull request Oct 31, 2026
…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>
Japabu added a commit that referenced this pull request Nov 1, 2026
…s many writes there

An acknowledged write kept its arena blocks until a flush covered it, so
it could be issued again after a loss. A session's arena is 511 blocks,
and a full one made Served ask the disk for a Flush before the next
request. On the AMD bench (probe at 21265a0) an 8 MiB fsync paid four
such Flushes, about 0.95 ms each, besides its own two; on the T14, about
3.5 ms each. And the file server's cache wrote its runs one at a time.

The contract is unchanged: a write acknowledged and not covered is issued
again after a loss, and Durable means on the medium. Only where its bytes
wait changes. The client now gives a read or a write its arena blocks as
it goes on the wire (`next_request`'s `place`) and takes them back when it
is answered, and keeps an acknowledged write by its ticket; the session
keeps the caller's blocks for that ticket until the client retires it,
copying them into the arena each time the write goes out. A full arena
now only means requests on the wire hold it, and a request waits for one
of their answers. Placing every transfer as it goes out, not at submit,
is what keeps that wait from deadlocking: a write queued behind a reissue
holds nothing the reissue needs.

The blocks are shared, not copied: SharedBlock is an Rc of a boxed block,
and the cache's slots are SharedBlocks, so a flush hands the disk the
cache's own blocks (one copy, into the arena, where there were two). A
write to a block the disk still keeps takes a block of its own. The flush
keeps up to Disk::depth runs in flight, which a served disk answers as the
full-size requests its session carries at once: min(DEPTH 64, 511 / 32) =
15, under the NVMe driver's 63 per queue.

The swap track's design rested on acknowledged writes holding the arena;
its two bullets now say what the session keeps instead.

Review r1: the client carries each write's payload and bounds what it
keeps

Two blockers from review: a session's retention had no bound once the
arena's was gone, and the model did not check what the session kept for a
write.

The client now carries the caller's payload with each write (`Ask::Write`'s
`payload`, generic) and hands it to `place` each time the write goes out.
The session's `held` map, `take_retired` and the `expect` for a write whose
blocks were gone are deleted: a covered or given-up write drops its payload
with it. No copy comes back; the session's payload is the cache's shared
blocks. `Placing` is an enum of a read's ticket and a write's payload.

What a client keeps is bounded where it is kept. Once acknowledged writes
reach the limit it was made with, the caller's next write waits while the
client sends a flush of its own. If that flush fails past MAX_ATTEMPTS,
writes meeting the limit are answered Device unsent until a caller's flush
succeeds, as the arena path refused them. The session's limit is 32 MiB,
the file server cache's own dirty bound. The 511-block bound it replaces
cost 4 Flushes per 8 MiB round on metal.

The model runs a limit of one block, its write's payload being its ticket,
and holds a new law: no caller's write first goes out while the client
keeps its limit. Removing the wait breaks it. Dropping a payload at Done
rather than at the covering flush breaks the durable law.

Also: the module header states the payload contract once; cache.rs's header
is rewrapped; `Disk::depth` says it is at least one and the `max(1)` goes;
the swap record no longer derives a flush threshold from the arena, and
says a swap-in read can wait behind a write.

Review r1, stacked on #1099:
This change is now stacked on #1099, whose cache allocates a block it reads
in fallibly (`boxed`). Shared, that block goes into an `Rc` as its own box,
which an `Rc` of the block itself could not do on stable Rust. So
`SharedBlock` keeps its box, and its comment names that site instead of
claiming an owner none had.

The clean-blocks record's list of allocations with no refusal path follows
the flush as it now is: its runs, the writes it keeps in flight, and each
run's shares, plus a block's share count. A flushed block whose place in
the eviction order is refused is dropped in `write_run`, as #1099's r3 did
in the loop it replaces. #1113's cache tests pass the clean limit #1099's
constructor takes.

Review r2:
`KEPT_BLOCKS`'s doc keeps only its reason: no less than the file server
cache's dirty bound, so a cache flushing by that bound is never made to
flush by the session's. The counts and timings behind the choice are
#1113's: the 511-block arena bound forced 4 Flushes per 8 MiB of
persist.seq_write_fsync, 0.95 ms each on the AMD bench and 3.58 ms on the
T14. The cache now asserts `DIRTY_LIMIT <= KEPT_BLOCKS` at compile time, so
the two crates' constants cannot drift apart silently.

A write at the limit with a caller's flush on the wire waits for that flush
rather than asking one of the client's own beside it. Only a flush on the
wire counts: one queued behind the waiting write could never go out.

On main after #1099 and the event doorbells (3e0fd67, f83957d), as one
change: those turned the session's doorbell pipe into two events in the
same functions this change edits (`pump`, `wait`, the submits and the
session's fields). The session keeps main's `bells` and their `ring` and
`answered` waits, and this change's payload placement and reads table
around them.

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