Skip to content

A pager's objects are charged as they are made, and /tmp holds its files by that - #1273

Merged
Japabu merged 4 commits into
mainfrom
tmp-file-cost
Nov 12, 2026
Merged

Japabu merged 4 commits into
mainfrom
tmp-file-cost

Conversation

@Japabu

@Japabu Japabu commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

/tmp held at most a fixed number of files whatever they held: 3,468 on a 4 GiB guest, one file per 1.07 MiB of RAM, empty files included (issues/tmp-holds-one-file-per-megabyte-of-ram-whatever-the-files-hold.md on #1271; refusal at file 3,464 in its evidence).

Where the number came from:

  • The count was DATA's. The file server sized DATA's and MEMORY's pagers alike, capacity / 16 objects clamped to 1,024..65,536, which is 3,468 on 4 GiB. For DATA the count bounds a cache: past it, the server lets the oldest file nobody holds go. A memory volume's objects are its files, and nothing lets them go, so for /tmp, and for DATA in memory, the count was a hard cap.
  • The kernel reserved the whole table up front. It made the object table with the pager: 60 bytes a slot, charged to the file server. 3,468 slots are 208 KB, and the 65,536 clamp is 3.75 MiB. No per-object reservation was near 1 MiB; 1.07 MiB is RAM divided by the count.
  • The server's own cost per file, by inspection, is a few hundred bytes of its heap: an empty file's inode, its name and the server's record of it.

What changes

  • The kernel makes a pager's object slots as they are wanted (kernel::slots::Slots).
    • Slots come in pieces of 256, never moved, each charged to the pager's job when an object first needs it.
    • A release never allocates, and a slot let go is the next one taken.
    • A growth reserves its piece before its list, so a refused list leaves nothing changed for the caller's refund.
  • pager_create takes no object count. A pager's objects are bounded by what its job can be charged and by the slot table's own limit, and either refusal answers ResourceExhausted.
  • A disk volume's count is the file server's. Files::opened lets the oldest file nobody holds go once a disk volume keeps its count (DATA one per 16 pages, 1,024 to 65,536; LOG and BOOT 1,024), and refuses NoSpace while every one is held, as the kernel did. A memory volume keeps no count, so /tmp is bounded by what its server can be charged.
  • No-holders notices come off a list, not a walk over all slots.
    • The slot table keeps its marked slots on a list through the slots, so marking, unmarking and taking cost the same however many slots there are.
    • A holder's going marks its object; a release or a removal unmarks it; notices takes the marked slots.
    • The per-entry no_holders flag and its count are gone. The only walk over every slot left is the pager's end, which happens once.
  • Per-file cost. An empty /tmp file costs one 72-byte kernel slot (a 56-byte entry and a 12-byte mark), a few hundred bytes of the server's heap, and no page.

No manual page changes: the manual says nothing about how many objects a pager serves, and nothing in the manual names the ceiling.

Who is charged, and what bounds one client

Everything a /tmp file costs is still the memory file server's: its slot, the server's bookkeeping and its pages.

  • For this PR, the coordinator chose to leave the charge with the server, bounded by memory.
  • Charging the client changes the manual's rule that services own their private state. That is the owner's decision; the coordinator is proposing it to the owner as its own track, and it is not built here.
  • The gap has its own record: issues/one-client-can-fill-the-tmp-servers-memory-and-deny-every-other-client.md (severity: [security]). It holds:
    • one client filling /tmp's server and denying the others;
    • the slots a pager keeps at its peak, 72 bytes each, measured;
    • an exit for each: the client's charge (the owner's to decide), and a piece of free slots going back, with the free list taking low slots first.
  • issues/no-physical-memory-fairness.md points at that record.

For whichever lands second

  • The shared boots run as guest rows, each member judged by its own exit #1271:
    • Delete its /tmp record, or reduce it to a pointer: this branch meets its count, and its charge to the maker lives in the record above.
    • Drop only the x86-64 row tmp_holds_empty_files_by_what_each_costs; The shared boots run as guest rows, each member judged by its own exit #1271's testcases_members_exit_0 judges tmp_empty_files on the same 4 GiB guest.
    • Keep the DRIVEN_AND_SHARED entry, with its comment naming only the AArch64 row. virt_tmp_holds_empty_files_by_what_each_costs still names test_rs_tmp_empty_files, so removing the entry reds suite_split.
  • An open file is written and stated through its paged object, one write at a time, against credit #1258:
    • Its dirtied flag must come off a list too, either a second one in Slots or one "owes a notice" list with a kind per entry, with has_notices following, so that no walk over all slots returns.
    • Its 64-byte record and its other new fields take a slot from 72 bytes to about 144.
    • It also changes create, add, holder_gone (into say_no_holders), unref_locked, notices, and the ABI near pager_create, whose objects argument is gone here.
    • tests/guest/pagerprobe/src/main.rs:59 there, Pager::create(LARGE + 16 + WRITES, 1, 5, …), still passes an object count; it must lose it. That caller is guest-built, so no host check finds it.
  • A served file is mapped read-only and shared, and a fault on its absent page waits for its pager #1269 changes add, let_go, removing, unref_locked and on_zero_handles.
    • Its let_go drops the pager lock for revoke walks; entries.unmark(slot) must stay in the same hold as owned = false.
    • tests/guest/mapprobe/src/main.rs:60 there, Pager::create(CAPACITY, 0, 8, REPORT), still passes an object count; it must lose it. That caller is guest-built, so no host check finds it.

