Skip to content

A futex wait translates its word once and checks it with one load - #1253

Merged
Japabu merged 3 commits into
mainfrom
futex-key-once
Nov 11, 2026
Merged

Japabu merged 3 commits into
mainfrom
futex-key-once

Conversation

@Japabu

@Japabu Japabu commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

SYS_FUTEX_WAIT translated its word under the space's lock, and then wait_until's predicate translated it
again, under the same lock and with an Arc clone of the current space, on every check: before arming,
after arming, after each wake and at the deadline. wakeperf's segments put that translation at 60 to 63 ns,
taken two to four times per wake round trip, inside body_end:futex_wait of about 185 to 205 ns.

Resulting behavior

  • One translation per call (Space::translate -> Translation), which also reads the space's new count
    of leaf changes (Usage::shot_down) in the same hold of the lock.
  • Every change to a present leaf is counted: AddressSpace::shoot_down increments the count first, under
    the lock, before the TLB shootdown, the futex revoke and the free. build's unwinding and discard, which
    called the tables' shootdown directly, now go through it, so their shootdowns also count in the test
    kernel's SPACE_REACHED (which only munmap deltas read).
  • The predicate is a load of the word through the direct map and Translation::holds: an acquire fence
    and a load of the count. Unchanged, the address still maps the frame and the frame is not freed. Moved,
    one hold of the lock asks whether the address still maps the frame — the check every call made before —
    and takes the new count if it does. So a change elsewhere in the space (another range's munmap,
    mprotect) neither ends the wait early nor turns its timeout into a wake.
  • futex::watch_of and scheduler::futex_wake take the physical address instead of a direct-map pointer.

What keeps the key valid

Unchanged from before: an unmap ends the waits on the frames it frees (futex::revoke_frames, under the
bucket locks) after it counts the change, and a waiter arms under its bucket's lock before it checks. Either
the revoke finds the waiter armed and ends it, or the arm came after the revoke, so the waiter's check
happens after the count moved and the lock-held look finds the address no longer mapping the frame. Reading
a frame freed meanwhile through the direct map is what the previous code also did between its check and its
read; at worst it is a spurious return, which the ABI allows. No loom model is added: the ordering is the
bucket lock's and the space lock's, which their own models cover; the count is only read and written under
or after them.

Not done

  • A lock-free page-table walk for the one translation. Unsafe on AArch64 as things stand: its shootdown is a
    broadcast TLBI with no IPI, so nothing waits for a walker before tables are freed; it would need deferred
    table freeing.
  • No park counter is added (the brief asked to check benchrows first; this change adds none).

Checks

Targeted per the testing policy. kernel/src has no host tests.

check result
at 2583d7de8: cargo clippy -p kernel with build/clippy.rs's adopted lints, -D warnings, x86_64-unknown-none: default, boot-actuators,test-actuators, the instrument set exit 0 each
the same, aarch64-unknown-none-softfloat: default, boot-actuators,test-actuators exit 0 each
at bb5ae5397 (review r1: one SAFETY comment): cargo check -p kernel --target x86_64-unknown-none exit 0

Guest rows:

  • The first dispatch, 43058887998, ended at No test matches filter "futex_wake_counts" before building a
    guest: six of its eight names are shared-boot members, which guest rows do not select. It established
    nothing.
  • 43064655857 at 2583d7de8, green: std_threads_count, libc_children, affinity_pins, vector_state,
    and on AArch64 (mm::space sits beneath it) virt_smp, virt_unmap_touch. The head differs from
    2583d7de8 in one SAFETY comment and an issue record only.

Owed on metal

