diff options
| author | Kent Overstreet <kent.overstreet@linux.dev> | 2026-08-31 00:49:23 -0500 |
|---|---|---|
| committer | Kent Overstreet <kent.overstreet@linux.dev> | 2026-08-31 00:53:50 -0500 |
| commit | fd0e84863fd7bb32524334b84c69f760a53b4691 (patch) | |
| tree | 85b8ef5e8f652ba0f975bac314f478598ed0a0bc | |
| parent | b88d595f10d32fa54199d8873f8cfc3ae8263ed4 (diff) | |
Splash rendering was previously a question you could only answer by
rebooting a laptop and watching. Four pieces, together enough to test it:
KTEST_GUI=1 drops qemu's -nographic (-display gtk -vga std), for when a
person does need to look; ktest screendump takes the display as an image
over the monitor socket, which works headless because qemu renders
whether or not anyone is watching.
kconfig.sh gains a framebuffer, because with no DRM and no fbdev plymouth
falls back to the text plugin - whose message erase is one hardcoded line,
so a multi-line status block scrolls instead of redrawing. It decides what
plymouth renders with, not just what the pixels look like.
root_image.nix names what root_image and debootstrap actually invoke
instead of relying on the host, and buildFHSUserEnv is gone from nixpkgs.
mount.ktest reads /dev/vcsa1 - the VT layer's own character buffer - so
"what does the person booting see" has an answer in the test log.
HELD BACK, not ready to push: the file-scope kernel command line it adds
("splash plymouth.debug=stream") applies to every test in mount.ktest, and
the console tests there care about the console.
Co-Authored-By: Proof of Concept <poc@bcachefs.org>
| -rw-r--r-- | lib/libktest.sh | 44 | ||||
| -rwxr-xr-x | root_image | 5 | ||||
| -rw-r--r-- | root_image.nix | 32 | ||||
| -rwxr-xr-x | tests/fs/bcachefs/mount.ktest | 235 | ||||
| -rw-r--r-- | tests/kconfig.sh | 25 |
5 files changed, 335 insertions, 6 deletions
diff --git a/lib/libktest.sh b/lib/libktest.sh index 84674ac9..7352ff1c 100644 --- a/lib/libktest.sh +++ b/lib/libktest.sh @@ -222,6 +222,7 @@ ktest_usage_cmds() echo " kgdb Connect to kgdb" echo " mon Connect to qemu monitor" echo " sysrq <key> Send magic sysrq key via monitor" + echo " screendump [file] Capture the VM's screen (works headless)" } ktest_usage_post() @@ -410,6 +411,29 @@ ktest_sysrq() echo sendkey alt-sysrq-$key | socat - "UNIX-CONNECT:$ktest_out/vm/mon" } +# What is on the VM's screen, as an image, from here. +# +# qemu renders the display whether or not anyone is looking at it, so this works +# on a headless run - which is what makes "does the boot splash look right" a +# question with an answer, rather than one that needs a person in front of a gtk +# window at the right moment. The monitor writes PPM; converted to PNG when +# something here can, because very little else reads PPM. +ktest_screendump() +{ + local out=${1:-$ktest_out/screen.png} + local ppm=${out%.png}.ppm + + echo "screendump $ppm" | socat - "UNIX-CONNECT:$ktest_out/vm/mon" > /dev/null + + if [[ $out != "$ppm" ]] && command -v ffmpeg > /dev/null; then + ffmpeg -y -loglevel error -i "$ppm" "$out" && rm -f "$ppm" + else + out=$ppm + fi + + echo "$out" +} + save_env() { set |grep -v "^PATH=" > "$ktest_out/vm/env_tmp" @@ -497,7 +521,25 @@ start_vm() kernelargs+=("${ktest_kernel_append[@]}") - local qemu_cmd=("$QEMU_BIN" -nodefaults -nographic) + # KTEST_GUI=1 to look at something a person is meant to look at - the + # plymouth splash. Ad-hoc, not a feature: the alternative is doing it by + # hand on a laptop with real reboots. + # + # The console does not ride -nographic here - it is the explicit virtconsole + # on stdio below - so this only turns the display on. -vga std rather than + # virtio-gpu because bochs-drm gives both a framebuffer and a real vt, which + # is what plymouth draws on. + local qemu_cmd=("$QEMU_BIN" -nodefaults) + if [[ -n ${KTEST_GUI:-} ]]; then + echo "KTEST_GUI set: qemu gets a display (-display gtk -vga std)" + qemu_cmd+=(-display gtk -vga std) + else + # Said out loud because forgetting the variable and the variable not + # working look identical from outside - both are "no window". + echo "KTEST_GUI unset: qemu is headless (-nographic)" + qemu_cmd+=(-nographic) + fi + case $ktest_arch in x86|x86_64) qemu_cmd+=(-cpu host -machine type=q35,accel=kvm,nvdimm=on) @@ -101,6 +101,11 @@ PACKAGES+=(libkeyutils-dev liburcu-dev libudev-dev zlib1g-dev libattr1-dev syste PACKAGES+=(libaio-dev libzstd-dev liblz4-dev libfuse3-dev valgrind) PACKAGES+=(llvm libclang-dev libunwind-dev libelf-dev) +# The boot splash, so that what mount.bcachefs draws on it can be looked at: +# plymouth-label is not optional here, it is the plugin that renders text, and +# text is the entire thing we want to see. +PACKAGES+=(plymouth plymouth-themes plymouth-label) + # quota tools: PACKAGES+=(libudev-dev libldap2-dev) diff --git a/root_image.nix b/root_image.nix index 994eea0c..7587967d 100644 --- a/root_image.nix +++ b/root_image.nix @@ -1,14 +1,38 @@ # FHS shell for running ./root_image on NixOS # execute: -# $ nix-shell root_image.nix -# $ ./root_image <params> +# $ sudo nix-shell root_image.nix +# $ ./root_image create # $ exit - +# +# sudo outside rather than inside: root_image refuses to run as anyone else, +# and sudo from within the FHS env leaves it again. +# +# targetPkgs is what ./root_image and the vendored debootstrap call by name, +# taken from what they actually invoke rather than from whatever a Debian host +# happens to have lying around: +# +# root_image - fallocate, mount, umount (util-linux), chroot (coreutils), +# mkfs.ext4 (e2fsprogs), curl, rsync +# debootstrap - wget, ar (binutils), tar, xz, gpgv (gnupg), dpkg-deb (dpkg), +# perl { pkgs ? import <nixpkgs> {} }: -(pkgs.buildFHSUserEnv { +(pkgs.buildFHSEnv { name = "root_image-env"; targetPkgs = pkgs: with pkgs; [ coreutils + util-linux + e2fsprogs + curl + wget + rsync + binutils + gnutar + xz + gzip + gnupg + dpkg + perl + debianutils ]; runScript = "bash"; }).env diff --git a/tests/fs/bcachefs/mount.ktest b/tests/fs/bcachefs/mount.ktest index a31c87cd..76c6dc26 100755 --- a/tests/fs/bcachefs/mount.ktest +++ b/tests/fs/bcachefs/mount.ktest @@ -25,6 +25,24 @@ config-scratch-devs 4G config-scratch-devs 4G config-scratch-devs 4G +# For plymouth_shows_status, and file-scope because the kernel command line is +# built once per file - which is also why this does not belong in a committed +# mount.ktest: "splash" changes what the boot-time plymouth does for every test +# here, and the console tests care about the console. +# +# splash: without it plymouth_should_show_default_splash() says no, which sets +# SKIP_RENDERERS, which means plymouth never looks at a renderer and draws with +# the text plugin on tty1 no matter what graphics hardware is present. DRM in +# the kernel cannot change that; this word can. +# +# plymouth.debug=stream: is the only thing that routes the live trace to a file. +# plymouthd's own --debug-file only says where the buffer is dumped when it +# exits or crashes - and it *also* enables tracing, so passing it alone turns +# the trace on and then sends it to default_tty, i.e. over the splash we are +# trying to read. See plymouthd-diagnostics.c:100-115. +require-kernel-append splash +require-kernel-append plymouth.debug=stream:/run/plymouthd-trace.log + # Did the filesystem at $1 come up a member short? # # `bcachefs fs usage` prints the State column as "offline" for a member it has @@ -1228,7 +1246,9 @@ preflight() fi echo "mount helper: $(command -v mount.bcachefs || echo /sbin/mount.bcachefs)" - echo "show-status marker: $(ls /run/systemd/show-status 2>&1)" + # || not 2>&1: ls exits 1 when the marker is absent, and absent is the + # normal case here - reporting it must not be what ends the run. + echo "show-status marker: $(ls /run/systemd/show-status 2>/dev/null || echo absent)" echo "console loglevel: $(cut -d' ' -f1 /proc/sys/kernel/printk)" echo " (kernel messages above this level land on /dev/console and will" echo " push a \\r-held status line down a row each time)" @@ -1543,6 +1563,219 @@ test_systemd_mount_shows_status() systemctl stop $UNIT } +# What is actually on tty1, as text. +# +# /dev/vcsa1 is the VT layer's own character buffer for tty1: a four byte header +# - rows, columns, cursor x, cursor y - then one character and one attribute +# byte per cell. The VT code maintains it whether or not anything is scanning it +# out, so this reads the same with DRM, with vgacon, or headless with neither, +# and "what does the person booting see" becomes a question with an answer in +# the test log. +# +# Only for the text plugin. The graphical plugins put the VT in KD_GRAPHICS and +# draw pixels; the character buffer then holds whatever was there before and +# says nothing about the splash. +screen_dump() +{ + echo "---------------------------- tty1 ----------------------------" + python3 -c ' +d = open("/dev/vcsa1", "rb").read() +rows, cols, cx, cy = d[0], d[1], d[2], d[3] +cells = d[4:] +lines = ["".join(chr(cells[(r * cols + c) * 2]) for c in range(cols)).rstrip() + for r in range(rows)] +while lines and not lines[-1]: + lines.pop() +print("\n".join(lines) if lines else "(blank)") +print("[%dx%d, cursor at %d,%d, %d non-blank lines]" + % (cols, rows, cx, cy, sum(1 for l in lines if l))) +' || echo "(could not read /dev/vcsa1 - see the traceback above)" + echo "--------------------------------------------------------------" +} + +# The same block, on the boot splash instead of the console. +# +# For the text plugin this is a real test: screen() above prints tty1 into the +# log, so whether the last message replaced the previous one or piled on top of +# it is visible here, headless, without a screenshot. For the graphical plugins +# it is not - there the screen is pixels and there is no substitute for looking: +# +# KTEST_GUI=1 ktest run -I tests/fs/bcachefs/mount.ktest plymouth_shows_status +# +# KTEST_GUI drops qemu's -nographic (see lib/libktest.sh); -I leaves the VM up +# afterwards so the splash is still there to look at, and to try other things +# against by hand. +# +# --ignore-serial-consoles is what makes this workable at all, and it took an +# hour to find. plymouth reads console= off the kernel command line and attaches +# to what it finds there as a terminal to draw on. ktest boots with +# console=hvc0, so without the flag plymouthd holds *both* /dev/tty1 and +# /dev/hvc0 - checked, in /proc/<pid>/fd - and the splash goes down ktest's own +# console stream into the test log instead of onto the screen. Every plymouth +# command returns 0 while nothing appears, which is as misleading as it sounds. +# +# MOUNTED FROM A UNIT, not from this shell, and that is not incidental. +# RecoveryDisplay::new() picks its sink in this order: +# +# if io::stderr().is_terminal() -> Terminal +# else if plymouth::connect() -> Plymouth +# +# so a mount run from here, where stderr is a tty, draws to the terminal and +# leaves the splash blank however well plymouth is running. Going through +# systemd puts stderr on the journal, which is both what makes Plymouth +# reachable and what a real boot actually does. +test_plymouth_shows_status() +{ + set_watchdog 480 + + local PLY_LOG=/run/plymouthd-trace.log + + if ! command -v plymouthd > /dev/null; then + echo "plymouthd is not installed - the root image predates plymouth" + echo "being added to it, so there is nothing here to draw on." + exit 1 + fi + + # Which of the two cases this run is. qemu is started with -nodefaults, so + # without KTEST_GUI there is no display adapter at all - and that is the + # condition that actually matters, unlike the variable, which cannot reach + # us here (the host sets KTEST_GUI, and nothing in the tree reads the + # ktest.env file that would have carried it in). + if lspci | grep -qiE 'vga|display controller'; then + echo "display adapter present: expect a graphical plugin, and tty1 in" + echo "KD_GRAPHICS with nothing readable in its character buffer." + else + echo "headless: expect the text plugin on tty1, which screen() can read." + fi + + # Without this there is no splash plugin at all, and the screen is grey. + # + # The image ships the themes but nothing selects one: plymouthd.conf has its + # Theme line commented out, and the distro default it falls back to names + # ceratopsian, which is not installed. Plymouth then opens the DRM renderer, + # creates a 1280x800 head, and adds only a *text* display - so DRM owns the + # console and nothing draws on it. + # + # spinner rather than bgrt: both are ModuleName=two-step, which is the + # plugin real machines use and the one whose message handling is in + # question, but bgrt wants a firmware logo that a VM does not have. + plymouth-set-default-theme spinner + + format_with_data + + plymouthd --ignore-serial-consoles --mode=boot --tty=/dev/tty1 + plymouth show-splash + + # plymouthd forks; --ping is the only thing that says it is up and + # answering rather than merely started. Without it a blank screen has two + # explanations and no way to tell them apart. + if ! plymouth --ping; then + echo "plymouthd did not come up - nothing would be drawn on, and a" + echo "blank splash below would say nothing about mount.bcachefs." + exit 1 + fi + echo "plymouth is up" + + # And is it drawing where we can see it? Every plymouth command returns 0 + # whether the splash is on the VGA console or being written down ktest's + # own hvc0, so success proves nothing here - the open descriptors do. + local ply_pid=$(pgrep -x plymouthd | head -1) + if ls -l /proc/$ply_pid/fd 2>/dev/null | grep -q /dev/hvc0; then + echo "plymouthd has /dev/hvc0 open despite --ignore-serial-consoles:" + ls -l /proc/$ply_pid/fd | grep -E 'tty|hvc' + echo + echo "it is drawing into the test log rather than onto the screen, so" + echo "there would be nothing to look at. Not continuing." + exit 1 + fi + echo "plymouth has the screen and not our console" + + # The same question again, answered by plymouth rather than deduced from its + # descriptors. --debug makes ply_trace() live, and these are the lines that + # decide it: + # + # "ignoring all consoles but default console" the flag took + # "console <dev> found!" it did not, and that is the + # serial console it took + # "adding text display for terminal <dev>" text plugin, and where + # "adding NxN text display" text plugin + # "adding NxN pixel display" a graphical plugin + # + # Two witnesses rather than one because they fail differently: the + # descriptor check cannot tell a text display from a pixel one, and the + # trace is plymouth's account of its own intent rather than of the outcome. + echo "------------------ plymouth's own decisions ------------------" + grep -aE 'ignoring all consoles|console .* found!|adding .*(text|pixel) display' \ + $PLY_LOG || echo "(no device-selection traces in $PLY_LOG at all)" + echo "--------------------------------------------------------------" + + plymouth display-message --text="waiting for the mount" + screen_dump + + # The no-clear question on its own, before a mount complicates it. + # display-message is supposed to replace the message, not add one, and a + # short message after a tall one is the case that tells the two apart: if + # the tail of the tall one is still on the screen underneath the short one, + # the erase was sized to the message being drawn rather than to the one + # being replaced. Our recovery block is several lines, so that is exactly + # the shape it hits every time it redraws. + plymouth display-message --text="$(printf 'TALL line %d\n' 1 2 3 4 5)" + screen_dump + plymouth display-message --text="SHORT" + screen_dump + + mkdir -p $MNT + echo "UUID=$fs_uuid $MNT bcachefs $MOUNT_OPTS 0 0" >> /etc/fstab + systemctl daemon-reload + + preflight + set_delay $DELAY_MS + + echo + echo "==================== watch the OTHER screen ====================" + echo "The qemu window, not this one. ${DELAY_MS}ms per progress update." + echo + echo "Expect our block to replace \"waiting for the mount\" and then" + echo "redraw in place as recovery runs: passes done, elapsed, the bar," + echo "and the device line. What is being judged is whether that is" + echo "legible - wrapping, truncation, the theme restyling or clipping" + echo "it, updates too fast to read." + echo + echo "If the block appears HERE instead, the sink went to Terminal or" + echo "Console and the splash was never written to - that is the bug," + echo "not the display." + echo "===============================================================" + echo + + systemctl start $UNIT + + if ! mountpoint -q $MNT; then + echo "the unit did not mount:" + systemctl status $UNIT --no-pager | head -20 + exit 1 + fi + + # What the last frame of the mount left behind. + screen_dump + + # The longest message we can ever put on the splash, so the last frame is + # the worst case rather than the comfortable one. + # + # 254 and not more: the length is one byte, and plymouth's *client* asserts + # on it rather than checking (ply-boot-client.c:464), so 300 here aborts the + # plymouth command and takes the test with it. That assert is also why + # src/plymouth.rs truncates before sending - mount.bcachefs cannot reach it. + # Whether the cut lands somewhere legible is the part only a person can say. + echo + echo "and now the longest message that can be sent at all:" + plymouth display-message --text="$(printf 'x%.0s' $(seq 254))" + screen_dump + + echo + echo "leaving the splash up. plymouth quit --retain-splash to drop it," + echo "or just look. The VM stays if you passed -I." +} + # ------------------------------------------------------------------ remote unlock PASSPHRASE=correct-horse-battery-staple diff --git a/tests/kconfig.sh b/tests/kconfig.sh index dd6856b9..353ff750 100644 --- a/tests/kconfig.sh +++ b/tests/kconfig.sh @@ -402,3 +402,28 @@ require-kernel-config SECCOMP require-kernel-config SECCOMP_FILTER require-kernel-config RUST + +# A framebuffer, so plymouth loads a graphical splash plugin. +# +# Not for the graphics: it decides which plymouth *renders with*, and the two +# render very differently. With no DRM and no fbdev, plymouth falls back to the +# text plugin, whose message erase is one hardcoded line +# (view_show_message: clear_line, one cursor position) - so mount.bcachefs's +# multi-line status block scrolls forever instead of redrawing. The graphical +# plugins put the message in a label that knows its own width and height and +# redraw exactly that area, which is what the block was written for and what +# anyone with a GPU actually boots. +# +# So without this, the only splash we can look at is the one almost nobody sees. +# +# DRM_BOCHS is qemu's stdvga, which is what KTEST_GUI asks for (-vga std in +# lib/libktest.sh); DRM_VIRTIO_GPU so -device virtio-gpu also works if we +# switch. The rest of the chain comes for free: DRM_BOCHS selects +# DRM_CLIENT_SELECTION which DRM_FBDEV_EMULATION depends on, and +# drivers/gpu/drm/Kconfig has `select FB_CORE if DRM_FBDEV_EMULATION`, which is +# what lets FRAMEBUFFER_CONSOLE turn on. +require-kernel-config DRM +require-kernel-config DRM_BOCHS +require-kernel-config DRM_VIRTIO_GPU +require-kernel-config DRM_FBDEV_EMULATION +require-kernel-config FRAMEBUFFER_CONSOLE |
