Skip to content

A thread carries its process's handles, and a lookup takes no lock but the table's - #1246

Merged
Japabu merged 2 commits into
mainfrom
handles-per-thread
Nov 11, 2026
Merged

Japabu merged 2 commits into
mainfrom
handles-per-thread

Conversation

@Japabu

@Japabu Japabu commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Every handle-taking syscall resolved its handle through process::with_process_data: the machine-wide
PROCESS_TABLE lock, a walk of the process table, an Arc clone of the process's ProcessData, and that
data's lock, which also guards the process's accounting, space, endowments and job. Every thread of every
process serialized on the one table lock for each lookup. pipeperf measured the lookup at 60 ns on the T14 and
33 ns on the AMD bench per pread (SYS_PAGED_READ, every served file's read), before the read itself.

Resulting behavior

  • The handle table has a lock of its own (process::Handles), and each thread's scheduler record
    carries it
    (KernelPayload::handles), beside its address space and image, set at spawn and at thread
    creation. process::with_handles reads it off the running task and takes only the table's lock: no
    PROCESS_TABLE, no table walk, no ProcessData lock.
  • ProcessData keeps an Arc to it for readers outside the process: teardown's close_all (which still
    empties the table in place when the last thread leaves, so a thread record outliving it finds an empty
    table) and the device-claim inventory.
  • All 63 handle-taking call sites move from with_process_data to with_handles; comments that named the
    process's lock as the one a lookup holds now name the table's; kernel/CLAUDE.md's park rule names
    with_handles.

Decisions

  • Narrow, as approved: only the handle table leaves ProcessData's lock — with one addition. The pager
    role moves with it: SYS_PAGED_READ refuses a pager's process, so the role must be readable where the
    holder's handle is, or the measured pread path would take the process table again. Whether the process
    was started as a pager is a plain field (no call changes it); whether it made its one pager object is an
    atomic claim. This replaces PagerRole; the semantics of SYS_PAGER_CREATE (PermissionDenied,
    AlreadyExists, the claim given back when the install fails) are unchanged.
  • sys_job_own and a spawn's commit read the job off ProcessData before taking the table's lock, not
    under one lock with it; the job never changes. No call site holds both locks.
  • The lookup clones the table's Arc off the running task (driver::current_handles, as
    current_address_space does) and takes its lock: two atomic RMWs on the line the lock's word shares. A
    borrow without the refcount is possible (the running task's record keeps the Arc until it is released,
    after it last leaves its CPU) but needs an unsafe dereference; left out until a measurement says the two
    RMWs matter.
  • Lock-free reads of a slot (design step B) are not part of this PR.

Checks

Targeted per the testing policy. The branch is rebased onto main b0533900e (head 6d9c8671c); the rebase
resolved #1211's kernel-stack changes in process.rs, loader/start.rs and scheduler.rs (main dropped
OwnedAlloc and write_stack_canary; nothing of this change moved). The kernel binary (kernel/src) has no
host tests; nothing host-tested changed (kernel/pure, kernel/proclife and the loom models are untouched).

check, at 6d9c8671c result
cargo clippy -p kernel --target x86_64-unknown-none with build/clippy.rs's adopted lints, -D warnings: default, boot-actuators,test-actuators, boot-actuators, and the instrument set exit 0 each
the same for aarch64-unknown-none-softfloat: default, boot-actuators,test-actuators, the AArch64 instrument set exit 0 each

Guest rows:

  • My first dispatch, 43052649875, ended at No test matches filter "handle_lifetime" before building a guest:
    fourteen of its fifteen names are shared-boot members, which guest rows do not select. It established
    nothing.
  • 43064648373 at c61225bca (the same patch before the rebase; only one import line differs):
    kernel_objects_refused_alone, std_threads_count, libc_children, a_spawn_from_an_image,
    stream_holders, session_lifecycle, virt_smp, virt_user_mode. Queued.
  • 43065189798 at 6d9c8671c: virt_jobs_at_el2. Queued.

The head after 6d9c8671c adds an issue record only.

Owed on metal

Both benches are down, so this lands on its review, host checks and the guest rows above. The speed-up is
not yet measured on metal.
issues/a-memory-filesystem-call-costs-toyos-up-to-35-times-linuxs.md records
what is owed:

  • the T14's whole testcases, testcases-watchdog and shared-debug boots of main after this PR, every
    member passing, among them handle_lifetime, abuse_handle_table, abuse_thread_table,
    messages_are_whole, stream_handles, the three spawn_* actuator members, endowment_denied,
    a_served_file_reads_through_its_paged_object, a_pager_s_end_answers_its_waiting_holders_gone,
    std_threads, inbox_cancel_wakes and port_badge;
  • an A/B of main before and after this PR, on the T14 and the AMD bench (osbench files, ipc, time,
    sched; four boots a side, rotated; exact two-sided Mann-Whitney over per-boot p50s), and Linux's
    osbench files and ipc on each bench. Claimed: fs.mem.pread_8, fs.mem.pread_4k,
    fs.mem.direct.pread_4k, fs.mem.read_4k, fs.mem.read_4k_at, fs.mem.direct.read_4k_at, ipc.reqrep.8
    and ipc.reqrep_handle.8 lower or not separated, each set against the 60 ns (T14) and 33 ns (AMD) lookup;
    no other row 5% or more worse unless this change explains it. A missing gain does not reject this change;
    it decides whether Step B is worth building.

