Skip to content

Claude/labwc eclipse desktop 08f1ft - #414

Closed
Pryancito wants to merge 2330 commits into
rcore-os:masterfrom
Pryancito:claude/labwc-eclipse-desktop-08f1ft
Closed

Claude/labwc eclipse desktop 08f1ft#414
Pryancito wants to merge 2330 commits into
rcore-os:masterfrom
Pryancito:claude/labwc-eclipse-desktop-08f1ft

Conversation

@Pryancito

Copy link
Copy Markdown

No description provided.

Pryancito and others added 30 commits July 29, 2026 16:55
…8f1ft

feat(build): use Alpine's musl as the one loader so Alpine X binaries…
With the Alpine-musl loader in place, `mcookie` now creates the xauth cookie
and the Alpine Xorg binary actually runs — far enough to try opening its log
and abort fatally: "Cannot open log file /var/log/Xorg.0.log". The staged base
has no /var/log. Create it in the full rootfs (xorg staging) and in the minimal
live/installer root (build_live_rootfs), which is what boots under QEMU. Xorg
creates /tmp/.X11-unix itself at runtime, so only /var/log is needed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…8f1ft

fix(build): create /var/log so Xorg can open its logfile
The QEMU run logs `(EE) /usr/share/libinput: failed to find data files` and
falls back to degraded device behavior: the live-root tree list copied X11,
fonts and fontconfig but not `usr/share/libinput` (libinput's device-quirks
database) nor `etc/libinput`. Add both so libinput classifies input devices
correctly under `startx` in QEMU, matching the installed system.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…l/execve

Running X reparents exited grandchildren onto PID 1 / init, so init receives
their SIGCHLD and runs check_signals() on its own thread. If that thread has no
LinuxThread extension the old `thread.lock_linux()` unwrap-panicked
("init has no LinuxThread ext"), taking down the X session.

Make the two current-thread sites init actually hits tolerant, mirroring the
try_lock_linux pattern already used on the signal-*send* path:

- check_signals(): use try_lock_linux; a thread with no Linux ext has no Linux
  signal state, so nothing is pending and nothing interrupts -> return Ok.
- sys_execve(): guard the comm.clear() the same way; init re-execing carries no
  comm override to reset.

linux-object and linux-syscall both `cargo check` clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…8f1ft

Claude/labwc eclipse desktop 08f1ft
startx mints an MIT-MAGIC-COOKIE with mcookie, which busybox does not
provide, so the cookie comes out empty and the server is handed an
empty/absent -auth file. Every client is then rejected with
"Authorization required, but no authorization protocol specified", and
startx spins forever on "waiting for X server to begin accepting
connections"; the fork/exec churn from that retry loop eventually trips
an SMP process-teardown race in the kernel.

Ship a .xserverrc that launches the server with access control disabled
(-ac). startx still appends the display, its -auth <file> and the vt arg
as "$@", but -ac overrides the auth check so xterm/twm connect on the
first try. Fine for this single-user VM. Lands in the live root via the
existing LIVE_KEEP "root" copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…8f1ft

fix(x11): ship /root/.xserverrc with -ac so startx clients can connect
…group is set

xterm opens with a window but no shell prompt. Cause: xterm acquires its
pty as controlling terminal IMPLICITLY (setsid + first slave open, Linux
tty_open semantics) and never issues TIOCSCTTY, so the syscall layer's
explicit fg_pgrp seeding never fires and the pty's foreground group stays
0. tcgetpgrp() then reported the made-up constant 1, which can never equal
the shell's getpgrp(), so busybox ash's job-control init looped
killpg(0, SIGTTIN) forever -- shell alive, prompt never printed.
(foot/alacritty were unaffected: their login_tty() calls TIOCSCTTY.)

Fix: when fg_pgrp is unset, TIOCGPGRP falls back to the CALLING process's
effective pgrp -- the same fallback stdio's VT ioctl already uses -- so a
fresh shell's very first tcgetpgrp() sees itself as foreground and goes
straight to the prompt (its tcsetpgrp() then records the real value).
Applied to both pty implementations (fs::pty and devfs::pty).

Also teach devfs::pty to accept TCFLSH and TIOCNOTTY instead of returning
ENOTTY (fs::pty already did).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…8f1ft

fix(pty): report the caller's pgrp from TIOCGPGRP when no foreground …
…XFCE)

Now that startx yields a working X session, ship a real desktop on top:

xorg.rs (build-time apk stack):
- Add the XFCE4 components to the package set: xfce4-session, xfwm4,
  xfce4-panel, xfdesktop, xfce4-settings, xfconf, thunar, garcon,
  xfce4-terminal, xfce4-appfinder. Explicit list instead of the `xfce4`
  metapackage, which would drag in xfce4-pulseaudio-plugin and the whole
  audio stack, useless on this kernel. Plus dbus/dbus-x11 (xfce4-session
  aborts without a session bus) and adwaita-icon-theme (GTK fallback
  icons).
- Merge etc/xdg (xfce sysconfig defaults) and etc/dbus-1 from the staging
  root; create /var/lib/dbus; copy the XFCE/GTK data trees (xfce4, xfwm4,
  themes, icons, glib-2.0 schemas, dbus-1, mime, applications) into the
  live/QEMU initramfs so QEMU boots the same desktop.
- After the etc/xdg merge, re-assert Eclipse's xfconf defaults, which the
  staged Alpine ones would otherwise clobber.

desktop.rs:
- write_xfce_defaults: /etc/xdg xfconf channel with xfwm4 compositing OFF
  -- on the software ShadowFB every composited frame is a full-screen CPU
  blit and the desktop crawls.
- write_x11_prepare: /usr/local/bin/eclipse-x11-prepare regenerates at
  first boot the caches `apk --no-scripts` skipped (gdk-pixbuf
  loaders.cache, gschemas.compiled, dbus machine-id; icon/mime caches in
  background). Without the first two, GTK renders no images and aborts on
  GSettings access.
- .xinitrc: export XDG_RUNTIME_DIR, then prefer startxfce4 (wrapped in
  dbus-launch --exit-with-session) over the bare-WM/xterm fallbacks,
  which remain for xfce-less builds.

All three generated scripts pass `sh -n`; xtask compiles clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…8f1ft