Both benches are down, so this lands on its review, host checks and guest rows. The speed-up is not yet
measured on metal.
issues/a-two-thread-ping-pong-costs-toyos-up-to-twice-linuxs-on-the-t14.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 futex_wake_counts (its orphaned_by_unmap arm is the revoke race),
    kill_ends_every_wait, exit_wait_storm, std_threading, std_threads, shm_release_reclaims and
    tlb_shootdown_waits (which reads SPACE_REACHED, which discard and build's unwinding now also count);
  • an A/B of main before and after this PR, on the T14 and the AMD bench (osbench sched, time, files,
    ipc; four boots a side, rotated; exact two-sided Mann-Whitney over per-boot p50s), and Linux's
    osbench sched on each bench. Claimed: pingpong.futex.same_cpu and pingpong.futex.cross_core lower or
    not separated, the latter only from boots whose round trips parked; no other row 5% or more worse unless
    this change explains it.

Not taken (review r1's optional suggestion 2): a shared-boot member asserting that a timed wait beside a
sibling's mmap/munmap churn answers 1. The ABI allows a spurious 0, so it would hold a quality of the answer
rather than a contract.

Risks

  • A process with heavy mmap/munmap churn pays the lock-held look once per check after each change, which
    is what every check cost before; between changes, a check is a load.

🤖 Generated with Claude Code

`SYS_FUTEX_WAIT` translated the word under its space's lock, and then
`wait_until`'s predicate translated it again under the same lock on every
check: before arming, after arming, after each wake and at the deadline,
each with an `Arc` clone of the current space. wakeperf's segments put the
translation at 60 to 63 ns, two to four times a round trip.

The wait now translates once (`Space::translate`), and the translation
carries the space's count of leaf changes read in that same hold. Every
change to a present leaf is shot down, and `AddressSpace::shoot_down` now
counts it first, under the lock, before the shootdown, the futex revoke and
the free; `build`'s unwinding and `discard` go through it too, so their
shootdowns are also counted in the test kernel's `SPACE_REACHED`. The
predicate reads the word through the direct map and then asks
`Translation::holds`: an acquire fence and a load of the count. While the
count is unchanged the address still maps the frame and the frame is not
freed. When it has moved, one hold of the lock asks whether the address
still maps the frame, as every check did before, and takes the new count
if it does, so a change elsewhere in the space neither ends the wait nor
turns a timeout into a wake.

What keeps a waiter from sleeping on a frame an unmap freed is unchanged:
the unmap ends the waits on its frames under the bucket locks after it
counts the change, and a waiter arms under its bucket's lock before it
checks, so either the revoke finds it armed or its check sees the count
moved and the address no longer mapping the frame.

`futex::watch_of` and `scheduler::futex_wake` take the physical address
rather than a direct-map pointer.

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

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1 of #1253 at 2583d7d

Architecture. A wait now translates its word once and validates that translation against a per-space count of leaf changes, kept under the space's own lock. The count lives in Usage, the space's existing lock-free face, and Translation keeps the space alive for the length of the wait. No lock, park counter or new waiting mechanism is added. +87/-55 in 5 files. It merges cleanly with main. #1211 changes process.rs and scheduler.rs elsewhere (kernel stacks, an import), and none of the futex or space code.

Correctness is sound.

  • Coverage of the count.
    • Every change to a present leaf goes through AddressSpace::shoot_down: protect, discard, clear (reached from munmap and release_object), mremap's move, and build's unwinding. The last two are newly counted.
    • Held -> Mapped and Empty -> Mapped change no present leaf.
    • Drop runs only once no Translation holds the space.
  • ABA cases.
    • Frame freed and reused. If the same VA now maps a frame with the same number, the slow path answers what a fresh translation would, so waiting on is correct. If the address maps anything else, the wait returns Changed, a spurious return the ABI allows.
    • Mapping changed and changed back (protect None then RW, or a discard whose pinned frame is kept in place). The count moved, the slow path finds the same frame, and the wait goes on correctly.
    • COW break. ToyOS has none. A Copy range takes its private frame at the first touch of any kind, and futex_word's Read reach is such a touch.
  • Lost wakes: none new.
    • Every path that frees a frame counts the change before revoke_frames, and arming and revoking take the same bucket lock.
    • A waiter armed first is revoked. One armed afterwards sees the count through that lock on its post-arm check, and the slow path finds the address gone.
    • A pre-arm check that reads a stale count only goes on to arm and check again.
  • Memory ordering of the count. It is incremented under the space lock, Relaxed. Readers read it under that lock, or after fence(Acquire), whose only job is to tie the word's value to the count it is validated against. The key's validity rests on the bucket and space locks, which existing models cover, so I agree no loom model is needed.
  • The draft's defect is fixed. An unrelated change sends holds to the slow path once, which refreshes at, so a timeout is no longer reported as a wake.
  • A read after a revoke. Every check after a revoke now reads the freed frame through the direct map before holds; the old code checked the translation first. This is a harmless read of RAM: the only frames outside the direct map are Every kernel stack sits above unmapped memory, and an overflow names its stack #1211's permanent bring-up guards, and those are never user frames.

Blockers

  1. Guest evidence does not exist.
    • Run 43058887998 ended at No test matches filter "futex_wake_counts" (exit 1, 16:32:30) before building a guest.
    • Six of the eight names are shared-boot members. Guest rows select only MACHINE_TESTS and SCREEN_TESTS, so those six run only in the metal boots.
    • The PR's guest list is therefore false; correcting the body is the orchestrator's.
    • mm::space is memory management shared beneath AArch64, so dispatch virt_unmap_touch virt_smp, the two valid names.

Suggestions

  1. kernel/src/scheduler.rs:262: the SAFETY comment says the direct map "maps every frame". Main now has holes in it (Every kernel stack sits above unmapped memory, and an overflow names its stack #1211's bring-up guards). Saying "every frame a user page can hold" keeps the stated reason true after the merge.

  2. Nothing tests the fix to the draft. A shared-boot member could:

    • make a 50 ms timed futex_wait on one thread;
    • mmap and munmap another range in a loop on a sibling thread until the wait returns;
    • assert the answer is 1.

    That would catch a later change to holds that brings the bug back. QEMU can run it, because it asserts the answer, not the time. It is optional, since the ABI tolerates a spurious answer.

Evidence

  • logs/f1/diff.patch matches this head's diff against f79df95 line for line. The clippy runs cover x86-64 default, actuators and instruments, and AArch64 default and actuators, each exit 0. The change touches no feature-gated code beyond the actuators' reached counter, which those runs cover.

What metal must show

  • T14 Part 1 green at this head. In particular:
    • futex_wake_counts, whose orphaned_by_unmap arm is the revoke race;
    • kill_ends_every_wait, exit_wait_storm, std_threading, std_threads, shm_release_reclaims;
    • shared-debug's tlb_shootdown_waits, which reads SPACE_REACHED, a counter discard and build's unwinding now also increment.
  • A/B: record how the futex ping-pong moved; no control row worse by 5% or more. On 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.

SEND BACK. This is for the evidence only; no code change is requested. It can land once the virt_unmap_touch virt_smp dispatch is green and the metal results above are recorded.

…ct map holds

Since #1211 the direct map leaves out the bring-up guard pages, so it no
longer maps every frame; it maps every frame a user page can hold, which is
what the read relies on.

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

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Response to review r1, head bb5ae53.

  • Guest evidence: the first dispatch named shared-boot members and established nothing (the body now says so). The orchestrator's 43064655857 carries virt_smp and virt_unmap_touch with four x86-64 rows.
  • Suggestion 1 taken: the SAFETY comment says the direct map maps every frame a user page can hold (cargo check -p kernel exit 0). The queued metal boots at 2583d7d stand; this commit changes only that comment.
  • Suggestion 2 not taken: the ABI allows a spurious 0, so the test would hold a quality of the answer rather than a contract, and it would change the testcases image already queued for this head.

🤖 Generated with Claude Code

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator: verified bb5ae53 against review r1. git diff 2583d7de8 bb5ae5397 changes only the SAFETY comment in kernel/src/scheduler.rs ("every frame a user page can hold", which is accurate after #1211's guard pages). No code changed, so the queued T14 boots at 2583d7d stay valid. Landing needs guest run 43064655857 (std_threads_count libc_children affinity_pins vector_state virt_smp virt_unmap_touch) green, the T14 testcases/shared-debug boots green (especially futex_wake_counts, kill_ends_every_wait, the std threading tests and tlb_shootdown_waits), and the A/B's futex ping-pong recorded.

Both benches are down. #1253 lands on its review, host checks and guest
rows, and `issues/a-two-thread-ping-pong-costs-toyos-up-to-twice-linuxs-on-the-t14.md`,
the record of the futex round trip 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 verification, landing under the benches-down rule (metal unavailable; review + host + guest, metal owed):

  • Review LAND. Head 1ec47d7 vs guest-tested 2583d7d: one SAFETY comment in kernel/src/scheduler.rs (no executable line) + one record.
  • Guest run 43064655857 at 2583d7d: success.
  • Owed on metal recorded in issues/a-two-thread-ping-pong-costs-toyos-up-to-twice-linuxs-on-the-t14.md; the speed-up is not yet measured on metal.

@Japabu
Japabu marked this pull request as ready for review November 11, 2026 20:03
@Japabu
Japabu added this pull request to the merge queue Nov 11, 2026
@Japabu
Japabu removed this pull request from the merge queue due to a manual request Nov 11, 2026
@Japabu
Japabu added this pull request to the merge queue Nov 11, 2026
Merged via the queue into main with commit d17f22c Nov 11, 2026
10 checks passed
@Japabu
Japabu deleted the futex-key-once branch November 11, 2026 21:38
Japabu added a commit that referenced this pull request Nov 11, 2026
…utex wait

Every unmap that freed a touched private page took each of the 64 futex
bucket locks to end waits on its frames, whether or not anything waited.
The 2026-11-11 stage probes put that walk at 1,159 ns on the T14 and 1,146
ns on the AMD bench per unmap of one touched page.

Each space now counts its own threads' futex waits (`sched::futex::Waits`,
held by the `Space` and its `AddressSpace`). The futex-wait syscall counts
itself before it translates its word, under the space's lock, and stops
counting after the wait has unregistered. A clear or a discard ends waits on
its own private frames only while the count is not zero.

Why that is exact, with #1253's single translation, where a wait's later
checks read the space's count of leaf changes without the lock:
- A private frame is mapped in one space, so only that space's waits can be
  keyed on it, and only on a frame the syscall's translation found.
- A wait whose translation came before the clear is counted by then. The
  space's lock orders that count before the gate's load, so the buckets are
  walked. The walk's bucket locks then order the shootdown's count of the
  change before any later check of the wait, which finds the frame gone, as
  #1253 argues.
- A wait whose translation came after the clear finds the frame gone and
  waits on no frame of these.

The lock orders both cases, so the count can be `Relaxed`.

A count only around the later checks would not do: those checks take no
lock, so a skipped walk would leave nothing ordering the change before them.

A shared object's frames can be keyed by waits in any space that mapped it,
so its drop keeps walking every bucket, as before.

A machine-wide count, this PR's first form, never reached zero on a booted
ToyOS: the supervisor's files worker and logkeeper's carrier-stall thread
wait on futexes for the whole boot.

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

Every unmap that freed a touched private page took each of the 64 futex
bucket locks to end waits on its frames, whether or not anything waited.
The 2026-11-11 stage probes put that walk at 1,159 ns on the T14 and 1,146
ns on the AMD bench per unmap of one touched page.

Each space now counts its own threads' futex waits (`sched::futex::Waits`,
held by the `Space` and its `AddressSpace`). A clear or a discard ends waits
on its own private frames only while that count is not zero, which it reads
under the space's lock.

**The order the gate needs.** A wait's count must come before the hold of
the space's lock in which its syscall translates the word. That hold's
release carries the count to any unmap that takes the lock after it. So
the futex-wait syscall makes a `mm::space::FutexWait` first. It raises the
count when made and lowers it when dropped, after the wait unregisters,
and the word's translation goes through it (`FutexWait::translate`). The
order is the type's, not the statements'.

Why that is exact, with #1253's single translation, whose later checks read
the space's count of leaf changes without the lock:
- A private frame is mapped in one space, so only that space's waits can be
  keyed on it, and only on a frame that translation found.
- If the translation held the lock before the clear, the wait was counted
  by then. The lock orders the count before the gate's load, so the buckets
  are walked. The walk's bucket locks then order the shootdown's count of
  the change before any later check of the wait, which finds the frame gone,
  as #1253 argues.
- If the translation held the lock after the clear, it found the frame
  gone, and the wait is keyed on none of the clear's frames.

The lock orders both cases, so the count can be `Relaxed`. A count taken
after the translation would race #1253's lockless checks: nothing would order
it before the gate's load, or the change before the checks.

A shared object's frames can be keyed by waits in any space that mapped it,
so its drop keeps walking every bucket, as before.

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

Every unmap that freed a touched private page took each of the 64 futex
bucket locks to end waits on its frames, whether or not anything waited.
The 2026-11-11 stage probes put that walk at 1,159 ns on the T14 and 1,146
ns on the AMD bench per unmap of one touched page.

Each space now counts its own threads' futex waits (`sched::futex::Waits`,
held by the `Space` and its `AddressSpace`). A clear or a discard ends waits
on its own private frames only while that count is not zero, which it reads
under the space's lock.

**The order the gate needs.** A wait's count must come before the hold of
the space's lock in which its syscall translates the word. That hold's
release carries the count to any unmap that takes the lock after it. And the
count must last until the wait has unregistered.

So the futex-wait syscall begins a `mm::space::FutexWait`, which raises the
count when made and lowers it when dropped. The word's only translation is
`FutexWait::translate`'s, and that `Translation<'_>` borrows the guard
(`Space::translate` is folded into it), so:
- the guard cannot be dropped while the wait still holds the translation
  (E0505);
- it cannot be a temporary that the translation outlives (E0515);
- `scheduler::futex_wait` takes only such a translation, so no wait can skip
  the count (E0308). The wake translates its word to a bare frame, which no
  wait accepts.

Why that is exact, with #1253's single translation, whose later checks read
the space's count of leaf changes without the lock:
- A private frame is mapped in one space, so only that space's waits can be
  keyed on it, and only on a frame that translation found.
- If the translation held the lock before the clear, the wait was counted
  by then. The lock orders the count before the gate's load, so the buckets
  are walked. The walk's bucket locks then order the shootdown's count of
  the change before any later check of the wait, which finds the frame gone,
  as #1253 argues.
- If the translation held the lock after the clear, it found the frame
  gone, and the wait is keyed on none of the clear's frames.

The lock orders both cases, so the count can be `Relaxed`. A count taken
after the translation would race #1253's lockless checks: nothing would order
it before the gate's load, or the change before the checks.

A shared object's frames can be keyed by waits in any space that mapped it,
so its drop keeps walking every bucket, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
…e model's futex wait translates and arms apart

B13. `clear_paged` shot its span down through the tables directly, which
leaves the space's count of changes where it was; #1253's
`Translation::holds` trusts an unmoved count, so a futex wait that translated
before a visit cleared its leaf could arm after the walk's sweep, keyed on a
frame the walk then gave back. The visit now shoots down through the space's
own `shoot_down`, which counts the change under the space's lock before the
sweep can run. `clear_paged`'s and `futex_word`'s docs say so.

The model's futex wait is two steps, as the kernel's is: `Translate(s, page)`
notes the frame and the space's count; `Arm` keys the waiter on the frame
where the count has not moved and otherwise looks at the leaf again. A
visit's clear and an unmap move the count. The reviewer's sequence is
`a_wait_translated_before_a_visit_does_not_arm_on_a_frame_given_back`; with
the visit's clear uncounted (a checked patch, the source restored
byte-identical) it is red on the model's oracle, "frame 101 given back with a
futex waiter keyed on it".

B14: `Unmapped`'s doc says only that no space maps anything, and the double
blank line above it is gone. Teardown takes the space out of the process
data, as the handles line above it does, before ending it, so the process
data's lock is not held across the space's shootdowns and pager calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
…e model's futex wait translates and arms apart

B13. `clear_paged` shot its span down through the tables directly, which
leaves the space's count of changes where it was; #1253's
`Translation::holds` trusts an unmoved count, so a futex wait that translated
before a visit cleared its leaf could arm after the walk's sweep, keyed on a
frame the walk then gave back. The visit now shoots down through the space's
own `shoot_down`, which counts the change under the space's lock before the
sweep can run. `clear_paged`'s and `futex_word`'s docs say so.

The model's futex wait is two steps, as the kernel's is: `Translate(s, page)`
notes the frame and the space's count; `Arm` keys the waiter on the frame
where the count has not moved and otherwise looks at the leaf again. A
visit's clear and an unmap move the count. The reviewer's sequence is
`a_wait_translated_before_a_visit_does_not_arm_on_a_frame_given_back`; with
the visit's clear uncounted (a checked patch, the source restored
byte-identical) it is red on the model's oracle, "frame 101 given back with a
futex waiter keyed on it".

B14: `Unmapped`'s doc says only that no space maps anything, and the double
blank line above it is gone. Teardown takes the space out of the process
data, as the handles line above it does, before ending it, so the process
data's lock is not held across the space's shootdowns and pager calls.

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

Every unmap that freed a touched private page took each of the 64 futex
bucket locks to end waits on its frames, whether or not anything waited.
The 2026-11-11 stage probes put that walk at 1,159 ns on the T14 and 1,146
ns on the AMD bench per unmap of one touched page.

Each space now counts its own threads' futex waits (`sched::futex::Waits`,
held by the `Space` and its `AddressSpace`). A clear or a discard ends waits
on its own private frames only while that count is not zero, which it reads
under the space's lock.

**The order the gate needs.** A wait's count must come before the hold of
the space's lock in which its syscall translates the word. That hold's
release carries the count to any unmap that takes the lock after it. And the
count must last until the wait has unregistered.

So the futex-wait syscall begins a `mm::space::FutexWait`, which raises the
count when made and lowers it when dropped. The word's only translation is
`FutexWait::translate`'s, and that `Translation<'_>` borrows the guard
(`Space::translate` is folded into it), so:
- the guard cannot be dropped while the wait still holds the translation
  (E0505);
- it cannot be a temporary that the translation outlives (E0515);
- `scheduler::futex_wait` takes only such a translation, so no wait can skip
  the count (E0308). The wake translates its word to a bare frame, which no
  wait accepts.

Why that is exact, with #1253's single translation, whose later checks read
the space's count of leaf changes without the lock:
- A private frame is mapped in one space, so only that space's waits can be
  keyed on it, and only on a frame that translation found.
- If the translation held the lock before the clear, the wait was counted
  by then. The lock orders the count before the gate's load, so the buckets
  are walked. The walk's bucket locks then order the shootdown's count of
  the change before any later check of the wait, which finds the frame gone,
  as #1253 argues.
- If the translation held the lock after the clear, it found the frame
  gone, and the wait is keyed on none of the clear's frames.

The lock orders both cases, so the count can be `Relaxed`. A count taken
after the translation would race #1253's lockless checks: nothing would order
it before the gate's load, or the change before the checks.

A shared object's frames can be keyed by waits in any space that mapped it,
so its drop keeps walking every bucket, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 12, 2026
…e model's futex wait translates and arms apart

B13. `clear_paged` shot its span down through the tables directly, which
leaves the space's count of changes where it was; #1253's
`Translation::holds` trusts an unmoved count, so a futex wait that translated
before a visit cleared its leaf could arm after the walk's sweep, keyed on a
frame the walk then gave back. The visit now shoots down through the space's
own `shoot_down`, which counts the change under the space's lock before the
sweep can run. `clear_paged`'s and `futex_word`'s docs say so.

The model's futex wait is two steps, as the kernel's is: `Translate(s, page)`
notes the frame and the space's count; `Arm` keys the waiter on the frame
where the count has not moved and otherwise looks at the leaf again. A
visit's clear and an unmap move the count. The reviewer's sequence is
`a_wait_translated_before_a_visit_does_not_arm_on_a_frame_given_back`; with
the visit's clear uncounted (a checked patch, the source restored
byte-identical) it is red on the model's oracle, "frame 101 given back with a
futex waiter keyed on it".

