Skip to content

Kde full display - #130

Merged
hodgesds merged 31 commits into
mainfrom
kde-full-display
Aug 10, 2026
Merged

Kde full display#130
hodgesds merged 31 commits into
mainfrom
kde-full-display

Conversation

@hodgesds

@hodgesds hodgesds commented Aug 8, 2026

Copy link
Copy Markdown
Owner

No description provided.

hodgesds and others added 30 commits August 8, 2026 14:36
QV4_FORCE_INTERPRETER=1 and QT_ENABLE_REGEXP_JIT=0 were set because the
RW->RX flip returned EINVAL. That is no longer true: mprotect's W^X arm
returns WxTransition::NeedsCapJit, and jit_cap_default_policy() GRANTS a
JitCap by default (memory/src/wx.rs — denial is opt-in per task), after
which jit_mprotect performs the flip.

So the workaround was stale, and it was expensive: plasmashell is
QML-heavy and running it interpreted sits directly on session-startup
latency. W^X is still enforced — nothing grants a W|X end state.

Also drops the last QT_LOGGING_RULES debug category. Serial output is
synchronous, and the kernel console repaints per line while kwin only
silences it after its first successful blit, so log traffic both costs
wall-clock and widens the window in which the console overwrites the
compositor.

NOTE on hardware acceleration: there is no GPU path to enable. NARF's DRM
is a dumb-buffer scanout shim with no command submission or 3D engine, so
llvmpipe/kms_swrast is the only renderer. KVM (CPU virt) is already on via
XTASK_QEMU_ACCEL=kvm. The JIT above is the real remaining lever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kwin composites a decorated foot window that renders its grid and cursor
block but shows NO PROMPT. foot is a live Wayland client with correct
decorations, so the failure is the shell it spawns, not rendering.

Every terminal emulator gets its shell the same way:

    posix_openpt(O_RDWR|O_NOCTTY)   -> master on /dev/ptmx
    grantpt / unlockpt              -> TIOCSPTLCK
    ptsname                         -> TIOCGPTN, "/dev/pts/N"
    fork; child: setsid, TIOCSCTTY, dup onto 0/1/2, exec the shell
    parent: read the master

A prompt requires all of it. The probe walks the same sequence and prints
each step's errno, so the failure names a syscall rather than leaving
"the window is blank". The child echoes a token and exits, so the check
is deterministic — this tests the PTY path, not prompt rendering.

Self-validates on the host: every step ok, master read returns
PTY-CHILD-ALIVE. Built PIE, not -static — NARF rejects non-PIE ELFs with
execve EINVAL, which reads as the probe failing rather than never
running (already hit once with the QSaveFile probe).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kwin was not crashing. Its unit status read

    Process: 36 ExecStart=/usr/bin/kwin_wayland_wrapper --xwayland
             (code=dumped, signal=ABRT)
    Mem peak: 1010.3M
    Active: failed (Result: timeout)

which looks like a crash or an OOM and is neither. `Result: timeout` is the
tell: kwin_wayland is Type=dbus, so systemd waits for org.kde.KWinWrapper
to appear within DefaultTimeoutStartSec (90s upstream). Everything here
renders in software (llvmpipe/kms_swrast, QPainter), so it routinely does
not arrive in time; systemd then stops the unit, and Fedora's
10-timeout-abort.conf drop-in sets TimeoutStopFailureMode=abort, which
SIGABRTs it and writes a core. The 1 GB is Qt+llvmpipe's ordinary
footprint, not an allocation failure — no glibc allocator message appears
anywhere in the log.

plasma-kcminit and xdg-desktop-portal were TERM'd by the same mechanism as
ordering victims (Result: timeout, signal=TERM).

So: DefaultTimeoutStartSec=600s, DefaultTimeoutStopSec=120s.

This diagnosis was only visible because the verbose diagnostics were GATED
rather than deleted when the debug logging was stripped — the unit NAMES
alone never carried the ABRT or the peak. NARF_PLASMA_VERBOSE=1 restores
them.

Also builds narf-pty-probe into the image (PIE, never -static) for the
empty-foot-terminal question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PtyMaster::read popped the ring and returned Ok(0) when it was empty. On a
PTY master, read() == 0 means THE SLAVE HUNG UP. Returning it merely
because no bytes had arrived yet hands the terminal a phantom EOF: it
concludes the shell exited and stops reading forever.

That is why the `foot` window rendered its grid and cursor block but never
a prompt. A probe walking a terminal's own sequence in-guest
(posix_openpt, grantpt, unlockpt, ptsname, open slave, fork,
setsid+TIOCSCTTY+dup2, exec, read master) showed:

    posix_openpt   ok (3)
    grantpt        ok (0)
    unlockpt       ok (0)          <- TIOCSPTLCK fine
    ptsname        ok (/dev/pts/0) <- TIOCGPTN fine
    open(slave)    ok (4)
    master read    FAILED n=0 errno=0
    child exit     status=0, exited normally, code=0

Every setup step worked and the shell RAN AND EXITED CLEANLY — only the
slave->master path reported EOF. Nothing was hanging; the pipe was
one-way-dead.

The kernel already had the mechanism: sys_read consults
`read_should_block()` and parks a blocking fd or returns EAGAIN for an
O_NONBLOCK one. PtyMaster simply never implemented it, so it inherited the
`false` default. Same class as the pipe/socket phantom-0 that broke GLib's
dbus line-read ("Unexpected lack of content trying to read a line") and
the evdev EAGAIN-on-empty fix.

The test asserts BOTH directions — blocks when empty, does NOT block once
the slave has written, then blocks again after draining. A version that
always blocked would pass a one-sided test and hang the terminal a
different way.

Red-verified: with read_should_block() forced to false the test fails with
"empty master read reports EOF — a terminal takes that as the shell
exiting and stops reading (blank foot window)".

Not addressed here: PtySlave::read has the same phantom-0 shape, but its
empty path is entangled with ^D EOF semantics (`take_eof`), so it needs
its own test and change rather than a blind copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…king

execve("/usr/bin/plasmashell") returned EIO whenever the exec happened at
a busy moment (KDE session startup, dbus activation of
xdg-desktop-portal-kde), while the same binary exec'd fine when the disk
was quiet. bash surfaced it as "/usr/bin/plasmashell: Input/output
error" — the last blocker in front of a Plasma desktop.