feat(desktop): bake an XFCE4 desktop into the image (startx boots to …
…ve root

Two fixes for the XFCE session dying at startup with
"dbus-daemon: Failed to start message bus: Writing to pipe: Bad file
descriptor" and eclipse-x11-prepare never running:

1) FD_CLOEXEC was stored inside the File object. fork() clones the fd
   table as Arc clones, so parent and child SHARED each descriptor's
   close-on-exec flag: one process's fcntl(F_SETFD) (busybox sh does
   this constantly for redirection bookkeeping) retagged the other
   process's fd, and the execve CLOEXEC sweep then closed fds that
   should have survived exec -- e.g. the --print-address pipe dbus-launch
   hands to dbus-daemon. POSIX makes FD_CLOEXEC a property of the
   descriptor, not the open file description.

   Move it to a per-process HashSet<FileDesc> in LinuxProcessInner:
   - insert_file/replace_file register creation-time CLOEXEC from the
     freshly built object's flags (covers open/pipe2/socket/accept4/
     eventfd/epoll/signalfd/timerfd/inotify/memfd/pidfd with no per-site
     changes);
   - fcntl F_GETFD/F_SETFD, F_DUPFD_CLOEXEC and dup3(O_CLOEXEC) now read/
     write the set only;
   - fork copies the set (each process owns its flags thereafter);
   - the execve sweep drains the set instead of reading shared flags;
   - close/close_range/teardown unregister.

2) usr/local/bin was never copied into the live/QEMU initramfs (neither
   LIVE_KEEP nor the xorg live trees), so eclipse-x11-prepare and every
   desktop wrapper (eclipse-terminal, eclipse-firefox, labwc wrapper)
   were missing in QEMU. Add it to LIVE_KEEP.

linux-object, linux-syscall and xtask all cargo check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…8f1ft

fix(kernel): make FD_CLOEXEC per-descriptor; ship usr/local/bin in li…
dbus-launch fails with "Writing to pipe: Bad file descriptor" while
dbus-daemon --fork --print-address 1 works: the only extra ingredient is
the pipe fd crossing an execve. Log every pipe end the CLOEXEC sweep
closes (with the pre-exec execute_path and fd) so any recurrence after
the per-descriptor FD_CLOEXEC fix names the culprit directly on the
console instead of needing another deduction round.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…8f1ft

diag(kernel): log pipe fds closed by the execve CLOEXEC sweep
dbus-daemon dies with "Writing to pipe: Bad file descriptor" on the fd
dbus-launch hands it via --print-address, and the execve CLOEXEC sweep is
provably not the cause: the image that reproduces this carries the
[cloexec] sweep diagnostic and never logs a swept pipe.

write(2) can answer EBADF for two distinct reasons in this kernel and
they were being conflated:
  * the fd is absent from the process fd table (get_file_like), or
  * the fd is present but its access mode is not writable, which
    File::write_at reports as EBADF too -- e.g. an fd that ended up
    pointing at the read end of the pipe.

Log which one, with the process path plus (for the absent case) the list
of fds that ARE live, and (for the present case) the file's path and
flags. Error path only; the fast path is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…8f1ft

diag(kernel): name which EBADF a failing write(2) actually hit
dbus-launch cannot start a bus on this kernel: it asks dbus-daemon to
report the bus address back through an inherited pipe fd
(--print-address <fd>), and after dbus-launch's fork+exec that fd is
unusable -- the daemon dies with "Writing to pipe: Bad file descriptor"
and dbus-launch reports "EOF ... reading address from bus daemon". The
XFCE session therefore never came up.

Running the daemon directly works (verified in the guest:
`dbus-daemon --session --fork --print-address 1` prints its address), so
the .xinitrc no longer uses dbus-launch: it picks the socket path itself
and passes --address=unix:path=$XDG_RUNTIME_DIR/bus, then exports
DBUS_SESSION_BUS_ADDRESS. That removes the address hand-back entirely --
no pipe, no exec, nothing to lose -- and is what the session actually
needs. dbus-launch stays as a fallback if the direct bind fails.

The underlying fd-across-exec bug is NOT fixed by this and is still under
investigation (see the [ebadf-write] diagnostic); this unblocks the
desktop meanwhile.

Generated .xinitrc passes `sh -n`; xtask compiles clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
Two independent, mutually-masking defects in sys_close_range, found while
hunting the dbus-launch EBADF:

1) The flags word was bound to `_flags` and dropped. CLOSE_RANGE_CLOEXEC
   means "MARK this range close-on-exec" -- the descriptors stay open and
   usable. This kernel closed them instead, turning a routine hardening
   call (glibc, systemd, GLib and dbus all make one at startup) into a
   mass close of a live fd table. Route the CLOEXEC mode to a new
   LinuxProcess::set_range_cloexec, which only inserts into cloexec_fds.
   CLOSE_RANGE_UNSHARE is a no-op here (fd tables are never shared
   between processes), and any unknown bit is now EINVAL so callers can
   detect an old kernel and fall back, as they are written to do.

2) FileDesc is an i32, so the canonical `close_range(3, ~0U, 0)` idiom
   wrapped `last` to -1, matched nothing and returned success. Clamp both
   ends to i32::MAX.

Also log the call unconditionally through klog ([close-range]) so a mass
close of a live table is visible in dmesg regardless of log level.

These are correctness fixes on their own merits. Whether they are also
the cause of the dbus-launch failure is NOT established: dbus only takes
the close_range fast path when built against linux/close_range.h, and
Alpine v3.24's dbus APKBUILD does not list linux-headers in makedepends.
The [close-range] and [ebadf-write] logs together settle it on the next
boot.

Ruled out along the way, with proof rather than intuition: a forked
child's fd table is fully independent (Process::fork_from clones the
HashMap; only the Arc values are shared, which is POSIX open-file-
description sharing), so dbus-launch's babysitter closing the address
pipe cannot affect the bus runner that inherited it.

linux-object and linux-syscall check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…8f1ft

Claude/labwc eclipse desktop 08f1ft
eclipse-x11-prepare spawned gtk-update-icon-cache and update-mime-database
with '&'. When the script and its shell exit, those children are orphaned
and reparented -- and a backgrounded update-mime-database was the process
live when the kernel panicked at X session teardown:

  panic cpu=0 at linux-object/src/process.rs:147:17
  Process::linux(): pid=4400 name="update-mime-database" has no LinuxProcess ext

That kernel bug is real and still under investigation; this commit does not
fix it. It removes a trigger this project controls: run both caches inline so
the script creates no orphaned background children. They are optional
(lookups work without them, just slower), so ECLIPSE_X11_SKIP_CACHES=1 skips
them for anyone who would rather not wait on first boot.

Generated script passes `sh -n`; xtask checks clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…cking

Groundwork for two open failures seen when an XFCE session starts. Neither
root cause is established; this is instrumentation plus one defensive guard,
and must not be read as a fix.