B14: `Unmapped`'s doc says only that no space maps anything, and the double
blank line above it is gone. Teardown takes the space out of the process
data, as the handles line above it does, before ending it, so the process
data's lock is not held across the space's shootdowns and pager calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 12, 2026
…e model's futex wait translates and arms apart

B13. `clear_paged` shot its span down through the tables directly, which
leaves the space's count of changes where it was; #1253's
`Translation::holds` trusts an unmoved count, so a futex wait that translated
before a visit cleared its leaf could arm after the walk's sweep, keyed on a
frame the walk then gave back. The visit now shoots down through the space's
own `shoot_down`, which counts the change under the space's lock before the
sweep can run. `clear_paged`'s and `futex_word`'s docs say so.

The model's futex wait is two steps, as the kernel's is: `Translate(s, page)`
notes the frame and the space's count; `Arm` keys the waiter on the frame
where the count has not moved and otherwise looks at the leaf again. A
visit's clear and an unmap move the count. The reviewer's sequence is
`a_wait_translated_before_a_visit_does_not_arm_on_a_frame_given_back`; with
the visit's clear uncounted (a checked patch, the source restored
byte-identical) it is red on the model's oracle, "frame 101 given back with a
futex waiter keyed on it".

B14: `Unmapped`'s doc says only that no space maps anything, and the double
blank line above it is gone. Teardown takes the space out of the process
data, as the handles line above it does, before ending it, so the process
data's lock is not held across the space's shootdowns and pager calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 12, 2026
…e model's futex wait translates and arms apart

