Skip to content

feat(frame): an occupant of a place is handed a core, and finds a stack four pages wide - #48

Merged
dimmus merged 3 commits into
mainfrom
iritur/an-occupant-is-handed-a-core
Sep 13, 2026
Merged

dimmus merged 3 commits into
mainfrom
iritur/an-occupant-is-handed-a-core

Conversation

@dimmus

@dimmus dimmus commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Increment 6 of E1-B05: the decision, the mechanism, and the wall the mechanism found.

Read the last section before the code if you read nothing else — the gate this increment was written against is not met, deliberately, and the branch says so rather than rounding up.

What this is

kernel/src/runtime.rs has carried this sentence since RFC 0033:

there, a component is spawned into a place and never scheduled; here, a component is scheduled and never spawned into one.

The first half is now false. The supervisor's occupant is handed a core and enters ring 3 under its own address space on every boot. That is the first time any occupant of a place has executed.

RFC 0075 — the decision, made before the code

op::SPAWN/op::STOP landed in #44; this is the other half of the join. The plan said to make an Instance produce a process::Prepared, since the scheduled path already knows how to run one of those.

That is a double free. reap returns Prepared::pages to the FrameAllocator and checks them against the free count recorded in Prepared::before. An occupant's pages were derived from one supplied Untyped and are owed back to that account by tear_down. An Instance::into_prepared would be a value whose only purpose is to be handed to a function that must never see it, with nothing in the type system saying so — and it would quietly falsify RFC 0008's sentence that revoking the account ends the component.

What the code actually needs turned out to be much smaller: smp::run_on does not take a process. It publishes a Job { root, entry, stack, argument, hz, target } into a per-CPU slot. Six fields, every one of which an Instance has.

So: one scheduling interface, two ownership records. Job is what a core needs; Prepared and Instance differ in who is owed the memory; both build a Job, neither becomes the other. Two alternatives are recorded with why they lost — an account-aware reap (one name for two return disciplines, where Failure::Leaked exists precisely because the frame checks what it built against what it gave back) and a fourth Plan (the three that exist differ by how a run is judged, and an occupant has no verdict yet).

Two things the RFC did not anticipate, both found by running it

Writing Job alone is not scheduling. A core entering ring 3 runs against five per-core shards. A component given only a job runs against whatever the previous occupant left behind — which is how the first version handed the supervisor a core and watched it not execute. State, IN_RING3, the four arm_entries counters and Outcome are written beside it now.

The capability table is per-core, not per-instance. cap::of(cpu) is what a running component's calls resolve against; an Instance holds its own only while dormant. Importing a table built elsewhere breaks the generation arithmetic f_abi::door::Entry::granted rests on, and it broke it in exactly the way prepare_runtime's comment predicts:

init process  core 1, 3 call(s) answered, 1 refused, ended with status 5

So the excursion is made invisible: the core's previous table is restored afterwards, and only the occupant's own keeps what it derived. cap::Table's comment warns that a table copied by value is a second authority that can drift from the first — this is a swap, never two live copies, and the restore is what keeps it one.

A sibling session had reported two boots of one unchanged binary disagreeing — green once, status 5 the next. That was this. cargo xtask trace no longer reproduces it.

The wall: a four-page stack

The occupant dies on its first instruction, and this is the finding rather than a regression.

occupant  killed: exception 14 at 0x0000000000410ff8, error 0x6, rip 0x0000000000400000

0x415000 - 0x410ff8 is 0x4008 — the whole four-page stack plus eight bytes, a frame's stack probe walking down and stepping one word past the end. The guard page caught it exactly as designed: the fault is at SPAWN_GUARD, not in somebody else's memory.

Nothing had ever executed through the spawn path before this branch. user/init is a flat image on another path; a runtime comes from prepare_runtime. So SPAWN_STACK_PAGES = 4 had never been tested by anything except arithmetic.