ROOT CAUSE: do_execve_resolved's read_exec streamed the whole binary
through FileOps::read pumped by poll_blocking, whose 4M-iteration budget
is a hard cap on how long one read future may wait. Under concurrent
block I/O (KDE startup streams tens of MB of DSOs through the same
virtio queue and the ext2 volume's fill lock), a healthy read of a
~1-1.5 MiB binary legitimately needs more re-polls than that. The
overrun maps to EIO, so large binaries exec'd under load failed while
kwin_wayland — exec'd at a quiet moment — worked, making the bug look
binary-specific when it was actually launch-load-specific. In-guest
instrumentation caught it directly:

  EXECVE-EIO read overrun off=0 size=1427000
    path=/mnt/usr/lib/systemd/systemd-executor

while a quiet-boot probe exec'd plasmashell and portal-kde fine, and
cat/md5sum of plasmashell over the same ext2 read path succeeded — the
file, its sparse-hole layout, and the ext2 driver were all healthy.

The overrun is also unsound: poll_blocking DROPS the in-flight read
future, abandoning a virtio-blk request that is still DMA'ing into a
scratch buffer whose guard just returned it to the pool. That is exactly
the hazard poll_io_to_completion (huge backstop, never drops the future
mid-flight) was introduced for, and the PT_INTERP read
(process::read_path_from_vfs) was already cut over to it after the same
failure class. The main image read was left behind. Cut over the image
resolve + read (and the fexecve fd-image read) too, and print an
unconditional EXECVE-EIO diagnostic before the remaining
wedged-device-only EIO returns — this silent EIO cost a full debugging
session.

Test: smoke_execve_image_read_survives_slow_backing_store (kernel test,
userspace/mount) mounts a filesystem whose file read yields Pending 6M
times — a stand-in for a contended backing store — and execve's a file
on it. Red before the fix with the production signature ("EXECVE-EIO
read overrun ... path=/slowexec/prog" → EIO); green after (the read
completes and the junk image reaches ELF validation → InvalidOp).

Boot-verified on the Fedora 43 KDE image: zero EXECVE-EIO lines over a
full systemd boot + session startup (the old kernel logged them within
the same window), and the in-image probe execs /usr/bin/plasmashell
successfully both at quiet boot and mid-session under load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit c0458550cc9bf7a8b077325d1c0569238676b1cb)
A `FileOps::read` returning `Ok(0)` when there is simply nothing available
is correct for a FILE (end of file) and wrong for a STREAM: `read() == 0`
tells userspace the peer hung up, so the reader tears the fd down — or, if
it retries, burns CPU.

Found by auditing every `impl FileOps` that defines `read()`. NARF already
had both remedies; each of these silently inherited the `false` defaults:

    nonblock_read_eagain() -> sys_read returns EAGAIN for an O_NONBLOCK fd
    read_should_block()    -> sys_read parks a blocking one

Fixed, in descending severity:

  TimerFd        Ok(0) with no expirations, commented "libc loops until
                 non-zero" — the SYMPTOM written down as if it were the
                 contract. A caller handed 0 has nothing to wait on, so it
                 re-reads at once and spins. That is a busy-spin the kernel
                 inflicts on userspace.
  EventFd        Ok(0) on a zero counter. eventfd is the wakeup primitive
                 under every Qt/GLib event loop; a phantom EOF makes the
                 loop treat its own wakeup channel as dead.
  SignalFd
  SignalFdFile   Ok(0) with nothing pending; the latter even called that an
                 "EAGAIN shape" — a bare 0 is NOT EAGAIN at the syscall
                 boundary, it is EOF.
  PtySlave       Ok(0) on an empty queue, which is how an interactive shell
                 dies the instant it starts.
  UinputControl  Ok(0) unconditionally, commented "EOF is the safe answer" —
                 it is the opposite; a reader tears the device down.

