Skip to content

task(slice3c): a CPU pin a production thread can carry (inert scaffolding for the per-CPU ksoftirqd and pinned-wake slices) - #811

Merged
ryanbreen merged 12 commits into
mainfrom
task/562-slice3c-cpupin-scaffolding
Sep 5, 2026
Merged

task(slice3c): a CPU pin a production thread can carry (inert scaffolding for the per-CPU ksoftirqd and pinned-wake slices)#811
ryanbreen merged 12 commits into
mainfrom
task/562-slice3c-cpupin-scaffolding

Conversation

@ryanbreen

Copy link
Copy Markdown
Owner

What this is

Slice 3c of the aarch64 testing-profile line: it gives a production thread a
representation for the CPU pin its work needs. Thread::cpu_affinity widens
from a bare bool idea to Option<CpuPin>, add_thread_inner reads it and
bounds it by online_cpu_count() before falling through to
least_loaded_cpu(), and spawn_on_cpu / kthread_run_on_cpu exist as the
CPU-targeted counterparts of spawn / kthread_run. Content is ported per
hunk from the parked branch origin/fix/562-761-aarch64-testing-profile
(not merged) — see docs/planning/green-program/aarch64-testing/SLICE3C-2026-09-05.md
section 1 for the per-hunk provenance table.

Related work: #562, #786.

Why it is a ratchet, not a fix

0 of the issues this slice sits among is moved by this branch, and it should
not be read as moving one. The single mutating write to cpu_affinity lives
inside spawn_on_cpu, which has 1 call site (kthread_run_on_cpu), which
itself has 0 callers — no boot path, syscall, or gate in this branch reaches
either. add_thread_inner therefore still reads the empty state on the 15 of
15 Thread-construction sites the doc's section 3 census covers, and its
unwrap_or_else still calls
least_loaded_cpu(), the identical expression main evaluated before this
branch. tests/loopback_pump_structure.rs gains 2 census-shaped validators
and 9 tests that police the placement rule the pin would drive once something
calls spawn_on_cpu; neither validator pins a line or a closed name list.
The doc's section 3 gives the full grep-backed inertness argument and section
9 lists what is deliberately not claimed (the wake filter, the reclaim
custody guard, and 4 representable-but-unresolved dispositions section 10
names individually).

The per-CPU ksoftirqd that will actually call spawn_on_cpu is slice 3f, not
this branch.

Gates run