Raising it is a cross-crate move and not a constant. Every spawn address above the stack derives from it, and three compile-time assertions pin them to constants owned elsewhere — SPAWN_HEAP against f_ring::heap::AT, BLK_BOARD against the block and network drivers' routing::AT. 4 -> 8 does not build:

assertion failed: SPAWN_HEAP == f_ring::heap::AT
the frame and the network driver disagree about where the routing page is

The tree refusing a layout change made in one place is the tree working. That finding is written beside the constant with the fault line, so the next reader meets it where it matters rather than in this description.

What a reviewer should know

The gate is not met and nothing here claims it is. HEAP_GAP's needle is still in kernel/src/runtime.rs and the boot still reports peak 0 byte(s), because the component dies before it allocates. intent/0005-the-datapath/plan-e1-b05.md records that rather than the outcome it predicted — a plan that only says what it intended is one nobody can use to find out what it cost.

The boot reports how the occupant ended rather than asserting it. A boot that hands over a core and says only that it did so cannot tell the component ran from a core was told to run it — which is the difference three wrong diagnoses of peak 0 turned on. Hence the occupant killed: … line naming exception, address and rip.

CHAOS_GAP is untouched. Its needle is prepare_driver( and it closes when a driver is scheduled inside its place, which this makes possible and does not do. E1-P06's remaining half stays open.

Evidence

  • cargo xtask verifyverify: all green, exit 0
  • cargo xtask trace — agrees on the pair that must agree, disagrees on the mutated control that must not
  • cargo xtask run — reaches M0 ok; init process core 1, 4 call(s) answered, 0 refused, ended with status 0

Three failures the gate found before this was committed, each a real defect in my own diff: a SAFETY: comment orphaned from its unsafe block by an inserted line — twice, and an orphaned safety comment reads as discharged when nothing checked it, which is why the lint treats it as absent — and a needless_borrow.

🤖 Generated with Claude Code

dimmus and others added 3 commits September 13, 2026 17:03
…uling

Increment 6 of E1-B05 is the join `kernel/src/runtime.rs` has described since
RFC 0033: *there, a component is spawned into a place and never scheduled; here,
a component is scheduled and never spawned into one.* This is the decision the
code needs before the code.

**The obvious join is a double free, and this refuses it.** The plan said to make
an `Instance` produce a `process::Prepared`, since the scheduled path already
knows how to run one of those. But `reap` returns `Prepared::pages` to the
`FrameAllocator` and checks the free count recorded in `Prepared::before`, while
an occupant's pages were derived from an `Untyped` and are owed back to that
account by `tear_down`. An `Instance::into_prepared` would be a value whose whole
purpose is to be passed to a function that must never see it, and nothing in the
type system would say so. It would also quietly falsify RFC 0008's sentence that
revoking the account ends the component.

**What the code actually needs turned out to be much smaller.** `smp::run_on`
does not take a process. It publishes a `process::Job` into a per-CPU slot and
the target core reads it — `{ root, entry, stack, argument, hz, target }`, six
fields, every one of which an `Instance` has or trivially knows. The scheduling
interface was already narrow; what was wide was the assumption that running
something meant owning it the way `prepare_runtime` owns it.

So: `Job` is the one scheduling interface, `Prepared` and `Instance` are two
ownership records that differ in who is owed the memory, and both can build a
`Job` while neither may become the other.

Two alternatives are recorded with why they lost: an account-aware `reap` (one
name for two return disciplines, where `Failure::Leaked` exists precisely because
the frame checks what it built against what it gave back), and a fourth `Plan`
beside the three that exist (the three differ by how a run is *judged*, and an
occupant has no verdict yet — a plan minted now would be shaped by its first
caller).

The plan's increment 6 is corrected in the same commit rather than left standing:
its file list named `kernel/src/runtime.rs`, which must **not** be touched —
that module's comment is `HEAP_GAP`'s needle, so editing it would turn the
constant red without the boot having changed, which is a red build for a false
reason. And it put `CHAOS_GAP` in this increment's gate, which was wrong:
that needle is `prepare_driver(` and closes when a *driver* is scheduled inside
its place, which this makes possible and does not do.

The gate is one constant going red on purpose. `HEAP_GAP`'s needle is the
sentence this increment falsifies, so the check written two increments ago is the
acceptance test for this one.

`cargo xtask lint` is 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ck four pages wide

RFC 0075's code. `kernel/src/runtime.rs` has described this since RFC 0033 —
*there, a component is spawned into a place and never scheduled; here, a
component is scheduled and never spawned into one* — and the first half of that
sentence is now false. The supervisor's occupant enters ring 3 under its own
address space on every boot.

**It dies on its first instruction, and that is the finding rather than a
regression.**

    occupant  killed: exception 14 at 0x0000000000410ff8, error 0x6, rip 0x0000000000400000

`0x415000 - 0x410ff8` is `0x4008`: the whole four-page stack plus eight bytes, a
frame's stack probe walking down and stepping one word past the end. The guard
page caught it exactly as designed — the fault is at `SPAWN_GUARD` and not in
somebody else's memory. Nothing had ever executed through the spawn path before
today (`user/init` is a flat image on another path, a runtime comes from
`prepare_runtime`), so `SPAWN_STACK_PAGES` had never been tested by anything but
arithmetic.

Raising it is a cross-crate move and not a constant. Four is pinned by three
compile-time assertions to constants owned elsewhere — `SPAWN_HEAP` against
`f_ring::heap::AT`, `BLK_BOARD` against the block and network drivers'
`routing::AT` — so `4 -> 8` does not build. The tree refusing a layout change
made in one place is the tree working. That is written down beside the constant
with the fault line, so the next reader meets it where it matters.

**Two things RFC 0075 did not anticipate, both found by running it.**

*Writing `Job` alone is not scheduling.* A core entering ring 3 runs against five
per-core shards, and a component given only a job runs against whatever the
previous occupant left behind. `State`, `IN_RING3`, the four `arm_entries`
counters and `Outcome` are now written beside it.

*The capability table is per-core, not per-instance.* `cap::of(cpu)` is what a
running component's calls resolve against; an `Instance` holds its own only while
dormant. Importing one built elsewhere breaks the generation arithmetic
`f_abi::door::Entry::granted` rests on, and it broke it in exactly the way
`prepare_runtime`'s comment predicts — `init process core 1, 3 call(s) answered,
1 refused, ended with status 5`. So the excursion is made invisible: the core's
previous table is restored afterwards, and only the occupant's own keeps what it
derived. A sibling session had reported two boots of one unchanged binary
disagreeing; `cargo xtask trace` is green now, and that non-determinism was this.

The boot reports how the occupant ended rather than asserting it, because a boot
that hands over a core and says only that it did so cannot tell *the component
ran* from *a core was told to run it* — which is the difference three wrong
diagnoses of `peak 0 byte(s)` turned on.

**The gate is not met and the plan is not ticked.** `HEAP_GAP`'s needle is still
in the tree and `peak` is still zero, because the component dies before it
allocates. What closes it is the stack, and that is its own diff.

`cargo xtask verify` is 0 — `verify: all green`. `cargo xtask trace` agrees on
the pair that must agree and disagrees on the control that must not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dicted

The mechanism landed and the gate did not. The plan said increment 6 ends with
`HEAP_GAP` going red; it ends with an occupant entering ring 3 and dying on its
first instruction against a four-page stack, so the needle is still there and
`peak` is still zero.

A plan that only ever says what it intended is a plan nobody can use to find out
what it cost. This says which half landed, what the fault was, and that the
remaining half is a cross-crate constant move rather than a line in this file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dimmus
dimmus merged commit 7bc549d into main Sep 13, 2026
26 checks passed
@dimmus
dimmus deleted the iritur/an-occupant-is-handed-a-core branch September 13, 2026 13:31
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