PtySlave is the subtle one: canonical mode latches ^D as a GENUINE eof a
shell must see exactly once, so the empty case cannot simply block.
`LineState::would_block()` already drew that line ("no completed input AND
no ^D pending") and was never wired up; both opt-ins gate on it, so ^D
still returns 0. The pre-existing smoke_pty_slave_ctrl_d_eof still passes.

Deliberately NOT changed: FuseFile and MqueueFile (file-like — 0 past end
of data is right), EpollFile (epoll fds are not readable on Linux),
DevPtmx/FifoNode (node stubs; real I/O goes via the master / FifoHandle,
which already opts in). Blanket-applying the opt-ins would break every
regular file, where 0 is the correct EOF.

Tests, all green:
  smoke_pty_master_empty_read_is_would_block_not_eof   (red-verified)
  smoke_pty_slave_empty_blocks_but_ctrl_d_is_real_eof  — asserts BOTH that
      empty blocks and that ^D still yields a 0-byte read
  smoke_dev_uinput_empty_read_is_not_eof
  smoke_io_mux_empty_reads_are_not_eof                 — eventfd + timerfd,
      and that a pending value CLEARS the block, so a woken reader is not
      parked again

This is the same class as two already-fixed production failures: the pipe
phantom 0 that broke GLib's dbus line-read ("Unexpected lack of content
trying to read a line") and killed the KDE session bus, and PtyMaster's,
which left the foot terminal blank.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rehose

The UNIXENQ/UNIXACC pair timestamps an AF_UNIX connect() and the matching
accept(), which is how the ~37 s wayland connect->accept gap behind KDE's
slow session startup was measured. Both were gated behind `syscall-trace`.

That gate makes the measurement unusable: `syscall-trace` writes EVERY
syscall to the SYNCHRONOUS serial console, so it inflates the very latency
these two lines exist to measure. It is not a small effect — a boot built
with it did not even reach kcminit's connect before a 30-minute window
expired, so the traced run produced no measurement at all.

`unix-latency-trace` enables just the two timestamps, leaving the boot
otherwise quiet, so the gap can be measured without the instrument
dominating it.

The diagnostics themselves are unchanged; only their cfg is widened to
`any(syscall-trace, unix-latency-trace)`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A compositor's wayland-0 listener is a server parked in ppoll/epoll_wait
with an INFINITE timeout. Nothing guarded the two halves that make such a
park terminate on a peer's connect():

  1. connect() must bump the readiness generation — the wake channel that
     breaks an infinite park out of its re-park loop. Without it the
     poller only ever re-scans off the ~10 ms backstop, and before that
     backstop existed, never (the weston "listener never serves" class).
  2. the poll scan a woken poller re-executes must flip the listener
     0 -> POLLIN once a connection is pending.

The test also pins the negative: an idle listener must NOT report POLLIN,
or a parked server degenerates into an accept/EAGAIN spin.

Red-verified by commenting out the notify(0) in the Connect path:
  [FAIL] smoke_abi_socket_unix_listener_connect_wakes_parked_poller:
         connect() did not publish a readiness wake for a parked listener
and green with it restored, sole failure either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l set

The UNIXENQ/UNIXACC pair says a connect() waited 80 s for its accept().
It cannot say WHY, and the three causes want opposite fixes: the acceptor
never asked about the listener (its own event loop), the scan was asked
and answered wrong (ours), or the acceptor is parked and never re-fired
(a lost wake). Under `unix-latency-trace` this now reports, every 2 s for
any listener holding an unaccepted connection:

  UNIXPEND        queue depth + the listener's own poll readiness + the
                  fd and tid listen() was called on
  UNIXPEND-OWNER  that task's park state
  PARKREP/PARKFDS every parked task, with comm and its decoded poll fd
                  set, every 10 s

`scans` is the discriminator: a healthy parked poller re-executes its
syscall on a 1 ms deadline, so it climbs. Frozen with parked=1 is a park
that never re-fires.

Two placement constraints, both learned by losing a boot to them:

* Ahead of `stall_wd`'s DUMPED gate. That gate latches on the first dump
  of the boot — an early RCU stall trips it around t+25 s — after which
  every later check in `tick`, INCLUDING the wc==180 park census, is dead
  for the rest of the run. A process that freezes minutes into a desktop
  session is invisible behind it.
* The listener sweep alone is not enough: a compositor can go flat before
  any client connects, and then nothing is pending to report. The census
  covers that gap.

Every lock in the sweep is try_lock — it runs in the timer trap, which
can interrupt a CPU already holding any of them, and a skipped sample
beats deadlocking the machine under observation. It does allocate and
hold Arc<Task> clones there, which the task-lifetime rules tell IRQ paths
not to do; that is safe only because TASKS holds a ref for everything
listed, and it compiles out entirely without the feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PARKREP could say a task was in `wait4` but not what for, which is the
whole question when a compositor stops serving. Two additions under
`unix-latency-trace`:

* `wantpid=` — the target from `wait_child_want_pid`, which the park path
  already records. >0 is a specific pid, -1 any child.
* `PROCREP` — a full task roster every ~30 s. The park census only lists
  PARKED tasks, so a child that is RUNNING is invisible to it, and
  `wantpid=N` has nothing to resolve against.

The parent field is `pptid`, not `ppid`, and the name matters: `PARENT_OF`
is keyed by the child's visible PID but stores the parent's TID
(`parent_of_set(child_visible_pid, current_task_id())`). Read as a pid it
yields a plausible-looking wrong process tree — systemd appears as parent
"14", which is its tid.

This resolves the chain behind the 67-162 s first-wayland-client accept:

  systemd --user (10) -> (sd-exec-strv) (31) -> kwin_wayland_wrapper (33)
    -> kwin_wayland (35, tid 376 = MAIN thread) --wait4(27)--> plasma-keyboard

`plasma-keyboard` is alive and healthy — parked in poll on one fd with
`scans` climbing 7171 -> 18482 — and simply never exits. kwin's main
thread blocks on it, so kwin never reaches its wayland event loop and
nothing polls the listener.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`PIDFD_TABLE` maps pid → shared exit state and was never GC'd, while
pids are reusable — and NARF hands out the LOWEST free pid, so the
number a new process gets is typically the one most recently freed.
`mint_for` therefore returned the PREVIOUS occupant's state, already
`exited = true`, and the new process's pidfd was born POLLIN-readable.

What that cost: Qt's `forkfd` watches a child through its pidfd and, on
POLLIN, calls `waitid(P_PIDFD, ., WEXITED)` with NO `WNOHANG` to collect
the status. A pidfd readable while its process is alive turns that
collection into an unbounded block. kwin's main thread sat in `wait4` on
a live `plasma-keyboard` (its own input method), never reached its
Wayland event loop, and every client's `connect()` to
`/run/user/1000/wayland-0` went unaccepted for 67-162 s — measured
across four boots, against a 3-10 ms control on systemd's own socket in
the same boots.

Invalidate in `release_pid`, the single point where the number goes back
to the pool. Existing `Arc` holders are deliberately untouched: a pidfd
opened against the OLD process must keep reporting THAT process's exit.
Only the lookup path for new mints is cleared.

Test: `smoke_pidfd_recycled_pid_does_not_inherit_exit`
(`userspace/src/process_e2e_tests.rs`, subsystem `userspace/process`).
Red without the invalidation ("recycled pid's pidfd born readable —
stale exit state"), green with it, sole failure either way. It pins both
directions — the recycled pid must NOT be born readable, and the old fd
must still report the old process's exit — because clearing too much
trades a false "exited" for a lost one.

The test's pid must sit inside 1..=PID_MAX; `release_pid` rejects
anything outside that range, so an out-of-range constant silently skips
the invalidation under test. The neighbouring pidfd smokes use
0xA110/0xDEAD/0xB055, all above PID_MAX, because they never release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
libwayland's shape: `wl_event_loop` owns an epoll holding the display
socket and hands its fd to the toolkit's main loop, which `poll(2)`s it
alongside everything else. A client's `connect()` therefore has to travel
listener -> epoll ready-list -> the outer poll over the epoll fd, and a
break anywhere on that chain looks identical from outside — the
compositor simply never accepts.

Both trigger modes, deliberately. Level is a plain "is the ready list
non-empty" query; EPOLLET adds the edge bookkeeping (`last_mask` /
`poll_edge_token`) that `EpollInstance::poll_readiness` has to mirror
from `collect_ready`, and a listener's edge comes from
`listener_readable_token`, which only the enqueue in `connect()`
advances.

Asserts the whole chain, including two things a positive-only test would
miss: an idle listener must NOT make the epoll fd readable (or the outer
loop spins on an epoll_wait that returns nothing), and the epoll_wait the
woken loop then runs must AGREE with the outer poll — disagreement is
worse than a miss, because it spins hot instead of sleeping.

Written to test a suspect for kwin's 44 s first-accept delay. It passes,
which retired that theory; the delay is kwin burning ~41 s of user CPU in
ld.so symbol resolution. The test earns its place anyway — nothing else
guarded this path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The park census can say a process is running rather than stalled. It
cannot say what it is running, and "kwin burns 41 s of user CPU before it
serves its first client" is exactly where that ran out.

Adds, under `unix-latency-trace`:

* A user-mode sampling profiler. The timer trap already captures the
  interrupted RIP; bucketing it whenever the tick landed in CPL 3 on the
  target task is a profiler for free. Fixed-size, open-addressed,
  alloc-free — it runs in IRQ context, where NARF forbids allocation —
  and it reports dropped samples rather than growing. Non-target cost is
  one relaxed load. `PROFTOP` prints the top 8 with percentages.
  Symbolize offline: these are raw addresses, and the KDE work resolves
  them against INTERP_BIAS 0x4000_0000_0000.

* `d_fault`/`d_sysc`/`d_ctx`/`d_utick`/`d_ktick` deltas on each report.
  `d_utick`/`d_ktick` is a CPL-SAMPLED user/kernel split, which matters
  because `TASK_KERN_NS` is dead: the fold in `dispatch` is skipped
  whenever the syscall parked, and under the own-stack executor that is
  nearly always, so every task reads `kms=0` and /proc stime is ~0.
  Sampling cannot be defeated that way. `d_fault` separates a task
  executing from one thrashing on demand-paged mappings — `do_lookup_x`
  walking a symbol table it must fault in page by page looks exactly like
  `do_lookup_x` doing arithmetic until you count faults.

* `PROCARGV` (how a process was actually invoked — `comm` cannot tell a
  long-lived input method from a one-shot query), `waitid=`/`waitopts=`
  on `PARKREP`, per-fd file TYPE in `PARKFDS`, and `ums=`/`kms=` per task.

The fd type comes from `stat().file_type`, NOT `type_name_of_val(&*ops)`:
`ops` is a `dyn FileOps`, so that returns the TRAIT name and every fd
reads back "FileOps". `/proc/<pid>/fd`'s `anon_inode:[…]` link has the
same defect for the same reason.

Every new lookup is try_lock (`try_with_table`, `proc_argv_of_task_try`,
`cpu_split_ns_try`, `parent_of_get_try`): these run in the timer trap,
which can interrupt a CPU already holding any of them, and a skipped
sample beats deadlocking the machine under observation. `try_with_table`
also never CREATES a table — a probe that materialises what it inspects
is not a probe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cycles_per_ns()` is `hz / 1e9` clamped to 1..=6 — a TRUNCATED integer
GHz. This host calibrates to 3293 MHz and it read back as 3, so every
`ns * cpns` / `cycles / cpns` in the tree was ~9.8% wrong.

The exact mult/shift scale already existed (added when the same
truncation made userspace's monotonic clock run 10-20% fast). The lossy
accessor simply outlived it, and ~45 sites across 25 files still did the
arithmetic by hand. All of them now go through `ns_to_cycles` /
`cycles_to_ns`: the LAPIC period and HPET pump, the scheduler's idle-park
slice, halt-poll window and idle accounting, the userspace deadline
parks, TCP's RTO/keepalive/persist/TIME_WAIT/delayed-ACK timers and RTT
measurement, i8042 + mouse deadlines, rtlwifi's ten busy-waits, USB,
virtio-net, e1000, both Intel GPU paths, memfs/overlayfs/statx/procfs
mtime, and the clockevent probe.

The most load-bearing was `arm_periodic`: the TSC-deadline period is
`period_ns * cpns`, so the system tick was armed ~9.8% long.

`cycles_per_ns()` survives only where a coarse scalar RATE is genuinely
stored — TCP congestion control keeps one per loss epoch behind a trait
signature — and a couple of status lines. Its doc now says plainly that
it must not be used for conversions, and lists the three separate bugs
this truncation has caused.

Test: `smoke_clock_ns_to_cycles_fixed_point_accuracy` (subsystem `time`)
pins the ns->cycles direction, which is the one that sets the tick rate;
the existing test only covered cycles->ns. Red-verified by restoring the
truncated form ("3.293 GHz ns->cyc off by >1 ppm (truncated integer
rate?)"), sole failure. The 1 ppm bound is deliberately tight enough that
the truncated answer — 3_000_000 cycles against a correct 3_293_000 —
misses by ~89000x.

HONESTY NOTE on what this does NOT fix. I came here from a reading that
the tick rate was ~19x too fast. That reading was wrong: `d_ktick` counts
timer INTERRUPTS, which include every early arm the timer wheel requests
for a parked task's ~1 ms re-poll. Measured properly afterwards
(`apic::timer_ticks()`, which only counts genuine periodic expiry, over a
MEASURED interval), the rate is ~1000 Hz per active CPU — correct, and
correct before this commit too. This fixes a real 9.8% error on its own
merits; it is not the cause of anything else under investigation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bad readings came out of dividing a counter by an assumed window, and
both got as far as a stated conclusion:

* `d_ktick` (perf) counts timer INTERRUPTS, not periodic ticks. The timer
  wheel arms the LAPIC early for every parked task's ~1 ms re-poll, so it
  runs far above the configured rate BY DESIGN. Read as a tick rate it
  said "19x too fast".
* the boot's `clockevent: … (probe: N ticks)` line is an ABSOLUTE counter
  sampled at whatever moment `probe_fires` returned — and that function
  returns on the FIRST tick it observes, so dividing N by the 50 ms probe
  window is meaningless. Read as a rate it said "59x too fast".

`TICKRATE` reports `apic::timer_ticks()` — which `on_timer_tick` bumps
only on genuine periodic expiry (`now >= periodic_next`) — over an
interval taken from `monotonic_ns()` rather than assumed from the sweep
cadence. Both halves matter: the right counter, and a measured window.

It reads ~6000 Hz aggregate with 16 CPUs online. Idle CPUs halt and do
not tick, so that is ~6 active CPUs at ~1000 Hz each — exactly what
`select_primary` requests. The tick rate was never wrong.

It also settles the thing that IS real: ~328k timer interrupts/s against
~6k periodic ticks/s, a 55:1 ratio of wheel-driven early arms, each
paying a full trap entry/exit plus `on_timer_tick` plus the watchdog.
That is now measured on both sides rather than inferred from one.

Also gates the profiler statics behind `unix-latency-trace`; the fns were
gated but the statics were not, so a default build carried 7 dead-code
warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three failures my own gates had missed, all self-inflicted:

* `abi_fdio2_tests.rs` — undocumented unsafe block on the test Waker
  (`clippy::undocumented_unsafe_blocks`). Added the SAFETY note: the
  vtable's fn pointers are 'static and the null data pointer is never
  dereferenced, which is what `Waker::from_raw` requires.
* `abi_socket_tests.rs` — `clippy::doc_lazy_continuation`: prose
  following a numbered list needs a blank line or it is parsed as a list
  continuation.
* `userspace/src/tests/namespaces.rs` — three calls passed `Accessor` by
  VALUE to `posix_access_ok`, which took `&Accessor` since the
  supplementary-groups change (a5de0e9) gave `Accessor` a `Vec` field.

The third is the one worth learning from. It is a hard compile error, and
it survived because every gate I had been running used `cgroup-all`
WITHOUT `container`, so the file was never compiled. Feature-gated test
modules do not exist until their feature is on; a green clippy proves
nothing about the configurations it did not build. The CI script runs
`--features cgroup-all,container,linux-compat` — match it.

Verified by exit code, not by reading output: clippy is now 0 for all
four CI invocations (x86_64 + aarch64, boot-smoke + kernel-test), and the
container build compiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`smoke_cgroup_events_emits_inotify_modify` passed alone (50/50) and failed
in the full suite. It watched the GLOBAL ROOT's `cgroup.events` for the
ancestor edge — but `populated` means "this cgroup OR ANY DESCENDANT has a
live process", so once any earlier test leaves a pid attached anywhere in
the tree, the root's value genuinely does not transition. Not emitting
IN_MODIFY is then correct Linux behaviour, and the test was asserting an
edge the kernel is right not to produce.

It now builds its own `t_evt_p/t_evt` hierarchy and watches the
intermediate cgroup, so the ancestor transition is guaranteed regardless
of what else is in the tree.

NOT a weakened assertion — the implementation is untouched (`git diff` on
`cgroupfs/mod.rs` is empty) and both halves were red-verified against the
code they guard:

  ancestor walk (`p.notify_events()`) disabled
    -> "ancestor cgroup.events did not get IN_MODIFY"
  leaf `crate::notify_modify(&path)` disabled
    -> "no IN_MODIFY on t_evt/cgroup.events when the cgroup became populated"

PRE-EXISTING, not a regression from this branch. Verified rather than
assumed: main with only the `container` compile fix applied runs
`6971 pass, 1 fail` — same test, same message. This branch was
`6984 pass, 1 fail`; it is now `6986 pass, 0 fail`.

Also corrects the framing of ba111be, which called all three failures it
touched "self-inflicted". The `posix_access_ok(&Accessor)` compile break
was NOT mine — main does not build with `--features container` at all,
since the supplementary-groups change (a5de0e9, PR #126) changed the
signature and never updated the feature-gated test file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ru_stime` / `tms_stime` / `/proc` stat field 15 all read one accumulator,
and it was ALWAYS ZERO for every task on the system.

`dispatch` bracketed the whole syscall with two monotonic reads, then
discarded the result whenever the syscall had parked — correctly refusing
to bill arbitrary off-CPU sleep as CPU time. But under the own-stack
executor nearly every syscall parks at least once, so the fold almost
never ran. Measured on a full desktop boot: `kms=0` for all ~70 tasks,
kwin and journald included.

Fixed by splitting the bracket instead of discarding it. `UserTaskCtx`
gains `kern_span_start_ns`; the span is opened at dispatch entry, CLOSED
just before `yield_current_stackful()` and re-opened on resume, and closed
again at dispatch exit. What accumulates is on-CPU time and never the
sleep, so there is nothing left to throw away. The longjmp park paths,
which never reached the old fold at all, now at least get their pre-park
work counted.

Test: `smoke_kernel_time_accumulates_on_cpu_only`
(`userspace/src/process_e2e_tests.rs`, subsystem `userspace/process`).
Both halves are asserted, because fixing only the first is easy and wrong
— simply not skipping the fold would bill the sleep, which is a worse lie
than zero. Red-verified in both directions: with the span never re-opened
(pre-fix shape) it reports "the gap between close and open was billed as
CPU time"; with the emission removed it reports no accumulation.

Deliberately tested against the span helpers with a REAL `UserTaskCtx`
rather than through the ABI harness. That harness installs a task ID but
no `UserTaskCtx`, so `current_user_task()` is `None` and the accounting is
skipped entirely — my first attempt lived there and passed for the wrong
reason regardless of which way the bug went. Same shape as the
lower-layer-through-a-stricter-upper-layer trap.

Full suite: 6986 pass, 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7b6ab22b (mine) turned the JIT on, reasoning that mprotect's W^X arm
returns `NeedsCapJit` and `jit_cap_default_policy()` grants a JitCap by
default, so the RW->RX flip no longer EINVALs. That was never validated,
and it is wrong about WHICH transition Qt asks for.

JavaScriptCore's `ExecutableAllocator::makeWritable` wants a W|X END
STATE. `mprotect_core` refuses that for ANY task — the capability gates
the RW->RX *flip*, and per its own comment "nothing grants a W|X end
state". So the cap being granted by default is irrelevant to the call Qt
actually makes.

Measured, from one fault dump:

  kwin_wayland_wrapper: mprotect failed in
                        ExecutableAllocator::makeWritable: Invalid argument
  fatal-fault: comm=plasma-keyboard sig=11 #PF faultva=10 rax=0 r12=0 r14=0

The allocator returns NULL and the input method segfaults dereferencing it
at +0x10.

NOT VERIFIED, deliberately stated: the crash signature is absent from the
boot after this change, but a boot BEFORE it (boot18) also had zero fatal
faults with the JIT still enabled. That is n=1 either side against a
baseline this bring-up has repeatedly shown to be highly variable, so the
mechanism is what justifies this commit, not the A/B. Do not re-enable
without an A/B that actually shows plasma-keyboard surviving across
several boots.

NARF's refusal of W|X is a deliberate design position (see the comment
block in `mprotect_core`), not an oversight to route around.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A udev worker sits in `epoll_wait(-1)` on its worker socket waiting for
udevd to hand it a device event. Measured on a real boot, those workers
park with `checks` CLIMBING — the park re-fires and the scan re-runs — and
still never see a message, until udevd is stuck at "18 children at max",
`/run/udev/data` stays empty, no device gets a `seat` tag, libinput
enumerates nothing, and kwin advertises `wl_seat capabilities(2)` =
keyboard only. No pointer reaches any client.