Round 1 and round 2 (R157) ran the full battery at the pre-merge head and
again after a first origin/main merge (814bc65 -> 6f36e1a, PR #809) — see
the doc's sections 5-8 for those tables. This PR additionally merges a second
origin/main move (6f36e1a -> 0ac28b2, PR #810) and re-runs everything at
that merged head (section 11, "Landing re-smoke"):

gate result
structure suites (tests/*_structure.rs) 30 / 30 green, 562 cases, 0 failed
aarch64 build x3 (no features, boot_tests, testing) 1 line each (toolchain future-incompat notice only); no-NEON guard PASS 0 on 3 of 3
aarch64 strict boot PASS: 2/2 boots succeeded
aarch64 production boot PASS: production profile reached bsshd with the futex oracle seam absent
x86 build x2 (testing,external_test_bins, zero-feature) 0 warning/error lines, on beast
x86 production boot exit 0, run twice, both clean

0 reds across both the round-2 and landing re-smoke passes, so 0 signatures
required classification against the pre-adjudicated gate set.

claim-lint: scripts/claim-lint.py -> exit 0

ryanbreen and others added 12 commits September 5, 2026 03:51
Ported per hunk from the parked aarch64 testing-profile branch onto main, as
the representation the per-CPU ksoftirqd work needs. Nothing in this change
sets a pin, so nothing in it moves a thread.

* `Thread::cpu_affinity: Option<CpuPin>` and the `CpuPin` type itself
  (4f6988c's `Option<usize>` field, widened by 5cefa78 to a pin that records
  WHY it exists -- a per-CPU worker whose bitmap lives on its home CPU, versus
  a hold pen that is placement only).
* `Scheduler::add_thread_inner` resolves the pin to its CPU, bounds it by the
  online range, and falls through to `least_loaded_cpu()` when there is no pin
  (4f6988c, with 5cefa78's `.map(|pin| pin.cpu)`).
* `scheduler::spawn_on_cpu` (4f6988c, stamping 5cefa78's `per_cpu_worker`
  pin) and `kthread::kthread_run_on_cpu` (4f6988c).
* Every `Thread` struct literal names the new field: 13 sites set `None`, the
  fork copy in `process/manager.rs` inherits the parent's (ef97d86, whose
  point was exactly this literal), and the `Clone` impl carries it.

Inert by construction, and that is the reason the gates are expected
unchanged: `spawn_on_cpu` has 1 call site (`kthread_run_on_cpu`), which has 0,
so `cpu_affinity` is the empty state on every thread this kernel builds and
`add_thread_inner` takes the `least_loaded_cpu()` fall-through it took before.
The pinned-wake liveness filter, the park protocol, the steal and reclaim
dispositions and the per-CPU ksoftirqd topology are NOT in this change.

Not ported from the branch: f7303d1's testing-cfg mutable binding, which
exists only for the `TEST_BINARY_STAGING` hold pen that is not on main -- so
`add_thread_inner` keeps its immutable `thread` parameter and the warning that
commit fixed cannot arise here.

Builds: aarch64 no-features, `boot_tests` and `testing` each exit 0 with 0
`^(warning|error)` lines beyond the toolchain's `core v0.0.0` future-incompat
notice; `scripts/check-kernel-no-neon.sh` reports 0 FP/SIMD load/store
instructions after each.

claim-lint: scripts/claim-lint.py -> exit 0

Co-authored-by: Ryan Breen <ryan.breen@gmail.com>

Co-authored-by: Claude Code <noreply@anthropic.com>
Two rules in tests/loopback_pump_structure.rs, both censused by shape rather
than pinned to a line or to a name list.

`validate_thread_placement_honours_the_cpu_pin` DERIVES its subject: a
scheduler function that resolves a ready-queue target through
`least_loaded_cpu()` is a placement function, which is 1 of 1 today
(`add_thread_inner`). Each one must read `cpu_affinity` BEFORE the least-loaded
choice, resolve it with `.map(|pin| pin.cpu)`, bound it by
`online_cpu_count()`, and fall through to `least_loaded_cpu()` for an unpinned
thread. An empty census is itself an error, so deleting the placement path does
not silence the rule.

`validate_cpu_pin_records_its_reason` censuses the constructors of `CpuPin`:
each must name the kind it builds, both kinds must be constructible, and
`Thread::cpu_affinity` must carry the pin rather than a bare CPU index -- the
distinction the later reclaim disposition reads.

Mutation, applied singly to kernel/src/task/scheduler.rs and then reverted --
the pin arm in `add_thread_inner` deleted, restoring the unconditional
`let target = self.least_loaded_cpu();` main carried before this branch:

  bash scripts/run-structure-tests.sh loopback_pump_structure -> exit 101
  test result: FAILED. 66 passed; 3 failed; 0 ignored; 0 measured
  thread 'thread_placement_honours_the_cpu_pin' panicked at
  tests/loopback_pump_structure.rs:1844:10:
  a pinned thread is queued to the CPU its pin names:
  "add_thread_inner does not resolve the thread's pin to the CPU it names"

The other 2 reds in that run are the mutation-fixture tests themselves
(`thread_placement_validator_rejects_a_deleted_pin_arm` and
`..._rejects_an_unbounded_pin`): each re-applies the mutation to a source that
already carries it, so its "fixture mutation must apply" assertion fires. They
are not independent evidence.

Unmutated, at the committed bytes:

  bash scripts/run-structure-tests.sh loopback_pump_structure -> exit 0
  test result: ok. 69 passed; 0 failed; 0 ignored; 0 measured

claim-lint: scripts/claim-lint.py -> exit 0

Co-authored-by: Ryan Breen <ryan.breen@gmail.com>

Co-authored-by: Claude Code <noreply@anthropic.com>
…claimed

docs/planning/green-program/aarch64-testing/SLICE3C-2026-09-05.md records the
round: the per-hunk provenance of every ported line (4f6988c, 5cefa78,
ef97d86), the hunks deliberately left on the parked branch for slices 3d, 3e
and 3f, the inertness census (`spawn_on_cpu` has 1 call site,
`kthread_run_on_cpu` has 0, so 15 of 15 `Thread` build sites hold the empty
pin), the ratchet and its mutation verbatim, the builds, the boots with their
`pgrep` readings, the x86 legs on beast, and a section 9 of things this branch
does NOT claim.

Two disclosures the doc carries rather than hides: the `pub(crate)` ->  `pub`
visibility change made while porting, and its dead-code reason; and the 2 build
artifacts this worktree symlinked from the primary checkout instead of
rebuilding (`userspace/programs/aarch64` and `target/ext2-aarch64.img`), both
userspace outputs this branch changes 0 bytes of.

claim-lint: scripts/claim-lint.py -> exit 0

Co-authored-by: Ryan Breen <ryan.breen@gmail.com>

Co-authored-by: Claude Code <noreply@anthropic.com>
`poll_tcp_gate_wiring_structure`'s census reads each file under `docker/qemu/`
and `scripts/` and `unwrap`ped `read_to_string`, so a single non-UTF-8 file
under either root aborted the whole ratchet:

  thread 'every_oracle_fail_gate_also_requires_the_kernel_ready_lost_marker'
  panicked at tests/poll_tcp_gate_wiring_structure.rs:102:37:
  read script <repo>/scripts/__pycache__/claim-lint.cpython-314.pyc

The 2 commands the claim-discipline rule asks a round to run are in conflict
because of it: `python3 scripts/test_claim_lint.py` imports
`scripts/claim-lint.py` and leaves that `.pyc` behind, and the suite battery run
afterwards then scores a red on a tree whose 8 of 8 censused gate scripts are
correctly wired. This round hit it at its own committed head, which is why it is
repaired here rather than reported.

A file that is not valid UTF-8 cannot contain the FAIL literal, so it is not a
member of the census and failing to read it is not an error. The rule itself is
untouched, and its anti-vacuity leg still fires -- mutation, applied singly to
this file and then reverted, forcing the census empty with
`.map(|text| false && text.contains(ORACLE_FAIL_LITERAL))`:

  found 0 scripts under docker/qemu/ or scripts/ asserting
  "POLL_TCP_ORACLE:FAIL" -- this ratchet would be vacuously true, which is
  exactly the failure mode it exists to prevent

Unmutated, with the `.pyc` still on disk: 3 passed, 0 failed; the whole battery
is 30 of 30 files, 550 cases.

claim-lint: scripts/claim-lint.py -> exit 0

Co-authored-by: Ryan Breen <ryan.breen@gmail.com>

Co-authored-by: Claude Code <noreply@anthropic.com>
Section 5 of the slice doc now carries the one red the second full battery
scored -- `poll_tcp_gate_wiring_structure` aborting on the `.pyc` the
claim-lint self-test leaves under `scripts/` -- the repair, its anti-vacuity
mutation verbatim, and the 30-of-30 re-run afterwards.

claim-lint: scripts/claim-lint.py -> exit 0

Co-authored-by: Ryan Breen <ryan.breen@gmail.com>

Co-authored-by: Claude Code <noreply@anthropic.com>
The gate runs happened before the branch's later commits, so section 7 now
states the provenance directly: the kernel sources reached their final state in
7257e09 and the 3 commits after it touch 0 lines under kernel/, so the 6
aarch64 boots and the 2 x86 boots ran the kernel this branch lands.

claim-lint: scripts/claim-lint.py -> exit 0

Co-authored-by: Ryan Breen <ryan.breen@gmail.com>

Co-authored-by: Claude Code <noreply@anthropic.com>
origin/main moved from 814bc65 to 6f36e1a (PR #809, the ASID-tag census)
while this round ran, so the branch carries a merge commit and each gate was
run a second time against what would actually land:

* 3 aarch64 builds, each with its no-NEON guard: same reading as before.
* strict x3: PASS 3/3, 36 s.
* production x3: 3 x PASS, pgrep 0 before each.
* the structure battery: 30 of 30 files, 559 cases -- 9 more than before,
  which are PR #809's own ratchet.
* x86 on beast at 3898de8: both builds clean, prod-profile boot exit 0,
  boot_tests exit 0 with `x86 userspace gate: PASS - exited=110 expected>=105
  nonzero=0 allowlist=0` and `x86 frame-custody gate run 1: PASS`.

Sections 5, 6, 7 and 8 now carry both rows, pre-merge and merged, rather than
implying one run covered both trees.

claim-lint: scripts/claim-lint.py -> exit 0

Co-authored-by: Ryan Breen <ryan.breen@gmail.com>

Co-authored-by: Claude Code <noreply@anthropic.com>
…ssion text

Review findings S3C-M1 and S3C-M2, both majors, both about the same defect
class: the rules landed in 862fa29 matched exact expression literals, so a
change that STRENGTHENED the code they police reddened them, with a message
asserting the opposite of what the code did.

S3C-M1. `validate_thread_placement_honours_the_cpu_pin` required the compact
literals `cpu_affinity.map(|pin|pin.cpu)`,
`.filter(|cpu|*cpu<self.online_cpu_count())` and
`.unwrap_or_else(||self.least_loaded_cpu())`. The first forbade a pin-kind
pre-filter, and the second forbade adding the scheduling-liveness conjunct --
the repair slice 3d owes this very arm -- while printing "does not bound the
pinned CPU by the online range", which is false about a strictly stronger
bound. The 4 requirements are now read by shape from the statement that runs
from the pin read through the fall-through: a `.cpu` field read off a non-self
receiver, a `filter` adapter whose predicate names `online_cpu_count` (so the
bound REJECTS rather than rewrites), and an `unwrap_or_else` whose argument
calls `least_loaded_cpu`. 3 new helpers do the parsing: `compact_call_arguments`
(balanced argument text of each `.method(` call), `compact_field_access`, and
`statement_region`.

S3C-M2. `validate_cpu_pin_records_its_reason` walked each `fn` inside
`impl CpuPin` and demanded `per_cpu_worker: true` or `false` of it, so adding
an accessor -- the shape slice 3e's reclaim disposition reads the kind through
-- reddened it with "builds a pin without naming the reason it exists" about a
function that builds no pin. The census is now over the functions whose body
builds a pin, which is 2 of the 2 the impl holds today.

1 fixture was re-shaped for the same reason:
`thread_placement_validator_rejects_an_unbounded_pin` deleted the filter by
matching its exact text, which collapses the moment the predicate is
strengthened. `without_call_matching` now removes the `filter` adapter whose
predicate names `online_cpu_count`, so the leg survives both repairs.

3 tests added, 3 of 3 GREEN legs -- the guard against the brittleness
returning:
`thread_placement_validator_accepts_a_stronger_bound`,
`thread_placement_validator_accepts_a_pin_kind_prefilter`, and
`cpu_pin_reason_validator_accepts_a_non_constructor_method`.

5 mutations, each applied singly to the live kernel file and reverted. Legs 3-5
are the review's own reproductions, RED before this commit and GREEN after:

  1 pin arm deleted, restoring `let target = self.least_loaded_cpu();`
    -> RED "add_thread_inner does not resolve the thread's pin to the CPU it
       names"; test result: FAILED. 67 passed; 5 failed
  2 the bounding filter deleted alone
    -> RED "add_thread_inner does not reject a pinned CPU outside the online
       range"; test result: FAILED. 68 passed; 4 failed
  3 bound strengthened with `&& self.cpu_accepts_wakeups(*cpu)`
    -> GREEN, test result: ok. 72 passed; 0 failed
  4 `.filter(|pin| pin.per_cpu_worker)` inserted ahead of `.map(|pin| pin.cpu)`
    -> GREEN, test result: ok. 72 passed; 0 failed
  5 `pub const fn is_per_cpu_worker(&self) -> bool` added to `impl CpuPin`
    -> GREEN, test result: ok. 72 passed; 0 failed

The extra reds in legs 1 and 2 are the mutation fixtures themselves, whose
surgery target the mutation removed; they are not independent evidence.

Unmutated, at the committed bytes:

  bash scripts/run-structure-tests.sh loopback_pump_structure -> exit 0
  test result: ok. 72 passed; 0 failed; 0 ignored; 0 measured
  (each tests/*_structure.rs)  green/total: 30 / 30   cases: 562
  python3 scripts/test_claim_lint.py -> exit 0

0 lines under kernel/ change in this commit.

claim-lint: scripts/claim-lint.py -> exit 0

Co-authored-by: Ryan Breen <ryan.breen@gmail.com>

Co-authored-by: Claude Code <noreply@anthropic.com>
…nresolved, and a false provenance sentence

The 6 minors from the round-1 review. 5 of them are disclosures the round-1
document owed and did not make; 1 is a false sentence.

S3C-m6 (FIXED). Section 7 said "this branch's own kernel sources reached their
final state in 7257e09 ... and the 4 commits after it touch 0 lines under
kernel/". That count was taken in 71f2e7f, the commit that added the sentence,
and was not re-derived when the merge and 1 further commit landed. At 8f5e6c9
there are 7 first-parent commits after 7257e09, and 1 of them -- the merge
3898de8 -- changes 8 files and 237 added / 16 removed lines under kernel/.
Section 7 now gives the 3 commands that measure it, and states the claim that
is actually true and checkable: 6 of the 7 are this branch's own and touch 0
lines under kernel/, the 7th brings origin/main's lines rather than this
branch's, and the branch's own contribution is unmoved -- `git diff
origin/main...HEAD -- kernel/` is byte-identical to `git diff 814bc65 7257e09
-- kernel/`.

S3C-m1 (DISCLOSED). A production CpuPin is enforced at 1 site,
add_thread_inner, and is invisible to 5 of 5 sites that could move a thread off
the queue its pin named: reclaim_unschedulable_cpu_queues and the 4
work-stealing sites all go through retain_cpu_affine_test_thread, which is keyed
on BOOT_TEST_CPU_AFFINITY rather than Thread::cpu_affinity and sits behind
cfg(aarch64 + boot_tests). Section 2 gains the grep and the consequence, section
9 gains a bullet, and the plan's 3e gains a binding item: guard 5 of 5, not the
reclaim site alone.

S3C-m2, m3, m4, m5 (DISCLOSED). A new section 10 enumerates 4 states the
representation makes expressible that this branch does not resolve, each with
its file:line and the slice that must rule on it: the out-of-range pin discarded
while the field keeps naming the CPU with 0 counters; the pin arm's bound, which
tests the online range where the fall-through also tests cpu_accepts_wakeups and
whose repair is the liveness test AND the park, not the conjunct alone; the
disagreement between the CoW-fork literal that copies the pin and sys_clone that
does not; and the 2 independent copies of the field -- the process-table row and
the scheduler's publication clone -- with no single authority. The plan's 3d
carries 4 of the 4 as binding items. 0 of the 4 is live: 0 threads carry a pin.

Section 10 also records the round: the disposition of 8 of 8 findings, the
mutation table now in section 4, and the fact that this round changes 0 lines
under kernel/ (`git diff --stat 8f5e6c9..HEAD -- kernel/` prints nothing),
which is why sections 6, 7 and 8 are not re-run.

By-catch, fixed in the same round: the plan named the parked branch's park
helper `park_pinned_worker_without_home` at 4 places. The name on 5cefa78 is
`park_pinned_thread_without_home` -- `git show
5cefa78:kernel/src/task/scheduler.rs | grep -n park_pinned` prints 7 lines, 6
call sites and the definition at 4066. 4 of 4 corrected.

  python3 scripts/claim-lint.py -> exit 0
  python3 scripts/claim-lint.py --files <SLICE3C doc> -> exit 0
  python3 scripts/claim-lint.py --files <SLICE3 plan> -> exit 0
  python3 scripts/test_claim_lint.py -> exit 0
  (each tests/*_structure.rs)  green/total: 30 / 30   cases: 562

claim-lint: scripts/claim-lint.py -> exit 0

Co-authored-by: Ryan Breen <ryan.breen@gmail.com>

Co-authored-by: Claude Code <noreply@anthropic.com>
Merged origin/main (0ac28b2, PR #810) into the branch via a fresh worktree,
git merge --no-ff, 0 conflicts; git diff --stat origin/main..HEAD lists
exactly the 9 paths this slice touches, so the merge dropped no main content.
Re-ran the full gate battery at the merged head: 30/30 structure suites (562
cases), 3 aarch64 builds clean with the no-NEON guard passing all 3, aarch64
strict boot 2/2 and production boot 1/1, both x86 builds clean on beast, and
the x86 production boot 1/1 (run twice). 0 reds. Appended the results as
section 11 of SLICE3C-2026-09-05.md.

claim-lint: scripts/claim-lint.py -> exit 0
@ryanbreen
ryanbreen merged commit 9b3dd4a into main Sep 5, 2026
@ryanbreen
ryanbreen deleted the task/562-slice3c-cpupin-scaffolding branch September 5, 2026 09:39
ryanbreen added a commit that referenced this pull request Sep 5, 2026
Both branches independently patched the same latent panic in
tests/poll_tcp_gate_wiring_structure.rs (a non-UTF8
scripts/__pycache__/*.pyc reaching scripts_asserting_oracle_fail's read
closure): origin/main (#811 by-catch) inlined a
read_to_string(...).map(...).unwrap_or(false) guard; this branch (6bf0b64)
added a shared script_text() helper used at both read sites.

Conflict resolution: scripts_asserting_oracle_fail keeps origin/main's
already-landed inline form verbatim. That leaves script_text() with a
single remaining caller (missing_ready_lost_wiring), so the helper is
removed and its non-panicking read is inlined at that one call site
instead, preserving the branch's R157 fix there with the smaller diff
against origin/main.

Co-Authored-By: Claude Fable 5.1 <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