Elsewhere the conflicts are the slot table's accessors: entries[slot as usize].as_mut() becomes entries.get_mut(slot), and an Entry goes in through insert_with.

Review round 1

  • B1: grow reserves the piece first.
  • B2: the new record, as above.
  • B3: the retention, in the same record, with its measurement and an exit.
  • B4: run 43123313540, below.
  • S1: the object count is dropped, carried through the SDK, pagerprobe, a_served_file_reads_through_its_paged_object and the server's fake.
  • S2 is moot after S1. With no count below the table's limit, grown_bytes() returning None is a pager holding as many objects as the table holds. That is reachable from userland, so make_slot answers ResourceExhausted rather than panicking.
  • S3: slots_charge.
  • S4: tmp_empty_files makes one file per MiB of the machine's memory and 1,024 more.
  • S5: virt_tmp_holds_empty_files_by_what_each_costs runs it on AArch64, in tests/images/virttmpcase.toml beside the memory file server alone.
  • Slots::held went with the count, its only caller.

Review round 2

The AArch64 case's test runner now receives power. A runner given a job list opens it for its bound's reboot, and exits before any job without it.

Checks

The host is macOS arm64. Logs are at evidence pr-1273, in r2/ to r5/ for the rounds.

At 3eaf817, rebased onto main ce202b2 (#1262):

At 12aa41f, rebased onto main 75bdec3 (#1259), every command exit 0:

  • cargo test -p kernel --lib: 486 passed.
  • The file server's tests.
  • The build's config gates.
  • cargo test --test toyos-checks.
  • cargo run -- --clippy: 26 invocations clean.
  • cargo run -- --build-only.

How the rebase went:

At d9bc3e4 (round 3, which changes only tests/images/virttmpcase.toml):

  • cargo test --lib -- build::tests: exit 0, 48 passed. These are the config gates over the changed config.

At d2b9c87 (round 2, rebased onto main 3b93f37):

  • cargo test --test toyos-checks: exit 0.
  • cargo run -- --build-only: exit 0.

On the same change before that rebase (07ea739c3), every command exit 0:

  • cargo test -p kernel --lib, -p toyos-abi and -p toyos.
  • The file server's tests: 123 + 3, including the new a_disk_volume_keeps_its_count_and_lets_its_oldest_closed_file_go.
  • The build's config gates, which take virttmpcase.toml.
  • cargo test --test toyos-checks.
  • cargo run -- --clippy: 26 invocations clean.

Negative controls, each a checked patch, run and restored in the same session:

control mutation result
m1 unmark leaves the next slot's back-link stale the mark test fails (exit 101); restored, it passes (0)
m2 Files::opened never applies its count the count test fails (exit 101); restored, it passes (0)

Round 1's cargo run -- --ci host was 113 of 114 steps green. Its one red is the macOS-only llvm environment test (issues/the-llvm-environment-test-breaks-git-on-macos.md); nothing under build/ changed but the config list.

Guest: run 43123313540 at 12aa41f, green on both architectures: x86-64 made 5,068 empty files, past the old cap of 3,468, and AArch64 made 3,030. It stands for 3eaf817, whose change from 12aa41f is #1262's rows alone. Its rows:

  • tmp_holds_empty_files_by_what_each_costs on x86-64's 4 GiB guest;
  • virt_tmp_holds_empty_files_by_what_each_costs on AArch64's;
  • a_spawn_from_an_image_frees_it_when_the_child_ends: objects on DATA and /tmp, and the census;
  • a_row_reaches_its_own_state_and_no_other: DATA's objects through the server's own count.

Earlier runs 43110990766, 43115186545, 43118861674 and 43121026670 were cancelled before they ran.

Control. On the base, the same 4 GiB x86-64 guest refused the 3,465th empty file in /tmp (#1271's evidence), by readdir_bound on the same create path.

Owed on metal: nothing. The claim is a count, with no timing or hardware in it.

🤖 Generated with Claude Code

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1273, round 1, at 47d8525

The brief named 17884f5. The branch moved to 47d8525 during the review, so this covers both commits.

Architecture

The ownership is right. The kernel makes object slots as they are needed and charges them to the pager's job. The file server decides how many objects its pager serves. A memory volume's files are now bounded by what the server's job can be charged, not by a sixteenth of its pages.

kernel::slots::Slots is a pure, unsafe-free, host-tested table with one user. It earns its module through its boundary: a growth's cost is known before the growth, so the caller charges first, and a release never allocates.

The no-holders walk. I was asked to decide whether it needed a structural fix. It did, and 47d8525 supplies one. At 17884f5 the walk ran under a ticket lock with preemption off, on every close of a /tmp file opened to read (each such open mints a holder). Its length had been capped at 65,536 slots and now followed a count a client controls. 47d8525 replaces it with a list threaded through the slots. Taking a notice is O(1) and allocates nothing. The list is correct by inspection and by its host test.

Manual. No change is needed. The manual says nothing about how many objects a pager serves, and every allocation still has one owner. "Services … bound what clients can make them grow" is still unmet for /tmp. Under the orchestrator's option C that is a recorded weakness, not a changed decision.

The objects ceiling with PAGER_MAX_OBJECTS works, but it is no longer the simplest representation (S1).

Size. Production grows by about 254 lines: slots.rs 231, paged.rs +11, ABI, SDK and server +11. Tests grow by 150: the Slots tests 84, the guest binary 35, the harness row 31. Issues grow by 35 lines and lose 5.

Paths traced at 47d8525

  • Create. No slots are made, and the pager starts with Charge::none on its job. The up-front table charge went with the table.
  • Add. The order is: Gone, then the count ceiling, then make_slot, then insert_with. make_slot reuses a free slot with no charge. Otherwise it charges grown − heap before grow and refunds the charge if the heap refuses.
    • A refused charge answers ResourceExhausted. The SDK maps that to NoRoom, and the server answers NoSpace for a memory volume, because drop_oldest declines memory volumes.
    • DATA instead drops its oldest unheld file and takes that slot back without a new charge.
    • No refusal panics the kernel.
  • Release and unref. Slots::remove unmarks the slot and puts it on the free list. Nothing is allocated, and the piece and its charge are kept.
  • Holders and notices.
    • holder_gone marks only an owned object of a live pager.
    • let_go unmarks in the same lock hold that clears owned.
    • notices takes marked slots and reports only those that still have no holders.
    • has_notices reads any_marked.
    • A reused slot starts unmarked.
  • Pager end. Each owned object is let go, in one walk that runs once. Released entries cannot be marked. Slots and the Charge drop together with State.
  • Lock order. Under the pager lock, pmm::charge takes the frame allocator's lock, as pmm::promise already did, and Slots::grow takes the heap's lock. Neither path takes the pager lock under either of those, so the header's "innermost but for the allocator's" holds.
  • Slots.
    • Growing the list moves its piece pointers, but the pieces themselves never move.
    • Slot ids stay below MAX (16,646,144), so NONE never names a slot.
    • PAGER_MAX_OBJECTS ≤ MAX is const-asserted, and so is a piece fitting in CHUNK_BYTES.
    • A slot is 72 bytes: a 56-byte entry plus a 12-byte mark. I checked this with a host replica of the layouts.

BLOCKERS

  1. kernel/pure/slots.rs:107-121 with kernel/src/object/paged.rs:203-209: a refused growth is not "nothing changed".

    • Problem. grow's contract says it returns false, "with nothing changed", where the heap refuses. But it reserves the list (:115) before the piece (:119). If the list grows and the piece is then refused, the list keeps its larger capacity and heap_bytes rises. make_slot refunds the whole more anyway.
    • Consequence. The list's growth stays uncharged for the pager's life: 256 KiB at the step from 16,384 to 32,768 pieces. It is rare, because it needs the heap to refuse room that a granted charge covered (fragmentation, issues/the-kernel-heaps-fragmentation-is-charged-to-nobody.md). Still, the caller's accounting relies on a false contract.
    • Fix. Reserve the piece first and the list second, and drop the piece if the list is refused. The reorder touches only the failure path, so a green guest run at 47d8525 still holds for it.
  2. issues/four-shares-take-a-file-server.md:91-114: a second issue inside another issue's file.

  3. The peak slot retention is not recorded.

    • What. The slot pieces are kept for the pager's life (kernel/pure/slots.rs:5, kernel/src/object/paged.rs:40-42). Each peak /tmp file leaves 72 bytes charged to the memory server until it ends. One client that makes and then deletes a million files leaves about 72 MB.
    • Why it needs a record. The representation is acceptable for now. But under option C one client can ratchet the server's kernel charge permanently, and the manual's Allocators section says unused backing returns under pressure. The risk is written only in the PR description.
    • Fix. Record it with evidence, owner and exit. One possible exit: a piece whose slots are all free goes back, with the free list preferring low slots so high pieces drain. The record from blocker 2 is a natural home.
  4. Evidence: the central behavioural claim has no guest run.

    • 43110990766, at 17884f5, was cancelled after 30 minutes in the queue without running.
    • 43115186545, at 47d8525, is still queued.
    • Landing needs that run, or its successor at the landing head, green. Its log must show tmp_holds_empty_files_by_what_each_costs printing tmp_empty_files: 10000 empty files made, read-opened and removed.
    • Metal is not required.

SUGGESTIONS

  • S1. The ceiling's representation. Now that slots are made on demand, the kernel's objects ceiling sizes nothing. It is DATA's cache policy enforced in the kernel, and PAGER_MAX_OBJECTS exists only so a memory volume can say "no ceiling".
    • A simpler design drops objects from pager_create. The kernel would bound objects by the charge and its own table limit. DATA's Files::opened would drop its oldest unheld file once its own count is reached, as it already does on refusal.
    • That removes an ABI constant, State.objects and a sentinel value. It costs an ABI change through the SDK, pagerprobe, one guest binary and the server's fake.
    • Optional.
  • S2. paged.rs:203. grown_bytes() cannot return None here: held < objects ≤ PAGER_MAX_OBJECTS ≤ MAX, and that is const-asserted. Answering ResourceExhausted would hide a kernel bug, so expect it.
  • S3. paged.rs:119. State.pieces is a Charge. In this file Piece and pieces already mean a read's frame parts (:500, :984), and :68 uses "pieces" for Chunked's pieces. A name like slots_charge would say what it is.
  • S4. tests/guest/rust-tests/src/bin/tmp_empty_files.rs:10. 10,000 files exceed the old cap only on machines with less than about 10 GiB of memory. On a larger bench the shared run, which tests/toyos.rs:380 calls "each bench's verdict", would also pass on main. Two options:
    • Set FILES to one per MiB of the machine's memory plus a margin. Every run is then a verdict, and the 4 GiB constant goes.
    • Otherwise, change the comment to say what the shared run asserts.
  • S5. AArch64 rows. Root's AArch64 rule counts memory management as shared code whose changes run aarch64 rows. This change has no architecture code and the table is host-tested, so the value is small. One aarch64 boot row that opens files would satisfy the rule cheaply. The orchestrator decides.

Interactions with open PRs

  • The shared boots run as guest rows, each member judged by its own exit #1271.
    • Its testcases_members_exit_0 runs every discovered Rust binary on the same 4 GiB x86-64 guest, tmp_empty_files included, and judges each by its exit.
    • Once both land, tmp_holds_empty_files_by_what_each_costs is a second boot for the same judgment. Whichever PR lands second should drop the row and its DRIVEN_AND_SHARED entry, and keep the binary as a member.
    • Its issue record is covered in blocker 2.
  • An open file is written and stated through its paged object, one write at a time, against credit #1258.
    • It adds dirtied, writers and a 64-byte record to Entry. That takes a slot from 72 to about 144 bytes and doubles this PR's per-file kernel cost.
    • It also adds a second walk over every slot in notices for dirtied, reached whenever a holder dirties an object. With this PR's unbounded slot count, that is the same regression 47d8525 removed.
    • On merge, dirtied must also use a list. Slots holds one list, so it needs a second, or a single "owes a notice" list with per-entry kinds. has_notices follows.
  • A served file is mapped read-only and shared, and a fault on its absent page waits for its pager #1269.
    • Its lock order (space, then pager, then allocator) agrees with this PR's allocation under the pager lock.
    • Its let_go drops the pager lock for revoke walks. The merge must keep entries.unmark(slot) in the same lock hold as owned = false.
    • The pieces never move, so its lookups by slot after those lock drops stay valid while another thread's add grows the table.
    • Besides add, let_go and removing, it also changes unref_locked and on_zero_handles.

Evidence checked

  • My runs. I used my own worktree and target directory on a macOS host. Every command exited 0.

    Head Command Result
    17884f5 cargo test -p kernel --lib 463 passed
    17884f5 cargo test -p toyos-abi passed
    17884f5 cargo test -p toyos passed
    17884f5 cargo test -p fileserver 122 + 3 passed
    17884f5 cargo test --test toyos-checks 50 passed
    17884f5 cargo run -- --clippy 26 invocations clean
    47d8525 cargo test -p kernel --lib 464 passed, including a_marked_slot_is_taken_once_unless_unmarked_or_removed_first
    47d8525 cargo test --test toyos-checks 50 passed
    47d8525 cargo run -- --clippy 26 invocations clean
  • The author's --ci host at 17884f5. 1 of 114 steps red: llvm::tests::the_caller_s_environment_reaches_neither_the_build_nor_the_key. That failure is the recorded macOS defect under build/, which this branch does not touch, so I accept that it is unrelated. The author's evidence predates 47d8525; my runs cover its host side.

  • Sizes. The author's replica checks the base's 60-byte slot and the 3,468 count. My replica confirms the new slot is 72 bytes; it was 56 before 47d8525.

  • Control. The base refused file 3,464 in The shared boots run as guest rows, each member judged by its own exit #1271's evidence, using readdir_bound. That is an acceptable control for the count: the cap follows from the base's arithmetic, and the binary takes the same create path.

PR description (for the orchestrator)

It is stale at 47d8525:

SEND BACK

Japabu added a commit that referenced this pull request Nov 11, 2026
…he server keeps its own count

The kernel's `objects` ceiling sized nothing once slots were made on
demand: it was DATA's cache policy enforced in the kernel, and
PAGER_MAX_OBJECTS existed only so a memory volume could ask for none.
`pager_create` now takes a capacity, a dirty ceiling and a report time.
A pager's objects are bounded by what its job can be charged and by the
slot table's own limit, and either refusal answers ResourceExhausted. A
disk volume's count moves into the file server: `Files::opened` lets its
oldest file nobody holds go once it keeps its count, and refuses an open
NoSpace while every one is held, as the kernel did. A memory volume keeps
no count. The SDK, pagerprobe, a guest binary and the server's fake follow.

`Slots::grow` reserves its piece before its list, so a list the heap
refuses drops the piece and leaves the list's capacity as it was, which
is what `make_slot`'s refund assumes (B1). The charge's field is
`slots_charge` (S3).

The /tmp per-client gap is its own record,
issues/one-client-can-fill-the-tmp-servers-memory-and-deny-every-other-client.md,
with the slots kept at a pager's peak, its measured 72 bytes, and an exit
for each: the charge to the client, the owner's to decide, and a piece of
free slots going back (B2, B3). It is out of four-shares, and
no-physical-memory-fairness points at it.

tmp_empty_files makes one file per MiB of the machine's memory and 1,024
more, so a run on any machine exceeds any count a share of its memory set
(S4). virt_tmp_holds_empty_files_by_what_each_costs runs it on AArch64,
in tests/images/virttmpcase.toml beside the memory file server alone (S5).

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

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1273, round 2, at d2b9c87

Architecture

S1 makes the ownership cleaner. The kernel now bounds a pager's objects only by what its job can be charged and by its table's limit. DATA's cache count is a file-server policy, and it now lives in the file server. PAGER_MAX_OBJECTS, State.objects, the "no ceiling" sentinel and Slots::held are all gone.

No object count remains in the kernel or the ABI:

  • rg finds no PAGER_MAX_OBJECTS.
  • pager_create, sys_pager_create, PagerObject::create and the SDK's Pager::create each take three arguments.
  • Their callers follow: the file server, pagerprobe and a_served_file_reads_through_its_paged_object.
  • Neither the std fork nor libc calls them.
  • The manual states no object count, so it needs no change.

Size against main.

  • Production: about +239 lines. slots.rs is 225 of them; the file server adds 10, paged.rs 2 and the SDK 1.
  • Tests: about +221 lines. The Slots tests are 83, the server tests +21, the guest binary 36, the harness 53, the AArch64 config 27 and the gate list 1.
  • Issues: +56 lines.

Round-1 blockers

  • B1: resolved.
    • kernel/pure/slots.rs:110-117: grow reserves the piece before the list. If the list is refused, the piece is dropped and the list keeps its capacity, so heap_bytes is unchanged and make_slot's full refund (paged.rs:203-207) is correct.
    • try_reserve_exact(PIECE) on an empty Vec, then exactly PIECE pushes, then into_boxed_slice never reallocates.
  • B2: resolved.
    • The gap has its own file. Its slug states the claim, and its frontmatter is valid (open, defect, [security]).
    • The charge exit is marked as the owner's to decide.
    • no-physical-memory-fairness.md points at the new file.
    • four-shares-take-a-file-server.md ends the branch unchanged against main.
  • B3: resolved. The new record covers the retention, with the 72-byte measurement (sizes/slot.txt in the evidence) and an exit.
  • B4: not resolved. Run 43118861674 is still queued, behind five other dispatches, and its AArch64 row cannot pass (blocker 1).

S1 at the server

  • The count.
    • system/fileserver/src/pages.rs:226 applies keeps before the create loop. At the count, it drops the oldest droppable file other than the one being opened. If none can be dropped, it refuses NoSpace.
    • main.rs:300 gives a count only to a volume that is not anonymous: DATA on a disk gets (capacity/16).clamp(1024, 65536), and LOG and BOOT get 1,024 each.
    • For files, this is the kernel's old behaviour. One difference: the kernel's count included objects that were released but still referenced by a holder. Those are now bounded by the charge alone, which is acceptable for a cache policy.
  • Tests.
    • a_disk_volume_keeps_its_count_and_lets_its_oldest_closed_file_go tests the count.
    • an_object_refused_its_charge_is_made_by_a_closed_files_going still covers the refused charge.
    • Negative control m2 shows the count test going red when the count is never applied.
  • The memory volume. Its None is not tested on the host. The guest row would catch a count there, because tmp_empty_files makes more files than any count pager() can set (S4 below).

S2 decline: correct

S2's premise was held < objects ≤ PAGER_MAX_OBJECTS ≤ MAX, and S1 removed it.

  • A pager that calls paged_create in a loop reaches Slots::MAX (16,646,144 slots, about 1.2 GB at 72 bytes) whenever its job can be charged that much. A /tmp client can drive the memory server there on a large machine.
  • grown_bytes() == None is therefore reachable by external input, and ResourceExhausted is the right answer. An expect would let userland panic the kernel.

S3, S4

  • S3: done.
  • S4: done. The old count is capacity/16, which is MiB − 256 before its clamp to 1,024..65,536. So one file per MiB of memory plus 1,024 exceeds the old count on every machine.

BLOCKERS

  1. tests/images/virttmpcase.toml:26-27: test-runner does not receive power, so the AArch64 row can never run its job.
    • Problem.
      • When given a job list, tests/guest/test-runner/src/main.rs:140-145 (deadline) opens power. Without it, test-runner exits 1 before any job, saying "no power connector to reboot with, so the job list has no deadline".
      • The supervisor builds a program's namespace from its receives and its view (system/supervisor/src/main.rs:3383-3405), and nothing else puts power there.
      • Every other config with a job list has receives = ["power"] on its test-runner: virtjob, virtsmp, virtpanic, virtreboot, virtclaim, virtpan, undeclared, latency and the rest.
    • Consequence.
      • virt_tmp_holds_empty_files_by_what_each_costs waits for a ===TEST_END test_rs_tmp_empty_files that never comes, and goes red. S5's AArch64 coverage does not exist.
      • The config's comment "a job past the bound reboots the guest" is false as written.
      • The build's config gates passed (I reran them), but no gate checks this.
    • Fix. Add receives = ["power"] to [programs.test-runner].
    • Evidence after the fix.
      • Run 43118861674 is at d2b9c87 and includes this row, so it will be red there.
      • Its x86-64 rows stay valid for the fixed head, because the fix touches only a config that no x86-64 row boots.
      • Landing needs virt_tmp_holds_empty_files_by_what_each_costs green at the fixed head, plus the x86-64 rows green, from that run or a successor. The x86-64 row's log must show the tmp_empty_files: N empty files, one per MiB of memory and 1024 more, … line.

SUGGESTIONS

None.

Evidence checked

My runs used my own worktree and target directory on a macOS host, at d2b9c87. Every command exited 0.

Command Result
cargo test -p kernel --lib 464 passed, including the three slots::tests
cargo test -p toyos-abi 50 + 4 passed
cargo test -p toyos passed, every suite
cargo test --manifest-path system/fileserver/Cargo.toml 123 + 3 passed, including a_disk_volume_keeps_…
cargo test --lib -- build::tests 48 passed, including every_config_renders_its_manifest over virttmpcase.toml
cargo test --test toyos-checks 50 passed
cargo run -- --clippy 26 invocations clean

Limits of these runs:

  • pagerprobe, rust-tests and mapprobe are guest-built packages, so clippy's $GUESTS exclusion keeps them out, and no host check compiles them.
  • Their Pager::create calls on this branch are correct by inspection. Only the guest run compiles them.
  • The author's r2 logs agree with these results. Negative controls m1 and m2 are recorded with their patches.

PR description (for the orchestrator)

The cross-PR notes need two corrections:

The #1258 entry-size and list notes, and the #1269 lock-hold note, are accurate.

SEND BACK

Japabu added a commit that referenced this pull request Nov 11, 2026
A test runner given a job list opens `power` for its bound's reboot and
exits before any job without it, so virt_tmp_holds_empty_files_by_what_each_costs
could never see its job end. virttmpcase.toml's runner now receives it,
as every other job list's does, and the comment says why.

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

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator verification of the round-2 fix at d9bc3e4: only tests/images/virttmpcase.toml changed, giving the test runner receives = ["power"] (read with git diff). Lands on 43118861674's x86-64 rows (at d2b9c87; no x86 row boots this config) plus 43121026670 (the AArch64 row at d9bc3e4).

Japabu added a commit that referenced this pull request Nov 11, 2026
…he server keeps its own count

The kernel's `objects` ceiling sized nothing once slots were made on
demand: it was DATA's cache policy enforced in the kernel, and
PAGER_MAX_OBJECTS existed only so a memory volume could ask for none.
`pager_create` now takes a capacity, a dirty ceiling and a report time.
A pager's objects are bounded by what its job can be charged and by the
slot table's own limit, and either refusal answers ResourceExhausted. A
disk volume's count moves into the file server: `Files::opened` lets its
oldest file nobody holds go once it keeps its count, and refuses an open
NoSpace while every one is held, as the kernel did. A memory volume keeps
no count. The SDK, pagerprobe, a guest binary and the server's fake follow.

`Slots::grow` reserves its piece before its list, so a list the heap
refuses drops the piece and leaves the list's capacity as it was, which
is what `make_slot`'s refund assumes (B1). The charge's field is
`slots_charge` (S3).

The /tmp per-client gap is its own record,
issues/one-client-can-fill-the-tmp-servers-memory-and-deny-every-other-client.md,
with the slots kept at a pager's peak, its measured 72 bytes, and an exit
for each: the charge to the client, the owner's to decide, and a piece of
free slots going back (B2, B3). It is out of four-shares, and
no-physical-memory-fairness points at it.

tmp_empty_files makes one file per MiB of the machine's memory and 1,024
more, so a run on any machine exceeds any count a share of its memory set
(S4). virt_tmp_holds_empty_files_by_what_each_costs runs it on AArch64,
in tests/images/virttmpcase.toml beside the memory file server alone (S5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
A test runner given a job list opens `power` for its bound's reboot and
exits before any job without it, so virt_tmp_holds_empty_files_by_what_each_costs
could never see its job end. virttmpcase.toml's runner now receives it,
as every other job list's does, and the comment says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu and others added 4 commits November 12, 2026 04:14
…les by that

A pager made its whole object table when it was made: a slot of 60 bytes
for every object it might serve, charged up front, and the file servers
sized that count as #1099 sized the DATA cache, a sixteenth of the pages,
between 1,024 and 65,536. DATA drops the oldest file nobody holds when the
count is reached. A memory volume cannot: its anonymous objects are its
files. So /tmp, and DATA when it falls back to memory, held at most that
many files whatever they held: 3,468 on a 4 GiB guest, one file per
1.07 MiB of RAM, empty files included.

The slots are now made a piece of 256 at a time (kernel::slots::Slots), and
each piece is charged to the pager's job when an object first needs it.
Only making an object allocates; a release never does, and a slot let go is
the next one taken. The `objects` argument stays as the most a pager
chooses to serve. A memory volume's pager asks for PAGER_MAX_OBJECTS
(8,388,608), so its files are bounded by what the file server's job can be
charged for them: 56 bytes of kernel heap and a few hundred of the server's
for an empty file, and its pages. A disk volume keeps its cache count.

The pieces are kept for the pager's life, so a volume keeps the slots its
largest count of files needed.

tmp_empty_files makes 10,000 empty files in /tmp and removes them; the
tmp_holds_empty_files_by_what_each_costs row runs it on QEMU's 4 GiB x86-64
guest.

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

A pager found each object owed a no-holders notice by walking its slots
from the first. That was bounded by 65,536 while the object count was, and
now grows with the number of files a memory volume holds, under the pager's
lock, on every notice a closed holder owes.

The slot table now keeps its marked slots on a list through the slots
themselves (Slots::mark, unmark, take_marked), so marking, unmarking and
taking cost the same however many slots there are, and none allocates. A
holder's going marks its object's slot; a release or a removal unmarks it;
`notices` takes the marked slots one by one. The `no_holders` flag on each
entry and the pager's count of them go. The one walk left is the pager's
end, once. A slot is 72 bytes: its 56-byte entry and its place on the list.

tmp_empty_files opens each file to read and closes it before removing it,
so each of its 10,000 files' last holder is reported through the list.

A memory volume's files stay charged to its server and bounded by memory.
issues/four-shares-take-a-file-server.md records that one client
can therefore fill the /tmp server's memory and deny the others, with the
proposed exit, charging what a server keeps for a client to that client's
job, pending the owner's decision; issues/no-physical-memory-fairness.md
points at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he server keeps its own count

The kernel's `objects` ceiling sized nothing once slots were made on
demand: it was DATA's cache policy enforced in the kernel, and
PAGER_MAX_OBJECTS existed only so a memory volume could ask for none.
`pager_create` now takes a capacity, a dirty ceiling and a report time.
A pager's objects are bounded by what its job can be charged and by the
slot table's own limit, and either refusal answers ResourceExhausted. A
disk volume's count moves into the file server: `Files::opened` lets its
oldest file nobody holds go once it keeps its count, and refuses an open
NoSpace while every one is held, as the kernel did. A memory volume keeps
no count. The SDK, pagerprobe, a guest binary and the server's fake follow.

`Slots::grow` reserves its piece before its list, so a list the heap
refuses drops the piece and leaves the list's capacity as it was, which
is what `make_slot`'s refund assumes (B1). The charge's field is
`slots_charge` (S3).

The /tmp per-client gap is its own record,
issues/one-client-can-fill-the-tmp-servers-memory-and-deny-every-other-client.md,
with the slots kept at a pager's peak, its measured 72 bytes, and an exit
for each: the charge to the client, the owner's to decide, and a piece of
free slots going back (B2, B3). It is out of four-shares, and
no-physical-memory-fairness points at it.

tmp_empty_files makes one file per MiB of the machine's memory and 1,024
more, so a run on any machine exceeds any count a share of its memory set
(S4). virt_tmp_holds_empty_files_by_what_each_costs runs it on AArch64,
in tests/images/virttmpcase.toml beside the memory file server alone (S5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A test runner given a job list opens `power` for its bound's reboot and
exits before any job without it, so virt_tmp_holds_empty_files_by_what_each_costs
could never see its job end. virttmpcase.toml's runner now receives it,
as every other job list's does, and the comment says why.

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

Japabu commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator: landing under the benches-down rule. Review r2's blocker (virttmpcase power) fixed and verified; rebased onto ce202b2 at 3eaf817 (row-function placement only; whole diff vs main identical). Guest run 43123313540 at 12aa41f: success on both architectures — x86-64 made 5,068 empty /tmp files (old cap 3,468), AArch64 3,030. The per-client gap is its own security record, exit the owner's to decide. Note for #1258/#1269: their guest probes (pagerprobe, mapprobe) must drop the object-count argument when they land after this.

@Japabu
Japabu marked this pull request as ready for review November 12, 2026 02:16
@Japabu
Japabu added this pull request to the merge queue Nov 12, 2026
Merged via the queue into main with commit d1cefba Nov 12, 2026
10 checks passed
@Japabu
Japabu deleted the tmp-file-cost branch November 12, 2026 02:30
Japabu added a commit that referenced this pull request Nov 12, 2026
…to main's

#1273 made /tmp hold its files by what each costs and left two
follow-ups for this branch. `testcases_members_exit_0` judges
`tmp_empty_files` on the same 4 GiB guest as the dedicated x86-64 row
`tmp_holds_empty_files_by_what_each_costs` did, so that row and its
function go. Its `DRIVEN_AND_SHARED` entry stays, now naming only the
AArch64 row that still drives the binary.

`issues/tmp-holds-one-file-per-megabyte-of-ram-whatever-the-files-hold.md`
goes: #1273 lifted its count, and what is open of it, one client filling
the server and denying the others, is
`issues/one-client-can-fill-the-tmp-servers-memory-and-deny-every-other-client.md`.
`readdir_bound`'s comment no longer counts its files against a pager's
object count, which a memory volume no longer keeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 12, 2026
…r's slots, as no-holders ones are

Main brings #1273: a pager's object slots are made as they are wanted
(kernel::slots::Slots), pager_create takes no object count, and the
objects whose last holder went are a list through the slots, not a walk
over every slot. #1258 had kept its dirtied objects the old way, a flag
an entry carries, a count, and a walk in notices.

- Slots holds a const number of mark lists, each apart from the others;
  a removal unmarks a slot on every one. The pager's table has two:
  NO_HOLDERS, #1273's, and DIRTIED. owe_dirtied marks and unmarks the
  second, has_notices asks both, notices takes from both, and a release
  unmarks both. The only walk over every slot left is the pager's end.
- An entry keeps #1258's writers and record beside #1273's fields, and
  add makes it in the slot make_slot charged for. holder_gone counts the
  holder down and say_no_holders marks the object only once no holder's
  write is under way, as #1258 had it.
- pager_create, the SDK's Pager::create and pagerprobe take no object
  count.
- Busy came from both sides. #1261 on main made it general, an inbox's
  submitter among its uses, mapped to EBUSY and ResourceBusy; #1258 used
  it for a write to a file a program runs from, mapped to ETXTBSY and
  ExecutableFileBusy. It keeps main's mapping, and its doc names both
  uses: a holder's write to a sealed object now answers EBUSY.
- The file server's tests take #1273's keeps argument (None where the
  count is not what a test is about) and #1258's record.
- A slot grows from #1273's 72 bytes to 216: #1258's object state,
  record and writers, and the second mark. The /tmp record's per-file
  cost says so, measured as #1273 measured it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 12, 2026
…record says who measures again

B19. The `Mappers` doc pointed at `Object::epoch`, which r8 replaced with
`Object::link`. `Pages::object` said a released object's id may be reused
because `release` takes its pages, but `release` went in the composition with
#1273. The docs now say a node is seen at the epoch `link` answers, and an id
is reused once its object is empty: `invalidate` marks its pages and the
walk's end takes them.

B20. The /tmp record now says that whichever of #1269 and #1258 lands second
measures the combined slot again, updates the figures and deletes the
sentence. #1258 rewrites the same paragraph for its own slot growth.

S1, S2: `Entry.refs` says only what it keeps: the slot, free with the last
reference. It also counts mappings, walks, queued removals and writes, which
the old list missed. The record says every entry carries the 64 bytes of
mapping state, not only a mapped object's.

Doc comments and a record only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 12, 2026
…record says who measures again

B19. The `Mappers` doc pointed at `Object::epoch`, which r8 replaced with
`Object::link`. `Pages::object` said a released object's id may be reused
because `release` takes its pages, but `release` went in the composition
with #1273. The docs now say a node is seen at the epoch `link` answers, and an id
is reused once its object is empty: `invalidate` marks its pages and the
walk's end takes them.

B20. The /tmp record now says that whichever of #1269 and #1258 lands second
measures the combined slot again, updates the figures and deletes the
sentence. #1258 rewrites the same paragraph for its own slot growth.

S1, S2: `Entry.refs` says only what it keeps: the slot, free with the last
reference. It also counts mappings, walks, queued removals and writes, which
the old list missed. The record says every entry carries the 64 bytes of
mapping state, not only a mapped object's.

Doc comments and a record only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 12, 2026
…record says who measures again

B19. The `Mappers` doc pointed at `Object::epoch`, which r8 replaced with
`Object::link`. `Pages::object` said a released object's id may be reused
because `release` takes its pages, but `release` went in the composition
with #1273. The docs now say a node is seen at the epoch `link` answers, and an id
is reused once its object is empty: `invalidate` marks its pages and the
walk's end takes them.

B20. The /tmp record now says that whichever of #1269 and #1258 lands second
measures the combined slot again, updates the figures and deletes the
sentence. #1258 rewrites the same paragraph for its own slot growth.

S1, S2: `Entry.refs` says only what it keeps: the slot, free with the last
reference. It also counts mappings, walks, queued removals and writes, which
the old list missed. The record says every entry carries the 64 bytes of
mapping state, not only a mapped object's.

Doc comments and a record only.

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