1) DEFENCE IN DEPTH (not a fix): check_signals() used proc.linux(), which
   panics the whole kernel when the ext will not downcast. It runs on every
   blocking syscall, so it is a likely site to take the box down at session
   teardown. Use try_linux and answer "no pending signal": a process with no
   resolvable Linux extension has no signal disposition table, so nothing is
   deliverable. The two lines above already use try_lock_linux for the same
   reason.

   IMPORTANT, and a correction to an earlier assumption in this branch: this
   state is UNREACHABLE by construction. process.rs:109 and :212 are the only
   creators of a Linux process and both install a LinuxProcess by value; `ext`
   is written once in the constructor, never replaced, and Process has no Drop.
   `Process::create` (ext = ()) is zircon-feature only. So a failed downcast
   means the ext fat pointer was CORRUPTED -- it does not mean a kernel-internal
   process wandered into a Linux path. Commit 2ecaa5f's justification for the
   thread-side guard ("init has no LinuxThread ext") is likewise not derivable
   from the code; that earlier sighting was very probably this same corruption,
   masked rather than explained.

   So Process::linux() now dumps both words of the ext fat pointer before it
   dies: a plausible vtable pointer means a structured overwrite by another
   allocation, zeros or garbage mean a spray.

2) HEAP FORENSICS on the user page-fault dump. xfce4-session dies with the musl
   mallocng shape -- get_meta()'s `mov -0x10(%rdi),%rax` returns 0 from a MAPPED
   address, so `assert(meta->mem == base)` faults at 0x10 (the 0xf4 bytes in the
   code dump are musl's a_crash()). The register dump alone cannot distinguish a
   narrow stray write from a wholesale page replacement, so also print the PTE
   (pa + flags) and 64 bytes at rcx, rdi, the containing page base, and rbx:
     * one zeroed qword, neighbours intact -> narrow stray write
     * whole page zero -> mapping replaced or frame re-zeroed (pa names the frame)
     * 0x5a5a.. with --features mem-debug (which poisons freed frames) -> stale
       PTE onto a FREED frame
   Note the dump also admits an application double-free: musl's free() writes
   *(uint16_t*)(p-2) = 0, and a zeroed in-band offset reproduces these exact
   registers, so check the two bytes at rdi-2 too.

Also worth recording: docs/README-crash-repro.md is CLOSED (that corruption was
self-referential /proc/self/exe recursion, fixed in b448c77, and its per-tick
stack-canary tripwire stayed silent here), so the itimer/signal-delivery prior
does not apply to these two failures.

Verified with the real bare-metal configuration:
`cargo check --no-default-features --features linux --target zCore/x86_64.json`
passes for linux-object, linux-syscall, zcore-loader and zcore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…8f1ft

Claude/labwc eclipse desktop 08f1ft
…ndow

Process::create_with_ext publishes a forked child into ROOT_JOB (a strong
Arc) BEFORE its address space is copied and before it has any thread, so the
half-built child is immediately reachable from all_live_processes() and
ROOT_JOB.find_process() on every CPU. If anything kills it in that window --
SIGKILL, `kill -9 -1`, Job::kill -- Process::exit() takes the
`threads.is_empty()` branch and runs the FULL teardown right there:
vmar.clear(), PROCESS_TERMINATED latched, removed from its Job,
hunter::task_exit.

Process::fork_from then continued regardless and stamped the status back to
Running. set_status_running had no Exited check, add_thread only rejects
Exited (so the thread was accepted), and add_signal_callback fires the
termination closure INLINE against the already-latched signal and then
discards it. The result was a process that is simultaneously "running, with
threads" and "already terminated": in no job, invisible to the reaper, with a
torn-down address space, and whose termination bookkeeping already ran.

set_status_running now refuses to resurrect an exited process and reports it;
fork_from abandons the child with BAD_STATE instead of returning a corpse
that userspace will try to run. The child never executed a user instruction,
so failing the fork is the truthful outcome.

Found while investigating the recurring "has no LinuxProcess/LinuxThread ext"
panics. This is a real state-machine defect fixed on its own merits -- it is
NOT established that it causes those panics, and it is not a fix for them.

Verified with the real bare-metal configuration:
`cargo check --no-default-features --features linux --target zCore/x86_64.json`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…ler's

Found by booting the kernel in QEMU and testing, not by reading:

  / # sh -c 'exec 7>/tmp/p7; busybox sh -c "echo INHERITED_OK >&7"'
  / # cat /tmp/p7
  INHERITED_OK                      <- fd 7 IS open across execve
  / # sh -c 'exec 7>/tmp/p7b; ls /proc/self/fd'
  ls: /proc/self/fd/10: Bad file descriptor
  ls: /proc/self/fd/11: Bad file descriptor
  0  1  2                           <- fd 7 absent, foreign numbers present

linux-object/src/fs/mod.rs built the ProcSelfFdDir from
`self.zircon_process()`, whose own doc comment reads "Get the PARENT zircon
process" -- it is literally `self.parent.upgrade().unwrap()` over the
`parent: Weak<Process>` field that fork_from fills with
Arc::downgrade(parent). That one call site was its only caller in the tree.
So readdir("/proc/self/fd") enumerated the parent's fd table. And for a
process that was never forked, `parent` is Weak::default(), so the unwrap()
would have panicked the kernel outright.

Resolve the CALLING process from the HAL's current thread instead -- the same
pattern the signal code already uses, and correct because every path here is
a syscall on behalf of the current thread. procfs.rs:437 already had the
right idea for the per-pid variant (ROOT_JOB.find_process).

This is not cosmetic. libdbus's _dbus_fd_set_all_close_on_exec() walks
/proc/self/fd and applies fcntl(F_SETFD) -- or, in its _dbus_close_all()
form, close() -- to every descriptor number it reads back. Against a foreign
listing it acts on the wrong descriptors of the calling process. Any program
using the /proc/self/fd idiom to sweep inherited fds was affected.

Also settled empirically, refuting an earlier working hypothesis of mine:
descriptors >= 3 DO survive execve correctly (T1), and a pipe write-end
inherited across execve works (T3), so fd inheritance was never broken.

Verified: cargo check for linux-object and for the real bare-metal config
(--no-default-features --features linux --target zCore/x86_64.json).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…8f1ft

Claude/labwc eclipse desktop 08f1ft
…ssion

Regression I introduced in 9509e1d. .xinitrc runs eclipse-x11-prepare BEFORE
startxfce4, and that commit made the icon/mime cache generation synchronous.
So update-mime-database over /usr/share/mime -- minutes under emulation --
now runs on the critical path, and the user sees a black root window with no
window manager and no cursor until it finishes.

Backgrounding them was worse (it orphaned children, and a backgrounded
update-mime-database was the process live when the kernel panicked at session
teardown), so keep them synchronous but take them OFF the boot path: they are
now opt-in via ECLIPSE_X11_CACHES=1. Both are optional -- icon and mime
lookups work without them, just slower.

The two caches that actually matter stay unconditional and are cheap:
gdk-pixbuf loaders.cache (without it GTK decodes no images at all) and
gschemas.compiled (without it any app touching GSettings aborts).

Generated script passes `sh -n`; xtask checks clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…8f1ft

fix(xfce): make the slow GTK caches opt-in; they were blocking the se…
Two defects that both hand userspace a region shorter than it asked for, with
no error reported. The caller gets success and its own length back, uses the
tail, and takes a SIGSEGV on first touch with NOT_FOUND -- no VmMapping covers
the address. This is what killed every XFCE component:

  xfdesktop: rcx=0x5051460 pa=0xb1d9000 flags=READ|WRITE|USER   <- mapped
             rdi=0x5052010 NOT MAPPED                           <- next page, gone
  xfsettingsd: rbp=0x509dff8 -> faults writing 0x509e000        <- next page boundary
  xfwm4, Thunar, migrate: same shape, most of them inside musl mallocng
  (`movb $0,(%rsi,%rcx,1)` while framing a group), i.e. the allocator writing
  through the end of memory mmap said it had.

1) linux-syscall/src/vm.rs sys_mmap, file path: `map_len = len.min(vmo.len() -
   vmo_offset)` mapped only what the file could back. Linux maps the whole
   `len`: bytes past EOF read as zero and the region exists for its full
   length -- which is exactly what a PT_LOAD segment with memsz > filesz, or
   any mmap past EOF, relies on. Reserve the full range with an anonymous VMO
   and overwrite its head with the file window, so the tail is demand-zero like
   Linux. (The `TODO: allow the mapping extends past the end of vmo` in
   Vmar::map_ext_min is the same gap seen from the other side.)

