Hi Breno, On Tue, Jun 30, 2026 at 05:46:09AM -0700, Breno Leitao wrote: > Add a destructive selftest that verifies > vm.panic_on_unrecoverable_memory_failure actually panics when a > hwpoison error hits a kernel-owned page. > > Three "kinds" of kernel-owned page can be targeted, selectable via > the script's first positional argument (default: rodata): > > rodata - a PG_reserved page in the kernel rodata range, sourced > from the "Kernel rodata" sub-resource of "System RAM" in > /proc/iomem. That entry is reported on every major > architecture and guarantees the chosen PFN is backed by > struct page (an online System RAM range, not a firmware > hole), is PG_reserved, and is read-only -- so even if > the panic fails to fire for some reason, the resulting > PG_hwpoison marker on rodata does not corrupt writable > kernel state. > > slab - a slab page found by walking /proc/kpageflags for the > first PFN with KPF_SLAB set (and KPF_HWPOISON / KPF_NOPAGE > / KPF_COMPOUND_TAIL clear). Exercises the get_any_page() > path on a non PG_reserved kernel-owned page and so > catches regressions where get_any_page() collapses > kernel-owned pages into a transient -EIO instead of > -ENOTRECOVERABLE. > > pgtable - same as slab, but the PFN is selected via KPF_PGTABLE. > > PageLargeKmalloc, the fourth page type matched by > is_kernel_owned_page(), is intentionally not covered: it is a > PAGE_TYPE_OPS flag with no /proc/kpageflags bit, so selecting such > a PFN from userspace is not feasible. The slab and pgtable > variants already exercise the same get_any_page() positive-check > branch. > > The script enables the sysctl and writes the selected physical > address to /sys/devices/system/memory/hard_offline_page. A > successful run crashes the kernel with > > Memory failure: <pfn>: unrecoverable page > > A return from the inject means no panic fired. Before reporting, the > script restores the sysctl and best-effort unpoisons the target PFN > through the hwpoison debugfs interface (hard_offline_page() injects > with MF_SW_SIMULATED, so the page stays unpoisonable), then re-reads > /proc/kpageflags: a PFN that is still the kernel-owned type it selected > is a genuine failure, while one that raced to a different type before > the inject is skipped as inconclusive. Test outcome is therefore > observed externally (serial console, kdump) rather than from the > script's own exit code. > > The script is intentionally NOT wired into run_vmtests.sh: every > successful run panics the kernel, which is incompatible with the > sequential "run each category in the same VM" model that > run_vmtests.sh assumes. It is also not registered as a TEST_PROGS / > ksft_* wrapper so a default kselftest run does not opt itself into > a panic. The script is meant to be executed manually inside a > disposable VM (e.g. virtme-ng), one variant per VM boot, and > requires RUN_DESTRUCTIVE=1 in the environment as a safety net. > > Signed-off-by: Breno Leitao <[email protected]> > --- > tools/testing/selftests/mm/Makefile | 4 + > tools/testing/selftests/mm/hwpoison-panic.sh | 255 > +++++++++++++++++++++++++++ > 2 files changed, 259 insertions(+) > > diff --git a/tools/testing/selftests/mm/Makefile > b/tools/testing/selftests/mm/Makefile > index e6df968f0971c..ed321ae709dac 100644 > --- a/tools/testing/selftests/mm/Makefile > +++ b/tools/testing/selftests/mm/Makefile > @@ -174,6 +174,10 @@ TEST_PROGS += ksft_userfaultfd.sh > TEST_PROGS += ksft_vma_merge.sh > TEST_PROGS += ksft_vmalloc.sh > > +# Destructive: every successful run panics the kernel. Installed and > +# kept executable, but not run from a default kselftest invocation. > +TEST_PROGS_EXTENDED += hwpoison-panic.sh > + > TEST_FILES := test_vmalloc.sh > TEST_FILES += test_hmm.sh > TEST_FILES += va_high_addr_switch.sh > diff --git a/tools/testing/selftests/mm/hwpoison-panic.sh > b/tools/testing/selftests/mm/hwpoison-panic.sh > new file mode 100755 > index 0000000000000..d953d13673324 > --- /dev/null > +++ b/tools/testing/selftests/mm/hwpoison-panic.sh > @@ -0,0 +1,255 @@ > +#!/bin/bash > +# SPDX-License-Identifier: GPL-2.0 > +# > +# Verify vm.panic_on_unrecoverable_memory_failure by injecting a hwpoison > +# error on a kernel-owned page and confirming the kernel panics. > +# > +# Three "kinds" of kernel-owned page can be targeted, selectable via the > +# first positional argument (default: rodata): > +# > +# rodata - a PG_reserved page in the kernel rodata range > +# (sourced from /proc/iomem "Kernel rodata"). Exercises > +# memory_failure() -> get_any_page() on a PageReserved page. > +# > +# slab - a slab page found via /proc/kpageflags (KPF_SLAB). > +# Exercises memory_failure() -> get_any_page() on a non > +# PG_reserved kernel-owned page. This path is what catches > +# regressions where get_any_page() collapses kernel-owned > +# pages into a transient -EIO instead of -ENOTRECOVERABLE. > +# > +# pgtable - a page-table page found via /proc/kpageflags (KPF_PGTABLE). > +# Same path as slab, different page type. > +# > +# This test is DESTRUCTIVE: a successful run crashes the kernel. It is > +# meant to be executed inside a disposable VM (e.g. virtme-ng) with a > +# serial console captured by the harness. It is skipped unless the > +# caller opts in via RUN_DESTRUCTIVE=1. > +# > +# Test passes externally: the kernel must panic with > +# "Memory failure: <pfn>: unrecoverable page" > +# A return from the inject means no panic fired: that is a failure, > +# unless the target PFN raced to a different page type before injection, > +# in which case the run is inconclusive and is skipped. > +# > +# Author: Breno Leitao <[email protected]> > + > +set -u > + > +# KTAP output helpers (ktap_print_msg, ktap_skip_all, ktap_exit_fail_msg, > ...). > +DIR="$(dirname "$(readlink -f "$0")")" > +# shellcheck source=../kselftest/ktap_helpers.sh > +source "${DIR}"/../kselftest/ktap_helpers.sh > + > +sysctl_path=/proc/sys/vm/panic_on_unrecoverable_memory_failure > +inject_path=/sys/devices/system/memory/hard_offline_page > +kpageflags_path=/proc/kpageflags > +unpoison_path=/sys/kernel/debug/hwpoison/unpoison-pfn > + > +# /proc/kpageflags bit positions (see include/uapi/linux/kernel-page-flags.h) > +KPF_SLAB=7 > +KPF_COMPOUND_TAIL=16 > +KPF_HWPOISON=19 > +KPF_NOPAGE=20 > +KPF_PGTABLE=26 > +KPF_RESERVED=32 > + > +pagesize=$(getconf PAGE_SIZE) > + > +kind=${1:-rodata} > + > +if [ "$(id -u)" -ne 0 ]; then > + ktap_skip_all "must run as root" > + exit "$KSFT_SKIP" > +fi > + > +if [ ! -w "$sysctl_path" ]; then > + ktap_skip_all "$sysctl_path not present (kernel without the sysctl?)" > + exit "$KSFT_SKIP" > +fi > + > +if [ ! -w "$inject_path" ]; then > + ktap_skip_all "$inject_path not present (no MEMORY_HOTPLUG?)" > + exit "$KSFT_SKIP" > +fi > + > +if [ "${RUN_DESTRUCTIVE:-0}" != "1" ]; then > + ktap_skip_all "destructive test; re-run with RUN_DESTRUCTIVE=1 inside a > disposable VM" > + exit "$KSFT_SKIP" > +fi > + > +# Pick a PFN inside the kernel image rodata region of /proc/iomem. > +# This is preferred over a top-level "Reserved" entry because top-level > +# Reserved ranges are often firmware holes that have no backing struct > +# page; pfn_to_online_page() returns NULL on those and memory_failure() > +# bails out with -ENXIO before reaching the panic path. > +# > +# "Kernel rodata" is reported as a sub-resource of "System RAM" on every > +# major architecture, which guarantees: > +# - the PFN is backed by struct page (within an online memory range); > +# - PG_reserved is set on the page (kernel image area); > +# - the memory is read-only, so setting PG_hwpoison on it does not > +# corrupt writable kernel state if the panic somehow does not fire. > +# > +# /proc/iomem entries look like (indented for sub-resources): > +# " 02500000-02ffffff : Kernel rodata" > +pick_rodata_phys_addr() { > + awk -v pagesize="$(getconf PAGE_SIZE)" ' > + # Convert a hex string to a number without relying on the gawk-only > + # strtonum(). mawk lacks it and would otherwise spuriously skip > + # this test on distros that ship mawk as /usr/bin/awk. > + function hex2num(s, n, i, c, v) { > + n = 0 > + for (i = 1; i <= length(s); i++) { > + c = tolower(substr(s, i, 1)) > + v = index("0123456789abcdef", c) - 1 > + if (v < 0) > + return -1 > + n = n * 16 + v > + } > + return n > + } > + /: Kernel rodata[[:space:]]*$/ { > + sub(/^[[:space:]]+/, "") > + n = split($0, a, /[- ]/) > + start = hex2num(a[1]) > + end = hex2num(a[2]) > + if (end <= start) > + next > + # Page-align upward and emit the first byte of that page. > + pfn = int((start + pagesize - 1) / pagesize) > + printf "0x%x\n", pfn * pagesize > + exit 0 > + } > + ' /proc/iomem > +} > + > +# Walk /proc/kpageflags and return the phys addr of the first PFN that > +# has bit $1 set, with KPF_HWPOISON, KPF_NOPAGE and KPF_COMPOUND_TAIL > +# all clear (so we attack a real, non-tail, not-already-poisoned page). > +# > +# We skip the first 16 MiB of PFNs to step past low-memory special > +# ranges (BIOS/EFI/ACPI/etc.) that often are PG_reserved and would not > +# exhibit the slab/pgtable type we are looking for. > +pick_kpageflags_phys_addr() { > + local want_bit=$1 > + local pagesize skip_pfn > + > + [ -r "$kpageflags_path" ] || return > + > + pagesize=$(getconf PAGE_SIZE) > + skip_pfn=$(((16 * 1024 * 1024) / pagesize)) > + > + od -An -tx8 -v -w8 -j "$((skip_pfn * 8))" "$kpageflags_path" > 2>/dev/null | \ > + awk -v want_bit="$want_bit" \ > + -v hwp_bit="$KPF_HWPOISON" \ > + -v nopage_bit="$KPF_NOPAGE" \ > + -v tail_bit="$KPF_COMPOUND_TAIL" \ > + -v base_pfn="$skip_pfn" \ > + -v pagesize="$pagesize" ' > + # Test whether bit "b" is set in the 16-hex-digit value "hex". > + # Done with substring + per-digit lookup so we never rely on awk > + # bitwise operators (mawk lacks them), 64-bit FP precision or the > + # gawk-only strtonum(). > + function bit_set(hex, b, di, bi, c, v) { > + di = int(b / 4) > + bi = b - di * 4 > + c = substr(hex, length(hex) - di, 1) > + v = index("0123456789abcdef", tolower(c)) - 1 > + if (bi == 0) return (v % 2) == 1 > + if (bi == 1) return int(v / 2) % 2 == 1 > + if (bi == 2) return int(v / 4) % 2 == 1 > + return int(v / 8) % 2 == 1 > + } > + { > + gsub(/^[[:space:]]+/, "") > + h = $1 > + if (bit_set(h, want_bit) && > + !bit_set(h, hwp_bit) && > + !bit_set(h, nopage_bit) && > + !bit_set(h, tail_bit)) { > + pfn = base_pfn + NR - 1 > + printf "0x%x\n", pfn * pagesize > + exit 0 > + } > + }
I'm looking at these awk scripts and od encodings and I wonder if wasn't it simpler to write the test in C. We have a bunch of helpers in tools/testing/selftests/mm/vm_utils.h for accessing /proc files and there is already /proc/iomem parser in tools/testing/selftests/mm/pfnmap.c that also could be lifter to vm_util -- Sincerely yours, Mike.