B13. `clear_paged` shot its span down through the tables directly, which
leaves the space's count of changes where it was; #1253's
`Translation::holds` trusts an unmoved count, so a futex wait that translated
before a visit cleared its leaf could arm after the walk's sweep, keyed on a
frame the walk then gave back. The visit now shoots down through the space's
own `shoot_down`, which counts the change under the space's lock before the
sweep can run. `clear_paged`'s and `futex_word`'s docs say so.

The model's futex wait is two steps, as the kernel's is: `Translate(s, page)`
notes the frame and the space's count; `Arm` keys the waiter on the frame
where the count has not moved and otherwise looks at the leaf again. A
visit's clear and an unmap move the count. The reviewer's sequence is
`a_wait_translated_before_a_visit_does_not_arm_on_a_frame_given_back`; with
the visit's clear uncounted (a checked patch, the source restored
byte-identical) it is red on the model's oracle, "frame 101 given back with a
futex waiter keyed on it".

B14: `Unmapped`'s doc says only that no space maps anything, and the double
blank line above it is gone. Teardown takes the space out of the process
data, as the handles line above it does, before ending it, so the process
data's lock is not held across the space's shootdowns and pager calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 12, 2026
…e model's futex wait translates and arms apart

B13. `clear_paged` shot its span down through the tables directly, which
leaves the space's count of changes where it was; #1253's
`Translation::holds` trusts an unmoved count, so a futex wait that translated
before a visit cleared its leaf could arm after the walk's sweep, keyed on a
frame the walk then gave back. The visit now shoots down through the space's
own `shoot_down`, which counts the change under the space's lock before the
sweep can run. `clear_paged`'s and `futex_word`'s docs say so.