Risks

  • A process's handle table (empty after teardown) now lives until the last of its threads' records is
    released as well as its table entry; it is charged to its job until then, as before.

🤖 Generated with Claude Code

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1 of #1246 at c61225b

Architecture. This is Step A as approved.

  • The handle table leaves ProcessData's lock and rides on each task's KernelPayload, beside address_space and image, which use the same ownership already.
  • A lookup no longer takes PROCESS_TABLE, walks the process table, or clones the ProcessData Arc.
  • Moving the pager role into Handles is the smallest change that lets SYS_PAGED_READ read it without the process table, and the orchestrator has accepted it.
  • +273/-222 over 22 files, nearly all mechanical. No test change: kernel/src has no host tests, and kernel/proclife and the loom models are untouched.

Call-site audit: no lost invariant found.

  • Teardown against a lookup. close_all still runs only on Leave::Last, after every thread has passed leave, so no thread of the process can be inside a lookup. A kill ends the same way. The table is emptied in place, so a task record still holding the Arc finds it empty.
  • Table against the rest of ProcessData. Only four sites read another field alongside the table:
    • sys_job_own, PendingHandles::commit and child_job read the job, which never changes after spawn.
    • sys_pager_create reads the pager role, which moved with the table. Its claim, install and unclaim were already two separate holds, so the AlreadyExists and PermissionDenied answers are unchanged.
    • Endowments are never read together with the table.
  • Lock order. No code under object/, pipe, message or inbox takes PROCESS_TABLE or a ProcessData lock. The table's lock therefore nests exactly where ProcessData's did (the inventory over pcidev, teardown's closes), and no site holds both.
  • current_handles through try_with_cpu. This is sound only if a syscall cannot change CPU between cpu_id() and the payload read. It cannot: the timer preempts only user mode (arch/x86_64/idt/timer.rs asserts a zero preempt count; AArch64 preempts only on EL0_IRQ). current_address_space already relies on the same thing.
  • One table per process. Only two places build a NewTask. loader::spawn passes the new process's Arc. spawn_thread spawns into the caller's own process, so its handles() is that same Arc.
  • The Arc clone per lookup costs two RMWs on the ArcInner line beside the lock word. That is acceptable for Step A, and measuring before adding the unsafe borrow is right. The A/B cannot separate their cost from the lock's; if the two-thread pipe rows stay high, look there first.

Blockers

  1. Guest evidence does not exist.
    • Run 43052649875 ended at No test matches filter "handle_lifetime" (exit 1, 16:32:41) before building a guest.
    • Guest rows can only select MACHINE_TESTS and SCREEN_TESTS (tests/toyos.rs, declared()). Fourteen of the fifteen names are shared-boot members, which run only in the metal testcases and shared-debug boots.
    • The PR's guest list is therefore false; correcting the body is the orchestrator's.
    • Root requires AArch64 guest rows here, because the loader, process and scheduler-payload code sits beneath AArch64. Dispatch virt_jobs_at_el2, the one valid name.
  2. The branch conflicts with current main. git merge-tree origin/main origin/handles-per-thread conflicts in kernel/src/process.rs, kernel/src/loader/start.rs and kernel/src/scheduler.rs; Every kernel stack sits above unmapped memory, and an overflow names its stack #1211 rewrote kernel-stack allocation in the same spawn paths. Metal Part 1 is staged at c61225b and has not touched a machine yet.
    • Rebase, rerun the kernel clippy set, and restage Part 1 at the rebased head before the T14 runs it, so the correctness boots test the tree that lands.
    • The A/B images can stay: they measure the change against its own base, f79df95.

Evidence

  • The clippy runs in logs/s1 were at 234a705b1. b56693152..f79df9574 changes nothing under kernel/ or sdk/, so they cover this head's kernel sources, but not the rebase in blocker 2.

What metal must show

  • Correctness: the T14 testcases, testcases-watchdog and shared-debug boots green at the rebased head. The members that bear on this change:
    • handle_lifetime, abuse_handle_table, abuse_thread_table, messages_are_whole, stream_handles;
    • spawn_lands_claimed, spawn_child_ends_first, spawn_returns_the_census, endowment_denied;
    • a_served_file_reads_through_its_paged_object, a_pager_s_end_answers_its_waiting_holders_gone;
    • std_threads, inbox_cancel_wakes, port_badge.
  • A/B:
    • No row separates worse by 5% or more.
    • Record how the named files pread rows and ipc pipe rows moved against the 60 ns (T14) and 33 ns (AMD) lookup they target.
    • A missing gain does not reject Step A, but it decides whether Step B is worth building.
  • The A/B rule itself: see A clock read takes RDTSCP and holds nothing after it, and std's Instant is the page's nanoseconds #1240's review. Before the boots, it should name the exact claimed rows, and say that a control row separating by 5% or more is investigated, not dismissed.

SEND BACK. This is for the evidence and the rebase only; no code change is requested beyond the rebase. Once those are done, it can land when virt_jobs_at_el2 is green and the metal results above are recorded.

…t the table's

Every handle-taking syscall went through `process::with_process_data`: the
machine-wide `PROCESS_TABLE` lock, a walk of the process table, an `Arc`
clone of the process's data, and that data's lock, which also guards its
accounting, space, endowments and job. Every thread of every process
serialized on the one table lock for each lookup (pipeperf: 60 ns on the
T14 and 33 ns on the AMD bench per `pread` before the read itself).

The handle table now sits behind a lock of its own in `process::Handles`,
which each thread's scheduler record carries beside its address space and
image, set at spawn and at thread creation. `process::with_handles` reads
it off the running task and takes only the table's lock. `ProcessData`
keeps an `Arc` to it for readers outside the process: teardown's
`close_all`, which still empties it in place when the last thread leaves,
and the device-claim inventory.

The pager role moves with it. A served file's read (`SYS_PAGED_READ`)
refuses a pager's process, so the role has to be readable in the same
place as the holder's handle, or the read would take the process table
again: whether the process was started as a pager is a plain field, and
whether it made its pager object an atomic claim, which replaces
`PagerRole`.

`sys_job_own` and a spawn's commit read the job off `ProcessData` before
they take the table's lock rather than under one lock with it; the job
never changes. Comments that named the process's lock as the one a lookup
holds now name the table's, and `kernel/CLAUDE.md`'s park rule names
`with_handles`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu
Japabu force-pushed the handles-per-thread branch from c61225b to 6d9c867 Compare November 11, 2026 17:08
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Response to review r1, head 6d9c867.

  1. Guest evidence: my dispatch named shared-boot members and established nothing (the body now says so). The orchestrator's 43064648373 carries valid rows; virt_jobs_at_el2 is dispatched at this head as 43065189798.
  2. Rebased onto main b053390. The conflicts were Every kernel stack sits above unmapped memory, and an overflow names its stack #1211's removals beside this change (OwnedAlloc, write_stack_canary, the NonNull import); nothing of the handle change moved. The kernel clippy set reran at this head (x86-64 default, both actuator sets, instruments; AArch64 default, actuators, instruments): exit 0 each. Metal Part 1 is restaged at this head (testcases, testcases-watchdog, shared-debug).

No code change beyond the rebase.

🤖 Generated with Claude Code

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2 of #1246 at 6d9c867 (follow-up to r1 at c61225b)

Scope. This round checks the r1 blockers and the rebase. The design and the call-site audit are settled.

Architecture. Unchanged since r1: Step A as approved, with the pager role moved alongside the handles.

r1 blockers

  1. Guest evidence: resolved in dispatch, results pending.
    • 43065189798 runs virt_jobs_at_el2 at 6d9c867.
    • 43064648373 (the orchestrator's, at the pre-rebase head c61225b) runs eight filters.
    • Each filter matches a declared row through tests/toyos.rs's substring rule. std_threads_count and a_spawn_from_an_image select the long-named rows; the virt_* names select AArch64 Virt/VirtEl2 rows.
    • Both runs were still queued at 17:19Z.
  2. Conflicts with main: resolved.
    • Same patch. git range-diff f79df9574..c61225bca b0533900e..6d9c8671c shows changes only in context lines. I also compared the -U0 added and removed lines of both patches. They differ in one line, the loader/start.rs import, which lost OwnedAlloc. The removed NonNull import and write_stack_canary appear only as context. Both patches are 22 files, +273/-222.
    • Every kernel stack sits above unmapped memory, and an overflow names its stack #1211's spawn changes. They meet this change only through NewTask. A struct literal requires every field, and the seven kernel clippy shapes compile at 6d9c867 (logs/s2/rev.txt, every exit 0). These are the same shapes r1 accepted, and the diff adds no cfg arm.
    • Main since then. Main has moved to be76e6f. The merge is clean, and the commits since b053390 touch only issues/ and tests/.

Evidence validity

  • The A/B images stay. ab/s3-handles/rev.txt reads c61225b and the base reads f79df95, so the A/B measures this exact patch against its own base. The rebase brings in main's own commits only.
  • Part 1 is restaged at the landing head.
    • metal-handles-r2/t14/rev.txt reads 6d9c867, and status.txt is empty: the A/B row patch was reverted before staging.
    • All three images (testcases, testcases-watchdog, shared-debug) are hashed in sha256.txt.
    • Exit 2 is "staged".
  • The two guest runs cover different heads.
    • The x86-64 guest rows test the pre-rebase tree. That suffices for this change, because the patch's own lines are the same.
    • The rebased tree is exercised on x86-64 by the T14 boots, and on AArch64 by virt_jobs_at_el2.
  • The A/B rule (r2) names the exact claimed rows (six fs.mem rows, ipc.reqrep.8, ipc.reqrep_handle.8) and the reported-only rows. It also says that a control row separating by 5% or more is investigated. That is what r1 asked. (The rule's AMD clause, which concerns A clock read takes RDTSCP and holds nothing after it, and std's Instant is the page's nanoseconds #1240, is reviewed there.)

Findings

None.

What must arrive before landing

  • Guest: 43065189798 (virt_jobs_at_el2, 6d9c867) and 43064648373 (c61225b) green.
  • T14 at 6d9c867: testcases, testcases-watchdog and shared-debug boots green, from the images whose hashes metal-handles-r2/t14/sha256.txt lists. They must include:
    • handle_lifetime, abuse_handle_table, abuse_thread_table, messages_are_whole, stream_handles;
    • spawn_lands_claimed, spawn_child_ends_first, spawn_returns_the_census, endowment_denied;
    • a_served_file_reads_through_its_paged_object, a_pager_s_end_answers_its_waiting_holders_gone;
    • std_threads, inbox_cancel_wakes, port_badge.
  • A/B, both benches:
    • No control row separates by 5% or more without explanation.
    • Record how the claimed fs.mem and ipc.reqrep rows moved against the 60 ns (T14) and 33 ns (AMD) lookup. A missing gain does not reject this change; it informs the Step B decision.

LAND, pending the guest runs and metal results above.

Both benches are down. #1246 lands on its review, host checks and guest
rows, and `issues/a-memory-filesystem-call-costs-toyos-up-to-35-times-linuxs.md`,
the record of the served-file reads it targets, records what only metal can
establish: the T14's whole `testcases`, `testcases-watchdog` and
`shared-debug` boots of main after it, and the A/B of main before and after
it on both benches with the Linux comparison. Its speed-up is not yet
measured.

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

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator: landing under the benches-down rule. Review r2 LAND. Head 9619ad2 is 6d9c867 plus one record (verified); 6d9c867's patch equals c61225b's but for one import main removed. Guest runs 43064648373 (x86, c61225b) and 43065189798 (aarch64 virt_jobs_at_el2, 6d9c867): success. Owed on metal recorded; the speed-up is not yet measured on metal.

@Japabu
Japabu marked this pull request as ready for review November 11, 2026 21:20
@Japabu
Japabu added this pull request to the merge queue Nov 11, 2026
Merged via the queue into main with commit 06fcb81 Nov 11, 2026
10 checks passed
@Japabu
Japabu deleted the handles-per-thread branch November 11, 2026 21:59
Japabu added a commit that referenced this pull request Nov 11, 2026
…o longer uses

#1241 deleted the watch's gate model and this change deleted the
two-submitter model. Those were `loom_watch`'s two uses of `CPU1`, so after
both the import is unused, and `cargo clippy -p kernel-loom --all-targets`
refuses it. The rebase's other resolutions are in the commits it replays:
`resolve` and `ThisProcess` look up through #1246's `with_handles`, and the
watch imports `poll::Via` beside main's `Arc` and `CellLock`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
…ved-write pieces

Main moved 64 commits. Four files conflicted, and one call stopped
compiling:

- kernel/src/object/paged.rs: #1245's read returns once a copy reaches
  the read's end. Here that return sits where no write met the read,
  before it reads again.
- kernel/src/syscall/paged.rs: #1246 looks a holder up under the handle
  table's own lock. The lookup keeps that form and checks the right each
  call needs (READ for a read and a statement, WRITE for a write).
- lib/bcachefs/src/engine/tests/data.rs and the memory filesystem's
  record: both sides added beside each other, and both are kept.
- #1216 split write_run into span, fill, seal and point_extent, and gave
  reserved writes their own planned path, dropping the reserved sector
  write_run took. The placed pool's writes take the sector their pool
  holds again: write_run has the optional placement it had, now over
  main's pieces, and the placed write checks the volume is writable as
  a reserved write does.

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