A direct write to a block device that completes only partially finishes as a buffered write through the direct I/O fallback. That buffered write has to hold i_rwsem like the plain buffered write path, so that it does not race set_blocksize() raising the mapping's minimum folio order and add a folio that is too small for the mapping. This is a regression test for that issue, fixed in the kernel patch "block: take i_rwsem for the direct I/O write fallback" [1]. Issue O_DIRECT pwritev() to a memory-backed null_blk device with a two-segment iovec whose second segment is an unreadable PROT_NONE mapping, so that the direct path returns short and enters the fallback. Meanwhile toggle the second segment's protection so that some fallbacks get past the fault-in and reach the page cache, populate the page cache with folios of the current block size, and toggle the block size between 512 bytes and 64K with BLKBSZSET. A CONFIG_DEBUG_VM kernel reports the folio order mismatch as a BUG, which blktests picks up from dmesg. The minimum folio order only moves with block sizes above the page size, i.e. with CONFIG_TRANSPARENT_HUGEPAGE raising BLK_MAX_BLOCK_SIZE to 64K. [1]: https://lore.kernel.org/linux-block/20260828-blkdev-fixes-v2-2-32f3f40cebed@columbia.edu/ Signed-off-by: Tal Zussman --- src/.gitignore | 1 + src/Makefile | 7 +- src/dio-fallback-race.c | 229 ++++++++++++++++++++++++++++++++++++++++++++++++ tests/block/049 | 57 ++++++++++++ tests/block/049.out | 2 + 5 files changed, 295 insertions(+), 1 deletion(-) diff --git a/src/.gitignore b/src/.gitignore index e9869e1..754beef 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -1,5 +1,6 @@ /bio-bounce-read /bio-full-trim +/dio-fallback-race /dio-offsets /discontiguous-io /loblksize diff --git a/src/Makefile b/src/Makefile index ef9c887..f789ff6 100644 --- a/src/Makefile +++ b/src/Makefile @@ -32,6 +32,7 @@ C_TARGETS := \ C_URING_TARGETS := metadata \ nvme-passthru-admin-uring C_UBLK_TARGETS := miniublk +C_THREAD_TARGETS := dio-fallback-race HAVE_LIBURING := $(call HAVE_C_MACRO,liburing.h,IORING_OP_URING_CMD) HAVE_UBLK_HEADER := $(call HAVE_C_HEADER,linux/ublk_cmd.h,1) @@ -43,7 +44,7 @@ CXX_TARGETS := \ SYZKALLER_TARGETS := \ sg/syzkaller1 -TARGETS := $(C_TARGETS) $(CXX_TARGETS) $(SYZKALLER_TARGETS) +TARGETS := $(C_TARGETS) $(C_THREAD_TARGETS) $(CXX_TARGETS) $(SYZKALLER_TARGETS) ifeq ($(HAVE_UBLK_HEADER), 1) ifeq ($(HAVE_NEW_UBLK_INTF), 1) @@ -68,6 +69,7 @@ override CXXFLAGS := -O2 -std=c++11 -Wall -Wextra -Wshadow -Wno-sign-compare \ -Werror $(CXXFLAGS) $(CONFIG_DEFS) URING_FLAGS := -D_GNU_SOURCE URING_LIBS := -lpthread -luring +THREAD_LIBS := -lpthread LDFLAGS ?= all: $(TARGETS) @@ -88,6 +90,9 @@ $(CXX_TARGETS): %: %.cpp $(SYZKALLER_TARGETS): %: %.c $(CC) $(CFLAGS) -Wno-unused-but-set-variable $(LDFLAGS) -o $@ $^ +$(C_THREAD_TARGETS): %: %.c + $(CC) $(CPPFLAGS) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(THREAD_LIBS) + $(C_URING_TARGETS): %: %.c $(CC) $(CFLAGS) $(LDFLAGS) $(URING_FLAGS) -o $@ $^ $(URING_LIBS) diff --git a/src/dio-fallback-race.c b/src/dio-fallback-race.c new file mode 100644 index 0000000..e5221c5 --- /dev/null +++ b/src/dio-fallback-race.c @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: GPL-3.0+ +/* + * Copyright (C) 2026 Tal Zussman + * + * Race partial O_DIRECT writes to a block device against BLKBSZSET. + * + * Writer threads issue O_DIRECT pwritev() with a two-segment iovec whose + * second segment is an unreadable PROT_NONE mapping. The direct path writes + * the first segment, fails to pin the second and returns short, so the write + * finishes as a buffered write through the direct I/O fallback. A second + * thread toggles the second segment's protection so that some fallbacks get + * past fault_in_iov_iter_readable() and reach the page cache, a third + * populates the page cache with folios of the current block size, and a + * fourth toggles the block size between 512 bytes and 64K with BLKBSZSET. + * + * The fallback has to run under i_rwsem like the plain buffered write path. + * If it does not, it races set_blocksize() raising the mapping's minimum + * folio order and adds a folio that is too small for the mapping, which a + * CONFIG_DEBUG_VM kernel reports as a BUG. The caller checks dmesg. + * + * usage: dio-fallback-race + * + * exit: 0 = ran for + * 1 = setup error + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#define GOOD (8 * 1024) /* written by the direct path */ +#define BAD (64 * 1024) /* unreadable, forces a short write */ +#define RANGE (2 * 1024 * 1024) /* keep the race on a few folios */ +#define NR_WRITERS 2 + +#define SMALL_BS 512 +#define LARGE_BS (64 * 1024) + +static const char *dev; +static long pgsz; +static char *badseg; +static int bszfd; +static volatile int stop; +static int failed; + +/* partial direct write, finished as a buffered write by the fallback */ +static void *writer(void *arg) +{ + struct iovec iov[2]; + off_t off = 0; + char *good; + int fd; + + fd = open(dev, O_RDWR | O_DIRECT); + if (fd < 0) { + perror("open"); + failed = 1; + return NULL; + } + + if (posix_memalign((void **)&good, pgsz, GOOD)) { + perror("posix_memalign"); + failed = 1; + return NULL; + } + memset(good, 'A', GOOD); + + iov[0].iov_base = good; + iov[0].iov_len = GOOD; + iov[1].iov_base = badseg; + iov[1].iov_len = BAD; + + while (!stop) { + if (pwritev(fd, iov, 2, off) < 0) { + perror("pwritev"); + failed = 1; + break; + } + off = (off + GOOD) % RANGE; + } + + return NULL; +} + +/* let some fallbacks get past the fault-in and into the page cache */ +static void *flipper(void *arg) +{ + while (!stop) { + if (mprotect(badseg, BAD, PROT_READ | PROT_WRITE) || + mprotect(badseg, BAD, PROT_NONE)) { + perror("mprotect"); + failed = 1; + break; + } + } + + return NULL; +} + +/* populate the page cache with folios sized for the current block size */ +static void *reader(void *arg) +{ + off_t off = 0; + char *buf; + int fd; + + fd = open(dev, O_RDONLY); + if (fd < 0) { + perror("open"); + failed = 1; + return NULL; + } + + buf = malloc(GOOD); + if (!buf) { + perror("malloc"); + failed = 1; + return NULL; + } + + while (!stop) { + if (pread(fd, buf, GOOD, off) < 0) { + perror("pread"); + failed = 1; + break; + } + readahead(fd, off, RANGE / 4); + off = (off + GOOD) % RANGE; + } + + return NULL; +} + +/* change i_blkbits and the mapping's minimum folio order underneath them */ +static void *resizer(void *arg) +{ + int bs = SMALL_BS; + + while (!stop) { + if (ioctl(bszfd, BLKBSZSET, &bs)) { + perror("BLKBSZSET"); + failed = 1; + break; + } + bs = bs == SMALL_BS ? LARGE_BS : SMALL_BS; + } + + return NULL; +} + +static int spawn(pthread_t *t, void *(*fn)(void *)) +{ + int err = pthread_create(t, NULL, fn, NULL); + + if (err) + fprintf(stderr, "pthread_create: %s\n", strerror(err)); + + return err; +} + +int main(int argc, char **argv) +{ + pthread_t writers[NR_WRITERS]; + pthread_t flipper_t, reader_t, resizer_t; + int bs = LARGE_BS; + int i; + + if (argc != 3) { + fprintf(stderr, "usage: %s \n", argv[0]); + return EXIT_FAILURE; + } + + dev = argv[1]; + + pgsz = sysconf(_SC_PAGESIZE); + if (pgsz < 0) { + perror("sysconf"); + return EXIT_FAILURE; + } + + bszfd = open(dev, O_RDONLY); + if (bszfd < 0) { + perror("open"); + return EXIT_FAILURE; + } + + /* + * The minimum folio order only moves with block sizes above the page + * size, which needs BLK_MAX_BLOCK_SIZE above PAGE_SIZE, i.e. + * CONFIG_TRANSPARENT_HUGEPAGE. + */ + if (ioctl(bszfd, BLKBSZSET, &bs)) { + perror("BLKBSZSET"); + return EXIT_FAILURE; + } + + badseg = mmap(NULL, BAD, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (badseg == MAP_FAILED) { + perror("mmap"); + return EXIT_FAILURE; + } + + for (i = 0; i < NR_WRITERS; i++) { + if (spawn(&writers[i], writer)) + return EXIT_FAILURE; + } + if (spawn(&flipper_t, flipper) || spawn(&reader_t, reader) || + spawn(&resizer_t, resizer)) + return EXIT_FAILURE; + + sleep(atoi(argv[2])); + stop = 1; + + for (i = 0; i < NR_WRITERS; i++) + pthread_join(writers[i], NULL); + pthread_join(flipper_t, NULL); + pthread_join(reader_t, NULL); + pthread_join(resizer_t, NULL); + + return failed ? EXIT_FAILURE : EXIT_SUCCESS; +} diff --git a/tests/block/049 b/tests/block/049 new file mode 100755 index 0000000..aa5622a --- /dev/null +++ b/tests/block/049 @@ -0,0 +1,57 @@ +#!/bin/bash +# SPDX-License-Identifier: GPL-3.0+ +# Copyright (C) 2026 Tal Zussman +# +# Race partial O_DIRECT writes to a block device against BLKBSZSET. A direct +# write that completes only partially finishes as a buffered write through the +# direct I/O fallback, which has to hold i_rwsem like the plain buffered write +# path so that it does not race set_blocksize() raising the mapping's minimum +# folio order. Without it, the fallback adds a folio that is too small for the +# mapping, which a CONFIG_DEBUG_VM kernel reports as a BUG. +# +# Regression test for patch "block: take i_rwsem for the direct I/O write +# fallback". + +. tests/block/rc +. common/null_blk + +DESCRIPTION="race the direct I/O write fallback against BLKBSZSET" +TIMED=1 + +requires() { + _have_null_blk + _have_kernel_option TRANSPARENT_HUGEPAGE + _have_kernel_option DEBUG_VM + _have_src_program dio-fallback-race + if (( $(_get_page_size) >= 65536 )); then + SKIP_REASONS+=("a 64K block size is not above the page size") + return 1 + fi +} + +test() { + echo "Running ${TEST_NAME}" + + # the race hits about once a minute on an unfixed kernel + : "${TIMEOUT:=60}" + + if ! _configure_null_blk nullb1 blocksize=512 memory_backed=1 \ + size=64 power=1; then + echo "configuring null_blk failed" + return 1 + fi + + if ! blockdev --setbsz 65536 /dev/nullb1; then + SKIP_REASONS+=("kernel does not support a 64K block size") + _exit_null_blk + return + fi + + if ! src/dio-fallback-race /dev/nullb1 "${TIMEOUT}" >>"${FULL}" 2>&1; then + echo "dio-fallback-race helper failed" + fi + + _exit_null_blk + + echo "Test complete" +} diff --git a/tests/block/049.out b/tests/block/049.out new file mode 100644 index 0000000..c88edfe --- /dev/null +++ b/tests/block/049.out @@ -0,0 +1,2 @@ +Running block/049 +Test complete -- 2.39.5 The block device splice read path has to hold i_rwsem like the plain read path, so that it does not race set_blocksize() raising the mapping's minimum folio order and add a folio that is too small for the mapping. This is a regression test for that issue, fixed in the kernel patch "block: take i_rwsem for the splice read path" [1]. splice() from a memory-backed null_blk device into a pipe while toggling the block size between 512 bytes and 64K with BLKBSZSET. A CONFIG_DEBUG_VM kernel reports the folio order mismatch as a BUG, which blktests picks up from dmesg. The minimum folio order only moves with block sizes above the page size, i.e. with CONFIG_TRANSPARENT_HUGEPAGE raising BLK_MAX_BLOCK_SIZE to 64K. [1]: https://lore.kernel.org/linux-block/20260828-blkdev-fixes-v2-3-32f3f40cebed@columbia.edu/ Signed-off-by: Tal Zussman --- src/.gitignore | 1 + src/Makefile | 3 +- src/splice-race.c | 171 ++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/block/050 | 54 +++++++++++++++++ tests/block/050.out | 2 + 5 files changed, 230 insertions(+), 1 deletion(-) diff --git a/src/.gitignore b/src/.gitignore index 754beef..dbebc22 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -11,6 +11,7 @@ /mount_clear_sock /nbdsetsize /openclose +/splice-race /sg/dxfer-from-dev /sg/syzkaller1 /zbdioctl diff --git a/src/Makefile b/src/Makefile index f789ff6..328f2c5 100644 --- a/src/Makefile +++ b/src/Makefile @@ -32,7 +32,8 @@ C_TARGETS := \ C_URING_TARGETS := metadata \ nvme-passthru-admin-uring C_UBLK_TARGETS := miniublk -C_THREAD_TARGETS := dio-fallback-race +C_THREAD_TARGETS := dio-fallback-race \ + splice-race HAVE_LIBURING := $(call HAVE_C_MACRO,liburing.h,IORING_OP_URING_CMD) HAVE_UBLK_HEADER := $(call HAVE_C_HEADER,linux/ublk_cmd.h,1) diff --git a/src/splice-race.c b/src/splice-race.c new file mode 100644 index 0000000..e411752 --- /dev/null +++ b/src/splice-race.c @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: GPL-3.0+ +/* + * Copyright (C) 2026 Tal Zussman + * + * Race splice() from a block device against BLKBSZSET. + * + * Splicer threads splice from the device into a pipe while another thread + * toggles the block size between 512 bytes and 64K with BLKBSZSET. + * + * The splice read path has to run under i_rwsem like the plain read path. + * If it does not, it races set_blocksize() raising the mapping's minimum + * folio order and adds a folio that is too small for the mapping, which a + * CONFIG_DEBUG_VM kernel reports as a BUG. The caller checks dmesg. + * + * usage: splice-race + * + * exit: 0 = ran for + * 1 = setup error + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +#include + +#define CHUNK (64 * 1024) +#define RANGE (2 * 1024 * 1024) /* keep the race on a few folios */ +#define NR_SPLICERS 4 + +#define SMALL_BS 512 +#define LARGE_BS (64 * 1024) + +static const char *dev; +static int bszfd; +static volatile int stop; +static int failed; + +/* filemap_splice_read() from the device */ +static void *splicer(void *arg) +{ + int pipefd[2]; + loff_t off = 0; + char *sink; + int fd; + + fd = open(dev, O_RDONLY); + if (fd < 0) { + perror("open"); + failed = 1; + return NULL; + } + + if (pipe(pipefd)) { + perror("pipe"); + failed = 1; + return NULL; + } + + sink = malloc(CHUNK); + if (!sink) { + perror("malloc"); + failed = 1; + return NULL; + } + + while (!stop) { + ssize_t n = splice(fd, &off, pipefd[1], NULL, CHUNK, 0); + + if (n < 0) { + perror("splice"); + failed = 1; + break; + } + + /* drain the pipe so the next splice does not block on it */ + while (n > 0) { + ssize_t d = read(pipefd[0], sink, n); + + if (d <= 0) { + perror("read"); + failed = 1; + return NULL; + } + n -= d; + } + + if (off >= RANGE) + off = 0; + } + + return NULL; +} + +/* change i_blkbits and the mapping's minimum folio order underneath them */ +static void *resizer(void *arg) +{ + int bs = SMALL_BS; + + while (!stop) { + if (ioctl(bszfd, BLKBSZSET, &bs)) { + perror("BLKBSZSET"); + failed = 1; + break; + } + bs = bs == SMALL_BS ? LARGE_BS : SMALL_BS; + } + + return NULL; +} + +static int spawn(pthread_t *t, void *(*fn)(void *)) +{ + int err = pthread_create(t, NULL, fn, NULL); + + if (err) + fprintf(stderr, "pthread_create: %s\n", strerror(err)); + + return err; +} + +int main(int argc, char **argv) +{ + pthread_t splicers[NR_SPLICERS]; + pthread_t resizer_t; + int bs = LARGE_BS; + int i; + + if (argc != 3) { + fprintf(stderr, "usage: %s \n", argv[0]); + return EXIT_FAILURE; + } + + dev = argv[1]; + + bszfd = open(dev, O_RDONLY); + if (bszfd < 0) { + perror("open"); + return EXIT_FAILURE; + } + + /* + * The minimum folio order only moves with block sizes above the page + * size, which needs BLK_MAX_BLOCK_SIZE above PAGE_SIZE, i.e. + * CONFIG_TRANSPARENT_HUGEPAGE. + */ + if (ioctl(bszfd, BLKBSZSET, &bs)) { + perror("BLKBSZSET"); + return EXIT_FAILURE; + } + + for (i = 0; i < NR_SPLICERS; i++) { + if (spawn(&splicers[i], splicer)) + return EXIT_FAILURE; + } + if (spawn(&resizer_t, resizer)) + return EXIT_FAILURE; + + sleep(atoi(argv[2])); + stop = 1; + + for (i = 0; i < NR_SPLICERS; i++) + pthread_join(splicers[i], NULL); + pthread_join(resizer_t, NULL); + + return failed ? EXIT_FAILURE : EXIT_SUCCESS; +} diff --git a/tests/block/050 b/tests/block/050 new file mode 100755 index 0000000..64c0406 --- /dev/null +++ b/tests/block/050 @@ -0,0 +1,54 @@ +#!/bin/bash +# SPDX-License-Identifier: GPL-3.0+ +# Copyright (C) 2026 Tal Zussman +# +# Race splice() from a block device against BLKBSZSET. The splice read path +# has to hold i_rwsem like the plain read path so that it does not race +# set_blocksize() raising the mapping's minimum folio order. Without it, the +# splice adds a folio that is too small for the mapping, which a +# CONFIG_DEBUG_VM kernel reports as a BUG. +# +# Regression test for patch "block: take i_rwsem for the splice read path". + +. tests/block/rc +. common/null_blk + +DESCRIPTION="race splice() from a block device against BLKBSZSET" +TIMED=1 + +requires() { + _have_null_blk + _have_kernel_option TRANSPARENT_HUGEPAGE + _have_kernel_option DEBUG_VM + _have_src_program splice-race + if (( $(_get_page_size) >= 65536 )); then + SKIP_REASONS+=("a 64K block size is not above the page size") + return 1 + fi +} + +test() { + echo "Running ${TEST_NAME}" + + : "${TIMEOUT:=30}" + + if ! _configure_null_blk nullb1 blocksize=512 memory_backed=1 \ + size=64 power=1; then + echo "configuring null_blk failed" + return 1 + fi + + if ! blockdev --setbsz 65536 /dev/nullb1; then + SKIP_REASONS+=("kernel does not support a 64K block size") + _exit_null_blk + return + fi + + if ! src/splice-race /dev/nullb1 "${TIMEOUT}" >>"${FULL}" 2>&1; then + echo "splice-race helper failed" + fi + + _exit_null_blk + + echo "Test complete" +} diff --git a/tests/block/050.out b/tests/block/050.out new file mode 100644 index 0000000..fc4e537 --- /dev/null +++ b/tests/block/050.out @@ -0,0 +1,2 @@ +Running block/050 +Test complete -- 2.39.5 bio_iov_iter_align_down() trims a direct I/O bio down to the logical block size and unpins the pages it drops. A bvec can span several pages of one folio, each with its own pin, and dropping or shrinking such a bvec has to release all of them. When it does not, the folio is never freed. This is a regression test for that issue, fixed in the kernel patch "block: unpin all pages of a bvec in bio_iov_iter_align_down()" [1]. Issue O_DIRECT pwritev() from a hugetlb mapping to a memory-backed null_blk device with a 64K logical block size, with a first segment that ends half a block past a block boundary and an unreadable second segment, so that the bio is trimmed by several pages of one huge page. Place the first segment across two huge pages so the tail is its own bvec and gets dropped, and inside one huge page so the tail is the end of a larger bvec and gets shrunk, to cover both paths. Compare HugePages_Free before and after, and fail if huge pages leaked. The leak needs a logical block size above the page size, i.e. CONFIG_TRANSPARENT_HUGEPAGE raising BLK_MAX_BLOCK_SIZE to 64K, and a large folio backing the buffer, which hugetlb provides. Reserve huge pages for the test and restore the previous count afterwards. [1]: https://lore.kernel.org/linux-block/20260828-blkdev-fixes-v2-6-32f3f40cebed@columbia.edu/ Signed-off-by: Tal Zussman --- src/.gitignore | 1 + src/Makefile | 1 + src/bio-trim-pin-leak.c | 191 ++++++++++++++++++++++++++++++++++++++++++++++++ tests/block/051 | 64 ++++++++++++++++ tests/block/051.out | 2 + 5 files changed, 259 insertions(+) diff --git a/src/.gitignore b/src/.gitignore index dbebc22..92c08d1 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -1,5 +1,6 @@ /bio-bounce-read /bio-full-trim +/bio-trim-pin-leak /dio-fallback-race /dio-offsets /discontiguous-io diff --git a/src/Makefile b/src/Makefile index 328f2c5..aba50f7 100644 --- a/src/Makefile +++ b/src/Makefile @@ -15,6 +15,7 @@ HAVE_C_DEF = $(shell if echo -e "$(H)include <$(1)>\n$(H)ifdef $(2)\nHAVE_$(2)\n C_TARGETS := \ bio-bounce-read \ bio-full-trim \ + bio-trim-pin-leak \ dio-offsets \ loblksize \ loop_change_fd \ diff --git a/src/bio-trim-pin-leak.c b/src/bio-trim-pin-leak.c new file mode 100644 index 0000000..54af181 --- /dev/null +++ b/src/bio-trim-pin-leak.c @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: GPL-3.0+ +/* + * Copyright (C) 2026 Tal Zussman + * + * Check that a direct write trimmed to the logical block size unpins all the + * pages it drops. + * + * Issue O_DIRECT pwritev() from a hugetlb mapping with a two-segment iovec + * whose first segment ends half a block past a block boundary and whose + * second segment is an unreadable PROT_NONE mapping. The direct path pins the + * first segment, fails to pin the second and trims the bio down to a block + * boundary. The trimmed tail spans several pages of one huge page, each with + * its own pin. If the trim releases at most one of them, the huge page + * never returns to the pool, which shows up as a drop in HugePages_Free. + * + * The tail is either its own bvec, when the first segment straddles two huge + * pages, or the end of a larger one, when it sits inside a single huge page. + * Each iteration issues one write of each kind. The direct I/O fallback + * finishes the trimmed half block through the page cache, so each write + * returns one and a half blocks. + * + * The tail only holds several pins when it spans several pages of a large + * folio, so the device needs a logical block size of at least four pages and + * the buffer comes from a hugetlb mapping. + * + * usage: bio-trim-pin-leak + * + * exit: 0 = no huge pages leaked + * 1 = setup error + * 2 = huge pages leaked + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#define EXIT_LEAKED 2 + +static long meminfo(const char *key) +{ + char line[256]; + long val = -1; + FILE *f; + + f = fopen("/proc/meminfo", "r"); + if (!f) { + perror("fopen"); + return -1; + } + + while (fgets(line, sizeof(line), f)) { + if (!strncmp(line, key, strlen(key))) { + val = strtol(line + strlen(key), NULL, 10); + break; + } + } + + fclose(f); + return val; +} + +int main(int argc, char **argv) +{ + struct iovec iov[2]; + long pgsz, hpsz; + long before, after; + char *badseg; + char *map; + int lbs, iters; + int fd, i; + + if (argc != 3) { + fprintf(stderr, "usage: %s \n", argv[0]); + return EXIT_FAILURE; + } + + iters = atoi(argv[2]); + + pgsz = sysconf(_SC_PAGESIZE); + if (pgsz < 0) { + perror("sysconf"); + return EXIT_FAILURE; + } + + hpsz = meminfo("Hugepagesize:") * 1024; + if (hpsz <= 0) { + fprintf(stderr, "no hugetlb page size in /proc/meminfo\n"); + return EXIT_FAILURE; + } + + fd = open(argv[1], O_RDWR | O_DIRECT); + if (fd < 0) { + perror("open"); + return EXIT_FAILURE; + } + + if (ioctl(fd, BLKSSZGET, &lbs)) { + perror("BLKSSZGET"); + return EXIT_FAILURE; + } + printf("logical block size: %d, page size: %ld, huge page size: %ld\n", + lbs, pgsz, hpsz); + + /* + * The trimmed tail is half a block, and it has to span at least two + * pages for a pin to leak. + */ + if (lbs < 4 * pgsz) { + fprintf(stderr, "logical block size %d is below four pages\n", + lbs); + return EXIT_FAILURE; + } + if (hpsz < 2 * lbs) { + fprintf(stderr, "huge page size %ld is below two blocks\n", hpsz); + return EXIT_FAILURE; + } + + badseg = mmap(NULL, lbs, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (badseg == MAP_FAILED) { + perror("mmap"); + return EXIT_FAILURE; + } + + before = meminfo("HugePages_Free:"); + if (before < 2 * iters + 2) { + fprintf(stderr, "HugePages_Free is %ld, need at least %d\n", + before, 2 * iters + 2); + return EXIT_FAILURE; + } + + for (i = 0; i < iters; i++) { + map = mmap(NULL, 2 * hpsz, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0); + if (map == MAP_FAILED) { + perror("mmap"); + return EXIT_FAILURE; + } + memset(map, 'A', 2 * hpsz); + + iov[0].iov_len = lbs + lbs / 2; + iov[1].iov_base = badseg; + iov[1].iov_len = lbs / 2; + + /* + * One block from the end of the first huge page and half a + * block from the start of the second, then an unreadable + * segment: the bio is trimmed by half a block, dropping the + * bvec pinned from the second huge page. + */ + iov[0].iov_base = map + hpsz - lbs; + if (pwritev(fd, iov, 2, 0) != lbs + lbs / 2) { + perror("pwritev"); + return EXIT_FAILURE; + } + + /* + * The same write from the start of the first huge page: the + * pinned segment is a single bvec, and the trim shrinks it + * by half a block instead of dropping one. + */ + iov[0].iov_base = map; + if (pwritev(fd, iov, 2, 0) != lbs + lbs / 2) { + perror("pwritev"); + return EXIT_FAILURE; + } + + if (munmap(map, 2 * hpsz)) { + perror("munmap"); + return EXIT_FAILURE; + } + } + + after = meminfo("HugePages_Free:"); + printf("HugePages_Free: %ld -> %ld over %d iterations\n", + before, after, iters); + + if (after < before) { + printf("%ld huge pages leaked\n", before - after); + return EXIT_LEAKED; + } + + printf("no huge pages leaked\n"); + return EXIT_SUCCESS; +} diff --git a/tests/block/051 b/tests/block/051 new file mode 100755 index 0000000..a4edec2 --- /dev/null +++ b/tests/block/051 @@ -0,0 +1,64 @@ +#!/bin/bash +# SPDX-License-Identifier: GPL-3.0+ +# Copyright (C) 2026 Tal Zussman +# +# Issue partial O_DIRECT writes from a hugetlb mapping to a block device with +# a 64K logical block size and check that no huge pages leak. The bio of such +# a write is trimmed down to a block boundary, and the pages it drops each +# hold their own pin when a large folio backs the buffer. If the trim +# releases at most one of them, whether it drops a whole bvec or shrinks one, +# the huge page never returns to the pool. +# +# Regression test for patch "block: unpin all pages of a bvec in +# bio_iov_iter_align_down()". + +. tests/block/rc +. common/null_blk + +DESCRIPTION="check for pinned page leaks from trimmed direct writes" +QUICK=1 + +requires() { + _have_null_blk + _have_kernel_option TRANSPARENT_HUGEPAGE + _have_kernel_option HUGETLBFS + _have_src_program bio-trim-pin-leak + if (( $(_get_page_size) > 16384 )); then + SKIP_REASONS+=("a 64K block size is below four pages") + return 1 + fi +} + +test() { + echo "Running ${TEST_NAME}" + + local nr_hugepages ret + + if ! _configure_null_blk nullb1 blocksize=65536 memory_backed=1 \ + size=64 power=1; then + SKIP_REASONS+=("null_blk does not support a 64K block size") + return + fi + + nr_hugepages=$(cat /proc/sys/vm/nr_hugepages) + echo $((nr_hugepages + 40)) > /proc/sys/vm/nr_hugepages + + src/bio-trim-pin-leak /dev/nullb1 16 >>"${FULL}" 2>&1 + ret=$? + + echo "${nr_hugepages}" > /proc/sys/vm/nr_hugepages + _exit_null_blk + + case $ret in + 0) + ;; + 2) + echo "huge pages leaked by trimmed direct writes" + ;; + *) + echo "bio-trim-pin-leak helper failed" + ;; + esac + + echo "Test complete" +} diff --git a/tests/block/051.out b/tests/block/051.out new file mode 100644 index 0000000..e9a2745 --- /dev/null +++ b/tests/block/051.out @@ -0,0 +1,2 @@ +Running block/051 +Test complete -- 2.39.5