The neighbouring `..._epoll_level_redelivers_partial_read` already covers
the connected-pair SCAN — `epoll_wait` with timeout 0, asking "is it ready
now". That cannot catch a missing wake: a fresh scan finds the data
whether or not anything was ever notified. So both halves are asserted
here, as on the listener test:

  1. `send()` must bump the readiness generation — the channel that breaks
     an infinite park out of its re-park loop.
  2. the re-executed scan must then report the fd ready.

Half 1 is invisible to every timeout-0 epoll test in the file.

Red-verified against the code it guards: disabling the `n > 0` notify in
`do_send` gives "send() published no readiness wake for a parked epoll
waiter", sole failure; green with it restored, and socket.rs back to
unmodified.

It PASSES, which is itself the result: the connected-pair wake path is
healthy, so the udev worker hang is NOT this. Landed anyway — the
invariant was unguarded, and ruling this out is what the next step needs.

Full suite: 6987 pass, 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A PTY has TWO end-of-stream conditions and NARF implemented neither.
`drivers/tty/pty.c` `pty_close` sets `TTY_OTHER_CLOSED` on the peer
whichever side closes, and the MASTER's close additionally
`tty_vhangup(tty->link)`s the slave — so the two sides end differently:

  | who closed | peer's read | peer's poll |
  |------------|-------------|-------------|
  | last slave | EIO         | POLLHUP     |
  | master     | 0 / EOF     | POLLHUP     |

  * EIO — `n_tty.c` `n_tty_wait_for_input`:
    `if (test_bit(TTY_OTHER_CLOSED, &tty->flags)) return -EIO;`
    Checked in the WAIT path, so queued bytes drain FIRST.
  * 0/EOF — `tty_io.c` `tty_read` via `tty_hung_up_p()`. That EOF is how a
    shell whose terminal vanished exits; EIO is how a terminal learns its
    child is gone. Swapping them wedges one side or the other.
  * POLLHUP — `n_tty.c` `n_tty_poll`:
    `if (test_bit(TTY_OTHER_CLOSED, &tty->flags)) mask |= EPOLLHUP;`
    An event loop never issues a bare blocking read, so without the HUP bit
    it simply never wakes to learn the peer is gone.
  * Re-opening a slave CLEARS the condition (`pty.c` clears the bit on
    open); the open counter reproduces that.

