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.

Reply via email to