Kde full display - #130
Merged
Merged
Conversation
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
force-pushed
the
kde-full-display
branch
from
August 10, 2026 23:54
a043518 to
74ac125
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.