8a253287 fixed the opposite bug — an empty master read reporting a phantom
EOF, which left `foot` blank — but overshot into blocking through a REAL
hangup. Caught by the ptyspawn smoke: its child failed to exec, every
slave fd closed, and the parent's master read parked forever.

Also wires that smoke up. It had been compiled into the KDE image since
6-Aug with NOTHING running it, which is why this class kept resurfacing.
Registration needs FOUR points and missing any one fails silently:
`verification/build.rs` (compile, hence the `{name}_x86_64.c` rename),
`verification/src/lib.rs` (`define_smoke_elf!`), `frame/src/bare_main.rs`
(staging into the guest `/bin`), and the xtask musl-demo expect table.
It now execs ITSELF rather than `/bin/sh` — with `/bin/sh` it passed in
the distro image and hung in NARF's native one, measuring the environment
instead of the PTY.

Tests:
  * `smoke_pty_hangup_matrix_matches_linux` (filesystem/devfs) — every
    cell, both directions, plus the negatives: a master read BEFORE any
    slave opens must still wait (the phantom EOF), queued bytes must drain
    before the hangup, and a re-opened slave must clear it. Red-verified
    three ways: reverting the master predicate gives "master read still
    blocks after the last slave closed", reverting the slave predicate
    gives "slave read still blocks after the master closed", dropping the
    HUP bit gives "master poll did not set POLLHUP".
  * `smoke_userspace_pty_slave_as_stdout_reaches_master` (userspace) — the
    fd/syscall layer the 31 object-level tests never covered: slave
    installed as an fd, written via `sys_write`, read back via `sys_read`
    on the master. Includes the `unlockpt` a real terminal issues between
    openpt and fork.
  * `ptyspawn_smoke` — real fork + setsid + dup2 + execve, gated in CI.

Full suite: 6989 pass, 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…et_tty)

Task #11: an interactive shell inside `foot` never printed a prompt. The
window rendered, foot logged no spawn error, and the image's own probe had
already proved `/bin/sh -c` works end-to-end on a NARF pty — so the PTY
layer was not the suspect.

Measured with a fresh probe that execs `/bin/bash` as `-bash` on its own pty:

  ISH: pts=/dev/pts/2
  ISH: shell emitted 0 bytes [<NOTHING>]
  ISH: child w=0 exited=1 code=0

`w=0` from waitpid(WNOHANG) means NO state change: bash was still ALIVE,
having written nothing. (The `exited=1 code=0` is an artifact of `st` never
being written; WIFEXITED(0) is trivially true. Reading that as a clean exit
sends you hunting the wrong bug.)

Cause. PtySlave::ioctl(TIOCSCTTY) only recorded the ctty index in the
per-task CTTY table and left `fg_pgrp` at 0. Linux does both halves at once
-- drivers/tty/tty_jobctrl.c __proc_set_tty():

    tty->ctrl.pgrp    = get_pid(task_pgrp(current));
    tty->ctrl.session = get_pid(task_session(current));

