summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKent Overstreet <kent.overstreet@linux.dev>2026-08-31 00:49:23 -0500
committerKent Overstreet <kent.overstreet@linux.dev>2026-08-31 00:53:50 -0500
commitfd0e84863fd7bb32524334b84c69f760a53b4691 (patch)
tree85b8ef5e8f652ba0f975bac314f478598ed0a0bc
parentb88d595f10d32fa54199d8873f8cfc3ae8263ed4 (diff)
ktest: look at the plymouth boot splash from a headless runHEADmaster
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.sh44
-rwxr-xr-xroot_image5
-rw-r--r--root_image.nix32
-rwxr-xr-xtests/fs/bcachefs/mount.ktest235
-rw-r--r--tests/kconfig.sh25
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)
diff --git a/root_image b/root_image
index d67e91dc..3570d5fb 100755
--- a/root_image
+++ b/root_image
@@ -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