[PATCH v4 4/4] selftests/mm: test fork of PMD-mapped shmem THPs
From: Konstantin Khorenko
Date: Wed Oct 21 2026 - 16:38:45 EST
Fork a process that maps shmem THPs with PMDs, in a VMA whose page
tables fork copies: a private mapping with an anon_vma, and a shared
one with a guard region. The child must find the PMDs before it
touches the mapping, and its exit must leave the parent's mapping and
the system-wide ShmemPmdMapped counter as they were.
With folio_dup_file_rmap_pmd() adding PTE mappings, the child's exit
takes the parent's share off ShmemPmdMapped while the parent still maps
the folios. Closing the file then reports "Bad page cache", or hits a
VM_BUG_ON with CONFIG_DEBUG_VM. A kernel that leaves file THPs to a
refault in the child skips the test.
The test mounts a tmpfs with huge=always over /tmp in a private mount
namespace, so it leaves the THP settings and the host's mounts and
files alone.
Signed-off-by: Konstantin Khorenko <khorenko@xxxxxxxxxxxxx>
---
MAINTAINERS | 1 +
tools/testing/selftests/mm/Makefile | 1 +
tools/testing/selftests/mm/fork_file_thp.c | 227 +++++++++++++++++++++++++++++
tools/testing/selftests/mm/run_vmtests.sh | 1 +
4 files changed, 230 insertions(+)
diff --git a/MAINTAINERS b/MAINTAINERS
index 7b5cab72d851b..1b5124e7e2bf9 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -17481,6 +17481,7 @@ F: include/trace/events/huge_memory.h
F: mm/huge_memory.c
F: mm/khugepaged.c
F: mm/mm_slot.h
+F: tools/testing/selftests/mm/fork_file_thp.c
F: tools/testing/selftests/mm/khugepaged.c
F: tools/testing/selftests/mm/split_huge_page_test.c
F: tools/testing/selftests/mm/transhuge-stress.c
diff --git a/tools/testing/selftests/mm/Makefile b/tools/testing/selftests/mm/Makefile
index 2d5366196e309..33ec3b7f4c048 100644
--- a/tools/testing/selftests/mm/Makefile
+++ b/tools/testing/selftests/mm/Makefile
@@ -104,6 +104,7 @@ TEST_GEN_FILES += guard-regions
TEST_GEN_FILES += merge
TEST_GEN_FILES += rmap
TEST_GEN_FILES += folio_split_race_test
+TEST_GEN_FILES += fork_file_thp
ifneq ($(ARCH),arm64)
TEST_GEN_FILES += soft-dirty
diff --git a/tools/testing/selftests/mm/fork_file_thp.c b/tools/testing/selftests/mm/fork_file_thp.c
new file mode 100644
index 0000000000000..76c8d1a0c9792
--- /dev/null
+++ b/tools/testing/selftests/mm/fork_file_thp.c
@@ -0,0 +1,227 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * fork() of a process that maps shmem THPs with PMDs, in a VMA whose page
+ * tables fork copies: a private mapping with an anon_vma, and a shared
+ * mapping with a guard region.
+ *
+ * The child must get the PMDs without faulting, and its exit must leave the
+ * parent's mapping and the system-wide ShmemPmdMapped counter as they were.
+ * Kernels that leave file THPs to a refault in the child skip the test.
+ */
+#define _GNU_SOURCE
+#include <asm-generic/mman.h> /* Force the import of the tools version. */
+#include <errno.h>
+#include <fcntl.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <sched.h>
+#include <sys/mman.h>
+#include <sys/mount.h>
+#include <sys/wait.h>
+
+#include "kselftest.h"
+#include "vm_util.h"
+
+#define NR_PMDS 4
+#define MAX_PMDSIZE (32UL << 20)
+/* ShmemPmdMapped is system-wide: retry if something else changed it. */
+#define ATTEMPTS 3
+
+static size_t pagesize, pmdsize;
+
+static long meminfo_kb(const char *key)
+{
+ char line[256];
+ long kb = -1;
+ FILE *f = fopen("/proc/meminfo", "r");
+
+ if (!f)
+ return -1;
+ while (fgets(line, sizeof(line), f))
+ if (!strncmp(line, key, strlen(key))) {
+ kb = strtol(line + strlen(key), NULL, 10);
+ break;
+ }
+ fclose(f);
+ return kb;
+}
+
+/* Skips the last page, which may be a guard page, and @skip. */
+static bool data_ok(const char *mem, size_t size, size_t skip)
+{
+ size_t off;
+
+ for (off = 0; off < size - pagesize; off += pagesize)
+ if (off != skip && mem[off] != (char)(off / pmdsize + 1))
+ return false;
+ return true;
+}
+
+/* Child exit codes, apart from what kselftest helpers exit with. */
+enum result { PASS, NO_PMDS = 10, FAIL_DATA, FAIL_COUNTER, FAIL_PARENT,
+ FAIL_KILLED, COUNTER_OFF, CHANGED };
+
+static enum result fork_once(char *mem, size_t size, bool shared,
+ long *before, long *after)
+{
+ /* The parent alone maps this much with PMDs. */
+ long own = (NR_PMDS - 1) * (pmdsize >> 10);
+ int status;
+ pid_t pid;
+
+ *before = *after = meminfo_kb("ShmemPmdMapped:");
+ if (*before < own)
+ return COUNTER_OFF;
+ fflush(stdout);
+ pid = fork();
+ if (pid < 0)
+ ksft_exit_fail_perror("fork");
+ if (!pid) {
+ /* Look before touching anything: the PMDs must be there. */
+ if (!check_huge_shmem(mem, size - pmdsize, NR_PMDS - 1, pmdsize))
+ _exit(NO_PMDS);
+ /* A shared retry sees the previous child's write. */
+ if (!data_ok(mem, size, pmdsize))
+ _exit(FAIL_DATA);
+ mem[pmdsize] = 'c';
+ _exit(PASS);
+ }
+ if (waitpid(pid, &status, 0) != pid)
+ ksft_exit_fail_perror("waitpid");
+ *after = meminfo_kb("ShmemPmdMapped:");
+
+ if (!WIFEXITED(status))
+ return FAIL_KILLED;
+ if (WEXITSTATUS(status) != PASS)
+ return WEXITSTATUS(status);
+ /* The child's exit took the parent's share off the counter. */
+ if (*after < own || *before - *after >= own)
+ return FAIL_COUNTER;
+ if (*after != *before)
+ return CHANGED;
+ if (mem[pmdsize] != (shared ? 'c' : 2) || !data_ok(mem, size, pmdsize) ||
+ !check_huge_shmem(mem, size - pmdsize, NR_PMDS - 1, pmdsize))
+ return FAIL_PARENT;
+ return PASS;
+}
+
+static void test_fork(bool shared)
+{
+ size_t size = NR_PMDS * pmdsize, last = size - pagesize, off;
+ const char *desc = shared ? "shared mapping with a guard region" :
+ "private mapping with an anon_vma";
+ char *buf, *area, *mem;
+ enum result res = FAIL_PARENT;
+ long before, after;
+ int fd, i;
+
+ fd = open("/tmp/fork_file_thp", O_RDWR | O_CREAT | O_TRUNC, 0600);
+ if (fd < 0)
+ ksft_exit_fail_perror("open");
+ unlink("/tmp/fork_file_thp");
+ buf = malloc(pmdsize);
+ if (!buf)
+ ksft_exit_fail_msg("malloc failed\n");
+ for (off = 0; off < size; off += pmdsize) {
+ memset(buf, off / pmdsize + 1, pmdsize);
+ if (pwrite(fd, buf, pmdsize, off) != (ssize_t)pmdsize)
+ ksft_exit_fail_perror("pwrite");
+ }
+ free(buf);
+
+ area = mmap(NULL, size + pmdsize, PROT_NONE,
+ MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
+ if (area == MAP_FAILED)
+ ksft_exit_fail_perror("mmap");
+ mem = (char *)(((unsigned long)area + pmdsize - 1) & ~(pmdsize - 1));
+ mem = mmap(mem, size, PROT_READ | PROT_WRITE,
+ (shared ? MAP_SHARED : MAP_PRIVATE) | MAP_FIXED, fd, 0);
+ if (mem == MAP_FAILED)
+ ksft_exit_fail_perror("mmap");
+ for (off = 0; off < size; off += pmdsize)
+ FORCE_READ(mem[off]);
+
+ /* Make fork copy the page tables; the last PMD gets zapped. */
+ if (shared) {
+ if (madvise(mem + last, pagesize, MADV_GUARD_INSTALL)) {
+ ksft_test_result_skip("%s: MADV_GUARD_INSTALL: %s\n",
+ desc, strerror(errno));
+ goto out;
+ }
+ } else {
+ mem[last] = 'p';
+ }
+
+ if (!check_huge_shmem(mem, size - pmdsize, NR_PMDS - 1, pmdsize)) {
+ ksft_test_result_skip("%s: shmem is not mapped with PMDs\n",
+ desc);
+ goto out;
+ }
+
+ for (i = 0; i < ATTEMPTS; i++) {
+ res = fork_once(mem, size, shared, &before, &after);
+ if (res != CHANGED)
+ break;
+ }
+
+ switch (res) {
+ case PASS:
+ ksft_test_result_pass("%s\n", desc);
+ break;
+ case NO_PMDS:
+ ksft_test_result_skip("%s: fork does not copy file THPs\n", desc);
+ break;
+ case COUNTER_OFF:
+ ksft_test_result_skip("%s: ShmemPmdMapped %ld kB is below what this process maps\n",
+ desc, before);
+ break;
+ case CHANGED:
+ ksft_test_result_skip("%s: ShmemPmdMapped keeps changing\n", desc);
+ break;
+ case FAIL_DATA:
+ ksft_test_result_fail("%s: child sees wrong data\n", desc);
+ break;
+ case FAIL_COUNTER:
+ ksft_test_result_fail("%s: ShmemPmdMapped %ld kB before fork, %ld kB after\n",
+ desc, before, after);
+ break;
+ case FAIL_PARENT:
+ ksft_test_result_fail("%s: parent's mapping changed\n", desc);
+ break;
+ case FAIL_KILLED:
+ ksft_test_result_fail("%s: child killed by a signal\n", desc);
+ break;
+ default:
+ ksft_test_result_fail("%s: child error %d\n", desc, res);
+ break;
+ }
+out:
+ munmap(area, size + pmdsize);
+ close(fd);
+}
+
+int main(void)
+{
+ ksft_print_header();
+ pagesize = getpagesize();
+ pmdsize = read_pmd_pagesize();
+ if (!pmdsize)
+ ksft_exit_skip("THP is not supported\n");
+ if (pmdsize > MAX_PMDSIZE)
+ ksft_exit_skip("PMD size %zu MiB is too large\n", pmdsize >> 20);
+ if (geteuid())
+ ksft_exit_skip("needs root to mount tmpfs\n");
+ /* The tmpfs over /tmp lives and dies with a private mount namespace. */
+ if (unshare(CLONE_NEWNS) || mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL))
+ ksft_exit_skip("private mount namespace: %s\n", strerror(errno));
+ if (mount("tmpfs", "/tmp", "tmpfs", 0, "huge=always"))
+ ksft_exit_skip("tmpfs with huge=always: %s\n", strerror(errno));
+
+ ksft_set_plan(2);
+ test_fork(false);
+ test_fork(true);
+ ksft_finished();
+}
diff --git a/tools/testing/selftests/mm/run_vmtests.sh b/tools/testing/selftests/mm/run_vmtests.sh
index d09f9f6a384ee..454a8b75a3bf3 100755
--- a/tools/testing/selftests/mm/run_vmtests.sh
+++ b/tools/testing/selftests/mm/run_vmtests.sh
@@ -445,6 +445,7 @@ fi
CATEGORY="thp" run_test ./transhuge-stress -d 20
CATEGORY="thp" run_test ./folio_split_race_test
+CATEGORY="thp" run_test ./fork_file_thp
CATEGORY="migration" run_test ./migration
--
2.43.0