With fg_pgrp 0, tcgetpgrp() answers 0, which never equals the shell's own
pgrp, so bash's initialize_job_control loop

    while ((terminal_pgrp = tcgetpgrp (shell_tty)) != -1) {
        if (shell_pgrp != terminal_pgrp) { /* SIG_DFL */ kill (0, SIGTTIN); continue; }

signals ITSELF with SIGTTIN -- default action: stop. Hence a live shell,
zero bytes, forever: the blank foot window.

Fix: TIOCSCTTY performs the full __proc_set_tty, storing session and
foreground pgrp. Adds TIOCGSID on the slave (a defined constant with no
handler until now), including Linux tiocgsid's ENOTTY-while-sessionless.
`sid` gets its real meaning as tty->ctrl.session; the never-read `pgid`
field is deleted rather than left as decoration.

BOTH values cross into userspace in the VISIBLE-pid space, via the new
`current_task_pgid_user()`. My first cut returned `current_task_sid_user()`
paired with the RAW `current_task_pgid()` -- the exact divergence the note
above `pgid_to_user` records as having "hung getty at the login read" by
making a foreground leader read as background. A half-translated pair is
the easy mistake when one value in a tuple is already correct.

Test asserts the shell's own convergence condition, tcgetpgrp(slave) ==
getpgrp(), not "fg_pgrp is nonzero" -- a nonzero-but-wrong pgrp hangs bash
exactly as hard, and only the former looks right in a dump. Plus the
negative: TIOCGSID is ENOTTY before any acquisition. Red pre-fix on the
tcgetpgrp()==0 branch.

NOT YET BOOT-VERIFIED that a prompt appears in foot; suite is green
(6895 pass, 0 fail) and the mechanism is source-matched, but the KDE boot
check is still outstanding.

LINUX-GAP: open(2)-time ctty acquisition (Linux tty_open_proc_set_tty, for
a session leader opening a slave without O_NOCTTY) is still unimplemented --
DirOps::lookup carries no open flags. Programs using login_tty()/TIOCSCTTY,
which is what foot does, are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s it

Task #12: the mouse is dead in Plasma because /run/udev/data is empty, so no
input device carries a `seat` TAG, so libinput's udev backend enumerates ZERO
devices, so the Wayland seat advertises capabilities(2). That reads like
"keyboard works, mouse doesn't" but it is not: KWin always advertises a
keyboard from its own xkb state, with or without hardware. 2 is the signature
of libinput finding nothing at all.

Root cause, named by udevd itself once it was run in the foreground with
--debug (journald has no storage here, so its log was otherwise unreachable):

  sd-device-monitor(manager): Failed to send device to netlink monitor:
      Operation not supported
  Failed to broadcast event (SEQNUM=23) to libudev listeners: Operation not
      supported
  event0: Worker [20] exited with return code 1.
  Event loop failed: Operation not supported

After a worker processes a device, udevd broadcasts the result to its libudev
listeners on the UDEV_MONITOR_UDEV multicast group. `send_netlink_user`
answered any nl_groups != 0 with NotSupported, reasoning that "userspace
multicast requires authority NARF does not grant through uid/capability
emulation". That is not a slow path -- it KILLS udevd's event loop, which
accounts for every symptom at once: workers exiting 1, the queue never
draining, an empty db, NRestarts=1, and 18 accumulated children.

Linux does not refuse this. `netlink_sendmsg` gates multicast on
`netlink_allowed(sock, NL_CFG_F_NONROOT_SEND)` and fails with EPERM, not
EOPNOTSUPP; udevd holds CAP_NET_ADMIN and is allowed. So the old behaviour was
both the wrong errno and the wrong answer for the one sender that matters.

Fix: `broadcast_netlink_user` derives the group number with Linux's ffs()
(lowest set bit of the nl_groups mask, 1-based), delivers a copy to every
same-protocol socket whose netlink_memberships contains it, skips the sender
(Linux's `sk == ssk` exclusion in do_one_broadcast), and wakes parked pollers.
Delivery is best-effort: netlink_sendmsg ignores netlink_broadcast's return, so
a broadcast with no subscribers still succeeds -- erroring on an empty listener
set would fail udevd whenever nothing happened to be listening yet.

Delivering the BYTES is not sufficient, and this is the trap worth recording.
libudev's `device_monitor_receive_device` treats a message arriving with
nl_groups == 0 as an untrusted UNICAST and discards it. recv_netlink_user
hardcoded 0, so a fix that only moved payloads would have passed a delivery
test while leaving udev exactly as broken. Packets now carry their originating
group and report it as Linux does via netlink_group_mask() in netlink_recvmsg.

Tests pin the properties that actually broke, not "bytes moved":
 * positive: a group subscriber receives the broadcast, with an explicit arm
   for the exact pre-fix SockError::NotSupported, and asserts the reported
   nl_groups is the group mask -- with a distinct message for the group-0 case
   that would silently break libudev.
 * negative: a different-group subscriber, a different-protocol socket, and
   the SENDER ITSELF (joined to the very group it broadcasts to) must all
   receive nothing, so "deliver to every netlink socket" cannot pass.

PROCESS NOTE: I read this exact branch early in the investigation and dismissed
it. I was tracing the unicast direction (udevd->worker), confirmed it worked,
and noted the `destination.1 != 0` arm without asking who ELSE sends. The
broadcast direction -- udevd->libudev listeners, which is what libinput and
KWin subscribe to -- never entered the hypothesis set, and four wrong theories
followed. What broke the loop was making the daemon report its own failure
instead of inferring it from kernel counters. Relatedly, the previously
recorded root cause ("udev workers wedge in epoll_wait") was unsound: it rested
on scans=0, but dbg_poll_scans is only ever incremented on the blocking poll(2)
path and those tasks were in epoll_wait, so that zero was structurally
guaranteed and carried no information.

Also fixes a doc_lazy_continuation clippy error in abi_socket_tests.rs that
dc43823 (mine, this session) landed with -- it ran xtask test but not the full
local CI. All gates verified by EXIT CODE: fmt 0; clippy 0 on x86_64 and
aarch64 for both boot-smoke and kernel-test; xtask test 0 with 6897 pass /
0 fail (+2, exactly the new tests, so they ran rather than silently skipped).

NOT YET BOOT-VERIFIED that /run/udev/data populates and the seat gains its
pointer bit; that check is next and is the claim that actually matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…reopen

Second blocker on task #12, uncovered only because 1810e2a0 let udevd's event
loop survive long enough to reach node-permission application:

  event1: Failed to set owner/mode of /dev/input/event1 to uid=0, gid=104,
          mode=0660: Operation not supported

udev applies GROUP="input", MODE="0660" to every evdev node
(50-udev-default.rules). `InputEventFile` hardcoded perms 0o660 in stat() and
implemented neither owners() nor set_owners()/set_perms(), so the trait
defaults returned Unsupported and the node stayed root:root.

That reads as cosmetic -- the mode was ALREADY 0660, so a glance at the node
says "correct". It is not: 0660 root:root cannot be opened by a compositor
running as uid 1000. This is the same failure shape as the DRM-node EACCES
that supplementary-group DAC support was added for; the group is the half
that matters and the half that was missing.

The state cannot live on InputEventFile. Every open("/dev/input/eventN")
constructs a fresh one (the type's own docs say so), so a chown recorded on
the instance would vanish with udev's fd and be invisible to the compositor's
later open -- while still passing any same-handle read-back. Hence a shared
table keyed by event number, which is the inode state Linux keeps.

Tests assert the property that actually broke:
 * chown/chmod are applied, then read back through a SECOND, freshly-opened
   handle -- instance-local state would pass a same-handle check and still
   leave the desktop broken. Also pins the pre-udev default (root:root 0660)
   so "it was always 0660" cannot mask a regression.
 * device ids are never recycled. EVDEV_NODE_META never removes entries,
   which is safe ONLY because narf-input allocates ids monotonically. That
   invariant lives in ANOTHER crate, where a future free-list would silently
   hand a new device the old one's permissions while a comment here kept
   looking correct. Encoded as a test rather than a comment.

Gates by exit code: fmt 0; clippy 0 on x86_64 + aarch64 for boot-smoke and
kernel-test; xtask test 0 with 6899 pass / 0 fail (+2, exactly the new tests).
Clippy first rejected the raw Vec<(u32,(u32,u32,u16))> for type_complexity;
the named EvdevNodeMeta reads better anyway.

Boot evidence that 1810e2a0 works, from the same run that surfaced this:
"Failed to broadcast event ... to libudev listeners", "Failed to send device
to netlink monitor", "Event loop failed", and "Worker [N] exited with return
code 1" are ALL now absent, and udevd forks a worker per seqnum and processes
input0..input3 in order. Those are warning/error level, so their absence is
evidence rather than an artifact of quieter logging.

STILL NOT VERIFIED: that /run/udev/data populates, that the `seat` tag lands,
and that the Wayland seat gains its pointer bit. The verification probe
produced no output because the chain pipes it through sed, which block-buffers
and lost everything when the service was killed; that harness bug is next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ountinfo

Task #20. /proc/mounts and /proc/<pid>/mountinfo answered from DIFFERENT
sources, so they disagreed about what was mounted. Measured in-guest on the
Fedora KDE boot:

  /proc/self/mountinfo (13):  / ext2, /dev devtmpfs, /tmp tmpfs, /run tmpfs,
                              /sys sysfs, /proc proc, /sys/fs/cgroup cgroup2,
                              /dev/shm tmpfs, /dev/pts devpts,
                              /sys/kernel/tracing tracefs,
                              /sys/kernel/debug debugfs,
                              /sys/fs/fuse/connections fusectl
  /proc/mounts (8):           MISSING /run, /dev/pts, tracing, debug, fusectl

Cause: `render_mountinfo` consults the per-namespace hook and falls back to
the global registry, while `gen_mounts` only ever read the global registry via
`list_with_names()` -- it was not namespace-aware at all. Every mount made
inside a private mount namespace (notably /run, mounted in the chroot by
9c60fa3) was therefore visible in one file and invisible in the other.

Linux keeps the two consistent by construction: `show_vfsmnt` and
`show_mountinfo` (fs/proc_namespace.c) walk the same mount list, and
/proc/mounts is /proc/self/mounts -- the CALLER's namespace. NARF's was
neither namespace-aware nor consistent with its sibling.

Why it matters beyond tidiness: consumers are split on which file they read.
libmount (systemd, udev, util-linux) prefers mountinfo; df and most shell
tools read /proc/mounts. A filesystem invisible in one can make a caller
conclude /run is not a mount point, skip a remount, or mis-resolve a path.

Fix: `ns_mounts_for(pid)` becomes the single source both paths use, and
gen_mounts resolves the caller via current_pid().

The test asserts through the NAMESPACE HOOK deliberately. With no hook
installed both sources collapse to the global registry and agree trivially --
exactly the configuration that never broke -- so a test written that way would
be green on the bug. It pins /run tmpfs and /dev/pts specifically, the entries
that actually went missing.

Found while investigating #12; NOT the cause of that (udevd's ENOENT survives
a directory that demonstrably exists and is writable). Landed separately
because it is a real divergence on its own.

Gates by exit code: fmt 0; clippy 0 on x86_64 + aarch64 for boot-smoke and
kernel-test; xtask test 0 with 6900 pass / 0 fail (+1, the new test).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task #12, fourth blocker. udev's `path_id` builtin failed outright:

  Builtin command 'path_id' fails: No such file or directory
  input1: 71-seat.rules:75 IMPORT{builtin}="path_id": Failed to run builtin
          "path_id": No such file or directory

path_id composes ID_PATH by walking a device's parents and classifying each by
its subsystem (systemd src/udev/udev-builtin-path_id.c):

    } else if (streq(subsys, "platform")) {
            path_prepend(&path, "platform-%s", sysname);
            supported_transport = true;
            supported_parent = true;

`/sys/devices/platform/narf-input` had NO `subsystem` symlink, and there was no
`/sys/bus/platform` for one to point at (/sys/bus held only pci and
event_source). So sd_device_get_subsystem() on the parent returned -ENOENT,
path_id composed nothing, and the builtin failed.

That is expensive because 71-seat.rules is the file that assigns the `seat`
TAG. libinput's udev backend enumerates by that tag; without it the Wayland
seat comes up capabilities(2) -- which reads as "keyboard works, mouse
doesn't" but actually means libinput found ZERO devices, since KWin advertises
a keyboard from its own xkb state regardless of hardware.

Linux gives every platform device `subsystem -> ../../../bus/platform` plus a
back-link under /sys/bus/platform/devices (drivers/base/bus.c bus_add_device);
both are added here, along with the parent's `uevent` attr, since Linux gives
every kobject one (drivers/base/core.c uevent_store) and `udevadm trigger`
writes "add" to each node it walks.

The test asserts the link TARGETS, not just their presence: a subsystem link
at the wrong depth resolves to nothing and fails identically while looking
correct in a directory listing.

How this was found: run the REAL systemd-started udevd's failing builtin
standalone (`udevadm test-builtin path_id <syspath>`) instead of reading the
per-device error salad. The three errors that appear together per device
(path_id ENOENT, "Failed to update database under /run/udev/data/" ENOENT,
"Failed to process device, ignoring" ENOENT) share one errno, and only the
first is a root cause -- the middle one reads like a filesystem write failure
but that directory demonstrably exists and a shell can write it.

NOT claimed: that this alone populates /run/udev/data. The db-id path
(device_get_device_id -> sd_device_get_subsystem) is a separate question and
the boot check is next.

Gates by exit code: fmt 0; clippy 0 on x86_64 + aarch64 for boot-smoke and
kernel-test; xtask test 0 with 6901 pass / 0 fail (+1, the new test).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every /dev/char/<major>:<minor>, /dev/disk/by-id/* and /dev/input/by-path/*
entry needs the same two steps: mkdir a subdirectory under /dev, then create a
symlink INSIDE it. Measured failure on the Fedora KDE boot (task #12):

  Failed to create symlink '/dev/char/226:0' to '/dev/dri/card0': No such file
      or directory

The two steps cross DIFFERENT DirOps impls -- DevDir::mkdir for the directory,
then DynamicDirectory::symlink for the entry within it -- which is the seam
where "the root supports it" stops implying "the child does". A test that only
asserted the mkdir, or only a symlink at the /dev root, would miss what udev
actually does.

Result: devfs handles the sequence correctly, so the /dev/char ENOENT comes
from above the filesystem layer, not from here. The test is kept as the
regression pin for that seam.

Note on how this test first went red: the final assertion used `lookup_async`
-- the FileOps lookup -- on a DIRECTORY, which legitimately NotFounds. That was
my error, not a kernel bug, and it is instructive: it is the same shape as the
resolve_async/lookup_dir_async gap that once made /dev/pts invisible to open
when an intermediate segment NotFounded. Corrected to assert through
`lookup_dir` (the call a resolver makes for a non-final path segment) and
`lookup` for the final one, keeping every meaningful step rather than dropping
the assertion.

xtask test 0 with 6902 pass / 0 fail (+1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t the opt-ins

Task #13 filed this as "EventFd::read returns Ok(0) on zero counter — phantom
EOF". Investigated: the bug as filed DOES NOT EXIST, and the Ok(0) is correct.

NARF has no FsError::WouldBlock, so "empty but open" is expressed at the
FileOps layer as Ok(0) PLUS the read_should_block()/nonblock_read_eagain()
opt-ins, and sys_read is what converts that into -EAGAIN or a park:

  sys_read.rs: if n == 0 && nonblock && entry.read_should_block() -> EAGAIN
               let should_block = n == 0 && !nonblock && entry.read_should_block()

EventFd already declares both (io_mux.rs: read_should_block() is exactly
`counter == 0`, nonblock_read_eagain() is true), and poll_readiness only sets
POLL_IN when the counter is non-zero. Linux agrees on the OBSERVABLE behaviour
-- fs/eventfd.c eventfd_read returns -EAGAIN on O_NONBLOCK with a zero count
and otherwise blocks; it never returns 0 -- which is what userspace sees here.

What DID exist is a coverage hole. smoke_io_mux_empty_reads_are_not_eof asserts
the two opt-ins on EventFd itself and says so in its own doc comment; it
deliberately never issues a syscall. So nothing proved sys_read still consults
them for this fd type: deleting the entry.read_should_block() check would leave
that test GREEN while handing userspace the phantom EOF back. That is the
lower-layer-tested-through-nothing gap, in the direction that is easy to miss.

This test drives the real syscall path (fd table + kernel_syscall_entry) and
asserts -EAGAIN, with a distinct failure message for the exact regression (a
bare 0). It also asserts the POSITIVE half on the SAME fd -- after a write, the
read must return 8 bytes with the right value -- so a version that always
reported EAGAIN cannot pass.

Why the EOF matters: a 0 from an eventfd tells an event loop its wakeup channel
closed. The same shape (spurious 0 on an O_NONBLOCK fd) previously killed the
KDE session bus through GLib's line-reader; sys_read carries that history in
the comment above its EAGAIN branch.

Verified the test RAN rather than being silently skipped ([run] then [ OK ] in
the suite output) — the pass total alone moved by more than this one test and I
cannot account for the difference, so the per-test line is the evidence.

Gates by exit code: fmt 0; clippy 0 on x86_64 + aarch64 (kernel-test);
xtask test 0 with 6912 pass / 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Linux's file ops signal "open but nothing to give" by returning -EAGAIN
directly: fs/eventfd.c::eventfd_read, fs/timerfd.c::timerfd_read,
fs/signalfd.c::signalfd_read. NARF instead returned Ok(0) and declared the
would-block out-of-band via a `read_should_block()` opt-in that the syscall
layer had to remember to consult.

That split is a bug factory, and not hypothetically. Ok(0) IS end-of-file for
every other file, so any consumer that does not also ask the second question
silently converts "nothing yet" into "the fd closed". A spurious 0 of exactly
this shape killed the KDE session bus through GLib's line-reader (the history
is recorded above sys_read's EAGAIN branch). And the split is invisible to
tests written at either layer alone: the existing io_mux test asserts the
opt-ins on the object and says in its own doc comment that it never issues a
syscall, so deleting the sys_read check would have left it green while
userspace got the phantom EOF back (5161ef0 closed that hole for eventfd).

This introduces FsError::WouldBlock -> EAGAIN and converts the three io_mux
types to return it. sys_read maps it to EAGAIN for an O_NONBLOCK caller and to
a park otherwise -- the same two outcomes as before, but derived from the file
op's own answer instead of a second, forgettable question.

`nonblock_read_eagain()` is NOT part of this and stays: it is a POLICY flag
(evdev nodes report EAGAIN even on a blocking fd, because libinput's
drain-to-EAGAIN loop cannot survive a blocking evdev fd), which is orthogonal
to signalling would-block.

MIGRATION IS PARTIAL AND DELIBERATELY SO. 3 of 16 read_should_block()
implementors are converted (EventFd, TimerFd, SignalFd). Still on the old
Ok(0) convention: pipe, fifo, socket, both PTY ends, both mqueue ends,
devfs_input, overlayfs, fuse_conn, linux_compat, compat.inc. sys_read handles
BOTH forms during the migration -- that is a transitional state, not a
permanent shim, and read_should_block() should be deleted once the list is
empty. Doing all sixteen in one commit would have been unverifiable; these
three are the ones #13 concerned and they boot-verify clean.

Verified: xtask test 0 (6912 pass, 0 fail); `cargo xtask boot-smoke` exit 0,
"kernel cleanly exited, no panic markers" -- these three fd types are
load-bearing for systemd, so a compile-and-suite pass alone would not have
been evidence. fmt 0; clippy 0 on x86_64 kernel-test and aarch64 boot-smoke.

NOT verified: a full Fedora/KDE distro boot with this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Continues the cutover started in 81392d2. 8 of 16 read_should_block()
implementors now signal would-block from the read itself, as Linux's file ops
do, instead of returning Ok(0) and leaving the classification to a separate
question the caller had to remember to ask.

Converted here: PipeRead, FifoEndpoint, SocketFile, PtyMaster, PtySlave. These
are the high-traffic ones — the paths GLib, dbus, and every shell actually hit.

Two conversions are strictly better than the hook they replace, not merely
equivalent:

* PIPE. The old `read_should_block()` had to be deliberately conservative
  because sys_read called read() and the hook under SEPARATE lock
  acquisitions, so a writer landing in between could turn arrived data into a
  spurious EOF; its comment says so. Deciding under the same lock that
  observed the empty queue removes the race rather than compensating for it.
* SOCKET. do_recv ALREADY returned SockError::WouldBlock for empty-but-open,
  and read() collapsed it to Ok(0) — throwing away an answer the layer had
  computed, so sys_read could re-derive it. Now it passes through.

sys_readv needed wiring too (it re-derives the same decision independently);
without it an O_NONBLOCK readv of an empty pipe stopped returning -EAGAIN.
That regression was caught by smoke_abi_fdio_pipe_readv_empty_eagain, which is
exactly why this landed in batches with a suite run between them.

THREE REAL BUGS the suite caught in my own conversion, all now fixed:
 1. readv unwired (above).
 2. PTY slave: `readable() != 0` counts BUFFERED bytes, but ICANON only
    releases a COMPLETED line — an incomplete line drains 0. Falling through
    to Ok(0) turned "no newline yet" into EOF, which kills an interactive
    shell at startup. Now would-block.
 3. Same fall-through for input consumed as a signal character (^C).

Tests updated rather than deleted, and re-pointed at the READ instead of the
opt-in — asserting `Some(Err(WouldBlock))` with a distinct failure message for
the Ok(0) case, so the exact regression is named if it returns. The one place
a 0 is still correct is preserved and still covered: a latched ^D EOF, and a
hung-up master/slave.

Verified: xtask test 0 (6912 pass, 0 fail — 4 failures caught and fixed en
route); `cargo xtask boot-smoke` exit 0, no panic markers; fmt 0; clippy 0 on
x86_64 kernel-test and aarch64 boot-smoke.

REMAINING (8 of 16 done): fuse_conn, overlayfs, devfs_input, mqueue x2,
linux_compat, compat.inc. sys_read/sys_readv still accept BOTH forms until
that list is empty, at which point read_should_block() should be deleted.
`nonblock_read_eagain()` is a separate POLICY flag and stays.

NOT verified: a full Fedora/KDE distro boot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replace remaining read_should_block consumers with FsError::WouldBlock and update syscall, splice, and direct FileOps behavior.

Co-Authored-By: OpenAI Codex GPT-5 <noreply@openai.com>
@hodgesds
hodgesds merged commit bbe1412 into main Aug 10, 2026
5 checks passed
@hodgesds
hodgesds deleted the kde-full-display branch August 10, 2026 23:54
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