The model's futex wait is two steps, as the kernel's is: `Translate(s, page)`
notes the frame and the space's count; `Arm` keys the waiter on the frame
where the count has not moved and otherwise looks at the leaf again. A
visit's clear and an unmap move the count. The reviewer's sequence is
`a_wait_translated_before_a_visit_does_not_arm_on_a_frame_given_back`; with
the visit's clear uncounted (a checked patch, the source restored
byte-identical) it is red on the model's oracle, "frame 101 given back with a
futex waiter keyed on it".

B14: `Unmapped`'s doc says only that no space maps anything, and the double
blank line above it is gone. Teardown takes the space out of the process
data, as the handles line above it does, before ending it, so the process
data's lock is not held across the space's shootdowns and pager calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 12, 2026
…e model's futex wait translates and arms apart

B13. `clear_paged` shot its span down through the tables directly, which
leaves the space's count of changes where it was; #1253's
`Translation::holds` trusts an unmoved count, so a futex wait that translated
before a visit cleared its leaf could arm after the walk's sweep, keyed on a
frame the walk then gave back. The visit now shoots down through the space's
own `shoot_down`, which counts the change under the space's lock before the
sweep can run. `clear_paged`'s and `futex_word`'s docs say so.

The model's futex wait is two steps, as the kernel's is: `Translate(s, page)`
notes the frame and the space's count; `Arm` keys the waiter on the frame
where the count has not moved and otherwise looks at the leaf again. A
visit's clear and an unmap move the count. The reviewer's sequence is
`a_wait_translated_before_a_visit_does_not_arm_on_a_frame_given_back`; with
the visit's clear uncounted (a checked patch, the source restored
byte-identical) it is red on the model's oracle, "frame 101 given back with a
futex waiter keyed on it".

B14: `Unmapped`'s doc says only that no space maps anything, and the double
blank line above it is gone. Teardown takes the space out of the process
data, as the handles line above it does, before ending it, so the process
data's lock is not held across the space's shootdowns and pager calls.

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