2) zircon-object/src/vm/vmar.rs VmMapping::clone_map, eager fork fallback:
   `VmObject::new_paged(len / PAGE_SIZE)` truncates on integer division, so a
   VMO whose length is not a whole number of pages produced a child VMO one
   page SHORT. The child mapping keeps the parent's `size`, so its last page
   had no backing store. Use pages() (round up) and clamp the final copy chunk
   to the remaining bytes.

Verified by booting the built kernel in QEMU (SMP=4, OVMF): boots clean, 30
rounds of malloc-heavy churn and 15 rounds of fork+exec of a large binary, with
zero panics and zero SIGSEGV; the /proc/self/fd fix from 6d15b3b still holds.
Also `cargo check` for zircon-object, linux-syscall, and the real bare-metal
config (--no-default-features --features linux --target zCore/x86_64.json).

NOT claimed: that this alone makes the XFCE desktop come up. It removes a
proven, reproducible source of exactly the observed crash signature; whether
other defects remain is for the next desktop boot to say.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
claude and others added 29 commits August 6, 2026 03:41
This branch never merged master's 1b1d289 ("mejoras a drivers, velocidad,
procesamiento y demas"), so has_ready()'s lock-contention fallback and
AHCI's zero-copy DMA are already in their safe state here -- but a future
merge from master could silently reintroduce either:

- has_ready(): that commit flipped the try_lock() failure fallback from
  true to false, contradicting its own doc comment and reintroducing a
  lost-wake class of bug (a CPU can halt through a wake it hasn't
  observed yet, with no timer backstop in the executor's idle path).
- AHCI zero-copy DMA: that commit re-enabled a path deliberately disabled
  with `&& false` since June, with no coherence/pinning audit against
  real hardware.

The equivalent functional fix (flip false back to true, redisable the
DMA path) still needs to land on master itself, where 1b1d289 actually
lives -- a direct push there was blocked by this session's auto-mode
guard, so it's pending a separate approval/route.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…8f1ft

docs(sched,ahci): guard against reintroducing 1b1d289's two regressions
Dos arreglos de un analisis multi-agente con verificacion adversaria.

EQUIDAD DEL PLANIFICADOR. Con 2N hogs peleando por N CPUs, el mas
progresado hacia 4,46x lo del menos (Linux 1,63x). Causa, confirmada por
el agente leyendo el codigo: `ready_num` -- la metrica de carga que usa
la colocacion en spawn -- excluye las tareas `borrowed` (la que se esta
puliendo AHORA). Una CPU clavada corriendo un hog reporta carga 0,
indistinguible de una CPU ociosa, asi que la tormenta de fork apila hogs
sobre las CPUs que solo parecen vacias porque su hog esta ejecutando, y
nada reequilibra despues. Se anade `placement_load` con mascara
`((notified | borrowed) & !dropped)` usada solo en la colocacion;
`ready_num` conserva su mascara para el robo (una tarea borrowed no se
puede robar). Lee los mismos bits, sin cerrojo, sin tocar la ruta
caliente del poll. Medido: fairness 4,46 -> 1,52x, en paridad con Linux.

READAHEAD POR-STREAM. La lectura secuencial de btrfs seguia a ~11 MB/s
porque el readahead se activaba con un unico `last_read_end` byte-exacto,
y btrfs intercala una lectura de nodo de btree entre lecturas de datos:
el flag unico hace ping-pong entre el offset de datos y el de metadatos,
asi que la continuacion byte-exacta nunca se cumple durante el streaming.
Se reemplaza por un anillo de 4 descriptores de stream: el stream de
datos sobrevive al desvio de metadatos (vive en su propio slot), se
detecta como secuencial y hace prefetch de 1 MiB; un paseo aleatorio no
casa con ningun stream y no hace prefetch, preservando el win de 4K
aleatorio. La arquitectura es la correcta; la ganancia secuencial es
modesta (btrfs no lee contiguo a nivel de dispositivo) y queda por afinar.

Y confirmado por HEAPPROF, para el siguiente commit: el fork O(n^2) es el
escaneo O(longitud-de-free-list) del dealloc del buddy allocator -- coste
por llamada 18K -> 284K ciclos durante un forkloop de 512 mapeos. La
sesion anterior lo descarto mirando el promedio acumulado diluido; el
verificador lo cazo en el codigo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwhtMqzF6Lq18WnrhJD3zR
…k O(n^2)->O(n)

El `dealloc` del buddy allocator (buddy_system_allocator 0.8) fusiona
bloques ESCANEANDO `free_list[class]` en busca del hermano
(lib.rs:161) -- O(longitud de la free-list). Un fork con M mapeos
asigna y libera del orden de M objetos del mismo tamano (el inner de
VMObjectPaged, VmMapping, VmMappingInner) cuyos hermanos siguen vivos,
asi que esas free-lists por clase crecen y CADA dealloc paga O(M): el
desmontaje del hijo al salir era O(M^2). HEAPPROF lo confirmo -- dealloc
subia de ~18 K a ~284 K ciclos/llamada a lo largo de un bucle de 512
mapeos.

Se antepone al buddy una cache de free-lists por clase de tamano (el
puntero `next` vive en la primera palabra del bloque liberado, igual que
la lista intrusiva del propio buddy) que sirve el par asignar/liberar
pequeno en O(1). El par comun ya no toca el buddy, sus free-lists se
mantienen cortas y el escaneo O(n) no se dispara en la ruta caliente.

La correccion se apoya en un invariante del buddy: todo bloque de clase
c esta alineado a 2^c (reparte por el bit bajo de la direccion y parte
bloques potencia-de-dos por la mitad), asi que como class_size >=
align, cualquier bloque cacheado de la clase c satisface la alineacion
de cualquier asignacion que caiga en esa clase -- los bloques de una
clase son intercambiables.

Se cachean las clases 2^3..2^12 (8 B..4 KiB, todo objeto caliente de
fork); los buffers mayores (E/S, readahead de 1 MiB) van directos al
buddy. Tope de 1024 bloques por clase (~8 MiB del heap de 512 MiB en el
peor caso) para que el buddy no se quede sin bloques grandes. Solo en la
build por defecto: `mem-debug` quiere los viajes reales al buddy para
sus canarios y su envenenado.

Medido (forkloop, TCG, 4 vCPU): el coste de `wait` (desmontaje del hijo)
pasa de crecer con M a quedarse plano (~1 ms) hasta 512 mapeos, y la
primera iteracion en frio frente a las calientes cae 4,5x (3,59 s ->
0,80 s a 512 mapeos) porque las asignaciones calientes las sirve la
cache. Queda un O(M^2) DISTINTO en el lado del `fork` (montaje COW del
padre), en la capa VM, no en el allocator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwhtMqzF6Lq18WnrhJD3zR
…O(n)

Tras arreglar el desmontaje O(n^2) del allocator quedaba un O(n^2)
DISTINTO, dominante, en el lado del `fork` (montaje COW del padre). La
causa: un `mprotect` o un `munmap` que perfora un agujero PARTE el mapeo
pero el trozo nuevo conserva el MISMO Arc de VMO (`cut`: la cola es
`vmo: self.vmo.clone()`), asi que un proceso puede tener M mapeos sobre
un unico VMO. `clone_map` clonaba cada mapeo por separado:

  * si el VMO acepta `create_child` (un solo mapeador), cada llamada
    RECORRE todos los mapeadores del VMO poniendo RemoveWrite
    (paged.rs:1343) y apila un nodo oculto -> O(M^2) y una torre de M
    ocultos; pero ademas
  * `try_cow_child` rechaza `share_count > 1` (justo el caso de M mapeos
    sobre un VMO), asi que TODOS caen al copiado EAGER, y cada mapeo
    copiaba el VMO ENTERO -> O(M^2) en bytes copiados.

Se deduplica el hijo por koid del VMO de origen dentro de `fork_from`:
se genera el hijo (snapshot COW o copia eager) UNA vez por VMO unico y
todos los mapeos hermanos se mapean sobre ese unico hijo. El primer
`create_child` ya protegio los PTE de todos los hermanos en su recorrido
de mapeadores, asi que el resto solo necesitan un `VmMapping` nuevo. Que
los hermanos compartan el hijo ademas preserva a traves del fork el
aliasing que los mapeos tenian en el padre (antes divergian en copias
separadas -- un bug latente de aliasing que esto tambien corrige).

El camino eager se extrae a `fork_eager_copy` para que la cache lo
envuelva igual que al COW.

Medido (forkloop, TCG, 4 vCPU, tiempos de `fork` ya calientes):

  mapeos   antes      ahora    factor
    64     ~16 ms    3,7 ms      4x
   128     ~55 ms    4,4 ms     12x
   256    ~200 ms    5,6 ms     36x
   512    ~800 ms    8,0 ms    100x
  1024    fallaba   13,0 ms      --

El escalado pasa de ~4x por duplicacion (O(M^2)) a ~1,2-1,6x (lineal), y
1024 mapeos ahora completa donde antes fallaba el fork. El `wait`
(desmontaje del hijo) sigue plano, confirmando que el arreglo del
allocator se mantiene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwhtMqzF6Lq18WnrhJD3zR
… que bate a Linux

Documenta la seccion 3.undecies: las dos causas O(M^2) del fork (el
desmontaje del hijo en el allocator, y el montaje COW del padre en la
capa VM), sus arreglos (front-cache de clases de tamano; deduplicacion
del hijo COW por VMO), y la comparacion contra Linux bajo el mismo
QEMU/TCG. El fork por mapeo pasa de un O(M^2) catastrofico (800 ms a 512
mapeos) a batir a Linux 2,6x, con la aislacion de memoria aun en PASS.
Anotado el hueco que queda: fork por MiB residente (map_committed instala
PTE eagermente frente al pagineo perezoso de Linux).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwhtMqzF6Lq18WnrhJD3zR
Revert unsound transmute-based vtable heuristics from bbddbb5 (busy-poll + labwc crash-loop fix)
…on arreglada

La corrupcion de memoria de usuario que mantenia el COW desactivado
("dos procesos escriben una pagina que debia copiarse", reproducible
siempre con `dd .../tmp/z && md5sum /tmp/z` -> SIGABRT en glibc) NO
estaba en la logica COW: era una entrada de TLB ESCRIBIBLE obsoleta.
`protect_for_cow` protege contra escritura las paginas del padre y el
shootdown debe invalidar el TLB de las demas CPU, pero una CPU girando
en un ticket lock tiene las IRQs deshabilitadas, no podia atender el IPI
de shootdown, y el iniciador agotaba su presupuesto de acks y se rendia.
Esa CPU conservaba una entrada escribible hacia un marco que el hijo ya
compartia, y una escritura a traves de ella caia sobre la pagina del
hijo. El spin-pump de shootdowns de esta sesion (`tlb_shootdown_pump`,
drenado cada 512 vueltas en el bucle del ticket lock) hace que una CPU
que gira vacie su propia cola, asi que no sobrevive ninguna entrada
escribible al write-protect.

Revalidado en esta build con FORKCOW=1, 4 vCPU: el reproductor exacto y
variantes mas duras (20x md5 en serie, un bucle sobre fichero aleatorio
de 2 MiB, 40x md5 en PARALELO -- el camino SMP con mas probabilidad de
exponer la carrera) devuelven todos un unico checksum identico; `fork
memory isolation` da PASS; el shell de login sobrevive. Con la build por
defecto ya en ON: reproductor limpio, aislamiento PASS, cero crashes.

Medido, build por defecto vs Linux (mismo QEMU/TCG):

  fork + exit               1366 us   Linux 2704   Eclipse 2,0x
  fork + exit, 1 MiB        1521 us   Linux 2945   Eclipse 1,9x
  fork coste por mapeo      4,57 us   Linux 22,3   Eclipse 4,9x
  fork + exit, 256 mapeos   2734 us   Linux 8635   Eclipse 3,2x
  COW fault (tras fork)    19158 ns   Linux 106892 Eclipse 5,6x
  fork coste por MiB        412 us    Linux 167    Linux 2,5x (era 7,7x)
  fork + exit, 16 MiB       7701 us   Linux 5451   Linux 1,4x (era 4,2x)

`FORKCOW=0` queda como kill-switch para rollback instantaneo y A/B.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwhtMqzF6Lq18WnrhJD3zR
…a comparacion

Documenta que el COW se activa por defecto: la corrupcion que lo mantenia
apagado era una entrada de TLB escribible obsoleta (misma raiz que el
convoy SMP #3, arreglada por el spin-pump de shootdowns), no la logica
COW. Incluye la revalidacion (reproductor exacto + 40x md5 en paralelo,
aislamiento PASS) y la comparacion de la build por defecto contra Linux:
Eclipse bate en fork base (2x), 1 MiB (1,9x), por mapeo (4,9x), 256
mapeos (3,2x) y COW fault (5,6x); estrecha el residente de 7,7x a 2,5x.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwhtMqzF6Lq18WnrhJD3zR
…peed-9fkd4w

Claude/eclipse os processing speed 9fkd4w
…e branch; fix two bugs a parallel merge reintroduced

print_fault_backtrace() (frame-pointer chain walk + raw stack scan, both
via the spin/blocking serial writer so they survive a re-fault) existed
but was only called from the "no current thread" branch of
handle_page_fault. The branch that actually fires for a fault during
normal syscall/user-thread execution -- the common case, and the one
hit by the current master-only RIP-lands-in-.rodata crash (see issue
#761) -- printed one line and panicked without it. Extracted the
existing logic into a shared fn, call it from all three exit points
(the new low-address guard, the common case, and the kernel-private
case).

Rebasing onto the last 58 commits on this branch surfaced two bugs that
a parallel session's port of master's bbddbb5 reintroduced here (this
branch never merged bbddbb5 directly, but got an equivalent change):

- task_collection.rs has_ready(): the try_lock() failure fallback was
  `false` again, contradicting its own doc comment and reintroducing the
  lost-wake class of bug already reverted on master by PR #759. Back to
  `true`. (A leftover fragment of an earlier guard comment on the old,
  simpler has_ready() body also collided with this during the merge and
  broke compilation entirely -- removed, the comment carried over to the
  surviving implementation.)
- task_collection.rs Task::poll and kernel-hal/timer.rs's expired-timer
  dispatch: both had the unsound transmute_copy-based vtable "corruption
  check" back too (same PR #759 finding: unstable layout assumption,
  false positives caused 6,500-30,000 busy-polls/s and a deterministic
  labwc crash at ~35s on master). Removed both, callback/poll now called
  unconditionally as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…r a bad indirect call

The nearest-symbol resolution of the previous [kfault-bt] capture (issue
#761) turned out to point at code that provably cannot be on the real
call stack for this crash (one of the four resolved functions is dead/
unreachable in this build; a structural check rules out two others ever
co-occurring). The raw stack scan's "looks like a kernel pointer" filter
apparently picked up stale, unrelated leftover stack contents rather
than the actual caller.

If this is an EXECUTE fault from an indirect `call` through a corrupted
fn-ptr/vtable slot, the CPU already pushed the return address (right
after that `call`) before loading the bad target into rip -- so [rsp0]
at fault time IS that caller's return address, full stop, independent
of any "plausible kernel address" heuristic (which can both reject a
valid near-zero .text address and accept unrelated stale data deeper in
the stack). Print it unconditionally, unfiltered, first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…ouble fault

The previous commit (4ca560d) read *sp unconditionally to surface the
likely bad-call return address, deliberately skipping the plausible()
address filter to avoid missing a legitimately low value. That was
wrong: sp comes straight from the trap frame, but this whole
investigation is about a corruption bug -- if it clobbers more than one
function pointer, rsp can be garbage too, and dereferencing an unmapped
address while already inside a page-fault handler is a textbook
double-fault trigger (#PF-during-#PF is one of the CPU's own DF
conditions). Reproduced exactly that: reporter's next run hit "CPU
EXCEPTION on CPU1: Double Fault (#DF)" at trap.rs's raw exception
handler, never reaching this code's own [kfault-bt] output at all.

Re-gate the read on plausible(sp) (the address, not the value) like
every other raw dereference in this function already is. Still reports
sp itself when it's out of range, just without touching it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…n, same bug class as b448c77

Epoll::any_ready() recurses through file.poll() whenever a watched fd is
itself an Epoll (documented as intentional: wlroots/labwc nests
libinput's epoll fd inside its own wl_event_loop epoll). But
EPOLL_CTL_ADD never validated the target: nothing rejected adding an
epoll to itself, nor a longer chain that loops back on itself, nor even
an acyclic chain deeper than a few hops.

A cycle makes any_ready()'s recursion have no terminating condition at
all -- the first poll/wait touching it recurses until the stack is
gone. An acyclic-but-deep chain is bounded but still unbounded in
practice (nothing stops a caller from nesting arbitrarily many epolls).
Either way this lands on the coroutine executor's 128 KiB heap-allocated
stack, which has NO guard page (vendor/PreemptiveScheduler/src/executor.rs)
-- so overflow doesn't fault, it silently scribbles low/zero bytes over
whatever heap allocation happens to sit below it. This is the exact
mechanism already proven to corrupt a live vtable once before (commit
b448c77, unbounded /proc/self/exe path recursion); that fix only
bounded the one path-resolution call site, not this one.

Fix: at ADD time, if the target downcasts to Epoll, walk its nested-epoll
graph (Epoll::contains_epoll, snapshot-then-recurse like any_ready()
already does to avoid holding the lock across a re-entrant poll) looking
for `self`; reject with ELOOP if found (a cycle) or if the walk exceeds
EPOLL_MAX_NEST_DEPTH (mirrors Linux's EP_MAX_NESTS=4) without resolving.
A global EPOLL_NEST_LOCK (mirroring Linux's epmutex) serializes the
check-then-insert against a concurrent nested add in the opposite
direction, closing the TOCTOU race a naive check-then-insert would have.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
Two real [kfault-bt] captures on the reporter's machine showed a live,
in-use rsp (~550 MiB above the kernel base) rejected by the 256 MiB
plausible() bound, so the one genuinely reliable data point ([rsp0], the
likely bad-call return address) never got read either time.

Split into two bounds instead of just widening the one closure:
- rbp-walk keeps the original 256 MiB bound -- that loop chases whatever
  value is STORED at each frame, so if the chain is corrupted (observed:
  goes nowhere useful within 1-2 hops in both captures), a wider bound
  would let an untrusted saved_rbp wander further into unmapped memory
  before the loop's own gate stops it.
- the stack-scan/[rsp0] read gets a new, wider 4 GiB bound
  (plausible_sp): this code only ever touches rsp0 itself and offsets up
  to 4 KiB from it (8 bytes at a time, 512 iterations max), so it stays
  local to a known-live pointer regardless of how wide the bound is --
  none of the widening's risk applies here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…etries forever

The low-address guard added by a parallel session ("catch them early...
avoid re-entrant fault") returned from handle_page_fault after logging,
without fixing the underlying mapping. A page-fault handler that returns
without resolving the fault gets IRET'd straight back to the exact
faulting instruction -- there is no other outcome, the CPU doesn't
auto-advance rip past a #PF. Since nothing about the null pointer got
fixed, that instruction re-faults immediately, forever: an unbounded
retry loop, not a recovery.

Two reproductions on real hardware hit a Double Fault instead of ever
reaching this branch's own [kfault-bt] output -- both times at the exact
same rip and near-identical register state, which is what a runaway
retry loop exhausting whatever stack services repeated exception
delivery would look like, not random corruption. This branch's `return`
was never actually a safe recovery, independent of anything this
session's diagnostic edits touched.

Panic instead: same diagnostic output (backtrace already prints before
this), but the CPU actually halts instead of retrying the same
instruction that's guaranteed to fault again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
…not 16

STASH_CPUS and DROP_TRACK_CPUS were hardcoded to 16, but bare-metal
cpu_id() legitimately ranges over 0..63 (kernel_hal::config::MAX_CORE_NUM
= 64, enforced by register_cpu()'s SMP bring-up assert and by mycpu(),
the IRQ-off primitive stash_cpu()'s Sync-safety argument itself depends
on). stash_cpu() clamped every id >= 16 down onto slot 15 via
`.min(STASH_CPUS - 1)` -- on any real or KVM box with more than 16
online logical CPUs (unremarkable on modern desktop/server silicon),
two or more DISTINCT, concurrently-running cores alias onto the SAME
DROP_STASH slot. IRQs-off on core A does not serialize against core B's
concurrent, unsynchronized push/pop of the same backing
Vec<Arc<VMObjectPaged>> -- a data race on a Vec's {ptr,len,cap} triple,
classic heap-corruption UB, on a path (commit_page/remove_child/
replace_child/COW teardown) that fires on essentially every VMO
mutation.

Widen both arrays to kernel_hal::config::MAX_CORE_NUM so the mapping is
injective on real hardware (the existing SMP bring-up asserts already
forbid onlining a 65th core, so this provably closes the gap). Every
clamp (stash_cpu(), drop_crumb(), Drop::drop()) is deliberately left
untouched: with the array now sized to the true ceiling the clamp is
dead code on bare metal, but it remains load-bearing in libos/test
builds, where cpu_id() is std::thread::current().id() truncated to u8 --
unrelated to MAX_CORE_NUM and routinely >64 under cargo test's
multi-threaded runner. Replacing that clamp with a hard bound (as an
earlier draft of this fix proposed) would silently skip a stash_defer()
some callers depend on to avoid a reentrant family-lock deadlock, for
any libos thread whose truncated id lands out of range -- a regression
caught during design review before implementation, not discovered
empirically.

Investigated as the top candidate (of four investigated this session)
for the still-unreproduced hardware-only crash this branch has been
chasing: a `Box<dyn FnOnce(Duration)>` callback in
kernel-hal/src/bare/timer.rs's TimerHeap read back with a NULL vtable
pointer (vaddr=0x18, exactly method-slot-0). A torn Vec write from this
race landing on a neighboring kernel-heap allocation -- such as a
TimerEvent's callback, which lives in the same flat static-BSS kernel
heap -- zeroing exactly the fat pointer's vtable word is mechanistically
consistent with that signature. This is NOT confirmed as the actual
trigger; it is a real, independently-verified latent bug regardless.

Verification caveat, disclosed in full: `cargo test -p zircon-object
--lib --features libos` is not 100% clean under this fix in this
sandbox -- but neither is the unmodified baseline. Ran 10 parallel
iterations of each: baseline crashed (SIGSEGV) 1/10, this fix crashed
(SIGSEGV x2, SIGABRT/tcache x1) 3/10, both showing the identical crash
signatures. Every --test-threads=1 run (2x baseline, 2x fixed) was
100% clean (only the known pre-existing task::job::tests::kill flake).
This matches kernel_hal::cpu::cpu_id()'s documented libos-mode behavior
(thread-id-derived, colliding across 100+ host test threads once
truncated) -- a pre-existing, disclosed, test-harness-only artifact
unrelated to MAX_CORE_NUM and orthogonal to this fix, not a new failure
mode this change introduces. Full release kernel build (--target
x86_64.json --features "linux graphic") is clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
EventDev::io_control built several userspace-facing ioctl responses by
casting the raw `data: usize` argument straight to `*mut T` and writing
through it with no bounds, alignment, or user/kernel-half check --
EVIOCGNAME and the EVIOCGPROP/EVIOCGKEY/EVIOCGLED/EVIOCGSND/EVIOCGSW
group in particular sized that raw slice directly off the
attacker-controlled `_IOC_SIZE` bits of `cmd` (up to 0x3fff = 16383
bytes), so any process able to open an evdev node could point `data`
anywhere -- including into kernel memory -- and have up to 16 KiB
zero-filled through it. (EVIOCREVOKE's argument is accepted even from an
unauthenticated caller during session (de)activation, per the comment
above the ioctl-group dispatch, so this path is reachable earlier than
the rest of the fd's lifecycle would suggest.)

This is a distinct bug class from the timer_tick/TimerHeap
vtable-corruption crash this branch has otherwise been chasing
(kernel-hal/src/bare/timer.rs) and from the DROP_STASH/DROP_TRACK_CPUS
per-cpu array widening in the preceding commit -- it touches neither the
timer heap, any Box<dyn FnOnce> callback path, nor VMO Drop/Arc
handling. It was found during the same audit pass and is fixed
independently; it is not believed to explain the reporter's [kernel
page fault] captures, but any process-controlled unbounded wild write
reachable from userspace is worth closing on its own merits.

Route every ioctl branch through kernel_hal::user::UserOutPtr's checked
write()/write_array(), which enforce non-null, correct alignment for the
target type, and (on non-libos builds) that the full destination range
stays inside the user half of the address space -- the same pattern
fs/stdio.rs's tty_ioctl already uses for its own ioctl surface. Also
hard-cap the decoded `size` field at 256 bytes before it is used
anywhere: every real evdev response this device ever produces (input_id,
input_absinfo, the key/led/sw/prop bitmaps, the device name) fits
comfortably under that, so 256 is small enough to make even a fully
attacker-controlled size harmless, and it lets every branch write
through a fixed-size stack buffer instead of trusting the request.

No unsafe code remains in EventDev::io_control; dropped the now-stale
#[allow(unsafe_code)] attribute along with it.

Verified via `cargo build -p linux-object` (clean) and the full release
kernel build (--target x86_64.json --features "linux graphic", clean).
cargo test -p zircon-object --lib --features libos passed 4/4 runs (2x
default-parallel, 2x --test-threads=1) with this change in the tree,
alongside the preceding commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
Root-caused the recurring Double Fault this branch has been chasing
(#761): every capture shows the identical rip=0xffffff000000024d, and
resolving it against the release binary's symbol table for the first
time (previous sessions' nm lookups landed in unrelated .rodata, not
this address) places it 38 bytes into trap.S's `__from_user` label --
exactly the `push [rax - 6*8]` instruction immediately after `mov rax,
gs:4`. That matches the crash's own register dump: rbp/r12/r13 still
hold user-space-looking values (this trampoline hasn't finished saving
GeneralRegs yet) while rsp is already a kernel address (the hardware
already switched stacks via TSS.RSP0) -- consistent with a fault this
early in ring3->ring0 entry, before the trapframe is even fully built.

`gdt.rs::init()`/`init_ap()` pointed `GsBase` (IA32_GS_BASE, MSR
0xC0000101 -- the currently *active* one) at this CPU's CpuLocalRegion,
but never touched `KernelGsBase` (IA32_KERNEL_GS_BASE, MSR 0xC0000102).
`swapgs`, used by every trap/syscall entry and exit in trap.S/syscall.S
to reach `gs:4`/`gs:12` (this region's TSS.sp0/sp1 slots), *exchanges*
those two MSRs. Left at its power-up default (0, confirmed: grepped the
whole tree, nothing else ever writes 0xC0000102), the very first
`swapgs` a CPU executes on a trap taken from ring 3 swaps a valid
GsBase for a zero one -- so `mov rax, gs:4` in `__from_user` (meant to
fetch this CPU's kernel stack) instead reads through linear address
~0x4, and the following `push [rax - 48]` dereferences whatever that
returned. Under the interrupted user thread's page table, low
addresses are normally the deliberately unmapped null-guard page, so
this is a second, nested page fault while the CPU is still delivering
the first exception -- x86's textbook double-fault trigger. (Traced
`syscall_return`'s own `swapgs` + explicit GS_BASE `wrmsr`: once a CPU
completes one full entry/exit cycle the invariant self-corrects, which
is consistent with this reproducing as an intermittent, not
immediate-on-every-boot, fault rather than fully explaining its exact
timing -- flagging that as not completely nailed down.)

Fix: write the same CpuLocalRegion pointer to both MSRs, in both
`init()` (BSP) and `init_ap()` (APs). This makes `swapgs` idempotent
with respect to the value every `gs:`-relative access sees, regardless
of how many times it has fired -- the standard pattern for any kernel
using swapgs, and unconditionally correct independent of whether it
turns out to be the full explanation for this issue's crash.

Verification caveat, disclosed in full: this touches vendor/trapframe's
bare-metal (`target_os = "none"`) ring0/ring3 transition assembly,
which is unreachable from the `libos` test target this session's other
fixes were verified against -- `cargo test -p zircon-object --lib
--features libos` cannot exercise this code at all, and this sandbox
has no way to boot the kernel (QEMU/hardware) to confirm the fix
directly. Verified via a clean full release kernel build (`--target
x86_64.json --features "linux graphic"`) and by resolving the exact
faulting instruction against `nm`/`objdump` on that build's own binary,
byte-for-byte matching the crash log's rip and register pattern. Real
confirmation needs the reporter's hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
The KernelGsBase fix (660916d) resolved the recurring Double Fault --
the reporter's next capture is a clean null-range page fault instead
(vaddr=0x0, EXECUTE), the actual signature this issue was chasing
before Double Faults took over the investigation: an indirect call
through a function pointer that reads back as exactly zero.

That capture's raw-stack-scan backtrace was unreliable again: the one
address that repeated across two stack slots (resolved via nm against
this session's own build) turned out to sit one byte past a `ret`
instruction in an unrelated function, not after any `call` -- a false
lead, not a real return address. rbp resolved into the middle of the
static HEAP symbol, nowhere near a real frame. Neither backtrace
mechanism named anything trustworthy this time.

The null-range guard never looked at the current thread at all (the
comment above it explains why: resolving the fault through the
thread's vmar is deliberately avoided here, since that walks page
tables and can itself fault if the vmar/process is the corrupted
object). But merely reading `.name()` off the current thread and its
process carries none of that risk -- it's a String already owned by a
live Arc from the current cpu's own thread-pointer slot, never derived
from whatever corrupted the call that led here. Print it before the
panic: when the backtrace can't name the caller, knowing which
process/thread was running when a zeroed function pointer got called
still narrows the search.

Verified via a clean full release kernel build (--target x86_64.json
--features "linux graphic"). Diagnostic-only, no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHCn5RNRcwR5PY1sYBHY2S
@Pryancito Pryancito closed this Aug 11, 2026
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.

4 participants