[RFC PATCH 4/4] selftests: filesystems: add blkdev writeback coalescing test
From: Lukas Sichert
Date: Fri Oct 09 2026 - 06:01:11 EST
Test bio coalescing in blkdev_writepages() and buffer_head batching
within a folio, using buffered writes to a loop device with a block
size below PAGE_SIZE. Contiguous, gapped and scattered writes are
checked against the backing file, and a concurrent writer/fsync run
checks for kernel warnings and crashes.
Signed-off-by: Lukas Sichert <l.sichert@xxxxxxxxxxx>
---
.../testing/selftests/filesystems/.gitignore | 1 +
tools/testing/selftests/filesystems/Makefile | 2 +-
.../filesystems/blkdev_writeback_test.c | 259 ++++++++++++++++++
3 files changed, 261 insertions(+), 1 deletion(-)
create mode 100644 tools/testing/selftests/filesystems/blkdev_writeback_test.c
diff --git a/tools/testing/selftests/filesystems/.gitignore b/tools/testing/selftests/filesystems/.gitignore
index 9eb185fb2f9d..ed90ff2bf086 100644
--- a/tools/testing/selftests/filesystems/.gitignore
+++ b/tools/testing/selftests/filesystems/.gitignore
@@ -7,3 +7,4 @@ anon_inode_test
kernfs_test
idmapped_tmpfile
ustat_test
+blkdev_writeback_test
diff --git a/tools/testing/selftests/filesystems/Makefile b/tools/testing/selftests/filesystems/Makefile
index 03be337c1f35..4236aa18a73b 100644
--- a/tools/testing/selftests/filesystems/Makefile
+++ b/tools/testing/selftests/filesystems/Makefile
@@ -2,7 +2,7 @@
CFLAGS += $(KHDR_INCLUDES)
TEST_GEN_PROGS := devpts_pts file_stressor anon_inode_test kernfs_test fclog ustat_test
-TEST_GEN_PROGS += idmapped_tmpfile
+TEST_GEN_PROGS += idmapped_tmpfile blkdev_writeback_test
TEST_GEN_PROGS_EXTENDED := dnotify_test
include ../lib.mk
diff --git a/tools/testing/selftests/filesystems/blkdev_writeback_test.c b/tools/testing/selftests/filesystems/blkdev_writeback_test.c
new file mode 100644
index 000000000000..307d67b150e6
--- /dev/null
+++ b/tools/testing/selftests/filesystems/blkdev_writeback_test.c
@@ -0,0 +1,259 @@
+// SPDX-License-Identifier: GPL-2.0
+#define _GNU_SOURCE
+
+#include <fcntl.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+#include <sys/ioctl.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+
+#include <linux/fs.h>
+#include <linux/loop.h>
+
+#include "kselftest_harness.h"
+
+#define DEV_SIZE (4 * 1024 * 1024)
+#define SOFT_BLKSIZE 1024U /* < PAGE_SIZE: several buffer_heads/folio */
+
+#define STRESS_DURATION_SEC 30
+#define STRESS_MAX_CHUNK_BLKS 8
+
+FIXTURE(blkdev_writeback) {
+ int fd_backing;
+ int fd_dev;
+ char dev_path[32];
+};
+
+static void fill_pattern(char *buf, size_t len, unsigned seed)
+{
+ size_t i;
+
+ for (i = 0; i < len; i++)
+ buf[i] = (char)(seed + i);
+}
+
+static void write_pattern(struct __test_metadata *_metadata,
+ FIXTURE_DATA(blkdev_writeback) *self,
+ off_t offset, size_t len, unsigned seed)
+{
+ char *buf = malloc(len);
+
+ ASSERT_NE(buf, NULL);
+ fill_pattern(buf, len, seed);
+ ASSERT_EQ(pwrite(self->fd_dev, buf, len, offset), (ssize_t)len);
+ free(buf);
+}
+
+static void verify_pattern(struct __test_metadata *_metadata,
+ FIXTURE_DATA(blkdev_writeback) *self,
+ off_t offset, size_t len, unsigned seed)
+{
+ char *expected = malloc(len);
+ char *actual = malloc(len);
+ size_t i;
+
+ ASSERT_NE(expected, NULL);
+ ASSERT_NE(actual, NULL);
+ fill_pattern(expected, len, seed);
+
+ ASSERT_EQ(fsync(self->fd_dev), 0);
+ ASSERT_EQ(pread(self->fd_backing, actual, len, offset), (ssize_t)len);
+
+ for (i = 0; i < len; i++)
+ ASSERT_EQ(actual[i], expected[i])
+ TH_LOG("mismatch at backing file offset %zu",
+ (size_t)offset + i);
+
+ free(expected);
+ free(actual);
+}
+
+static void verify_zero(struct __test_metadata *_metadata,
+ FIXTURE_DATA(blkdev_writeback) *self,
+ off_t offset, size_t len)
+{
+ char *actual = malloc(len);
+ size_t i;
+
+ ASSERT_NE(actual, NULL);
+ ASSERT_EQ(pread(self->fd_backing, actual, len, offset), (ssize_t)len);
+
+ for (i = 0; i < len; i++)
+ ASSERT_EQ(actual[i], 0)
+ TH_LOG("unexpected write at backing file offset %zu",
+ (size_t)offset + i);
+
+ free(actual);
+}
+
+FIXTURE_SETUP(blkdev_writeback)
+{
+ int fd_ctl, loop_nr, blksize = SOFT_BLKSIZE;
+ char template[] = "/tmp/blkdev_writeback_test_XXXXXX";
+ struct loop_config config = { 0 };
+
+ self->fd_backing = -1;
+ self->fd_dev = -1;
+
+ if (geteuid() != 0)
+ SKIP(return, "Test requires root to attach a loop device");
+
+ self->fd_backing = mkstemp(template);
+ ASSERT_GE(self->fd_backing, 0);
+ ASSERT_EQ(unlink(template), 0);
+ ASSERT_EQ(ftruncate(self->fd_backing, DEV_SIZE), 0);
+
+ fd_ctl = open("/dev/loop-control", O_RDWR);
+ if (fd_ctl < 0)
+ SKIP(return, "Loop device support is not available");
+ loop_nr = ioctl(fd_ctl, LOOP_CTL_GET_FREE);
+ ASSERT_GE(loop_nr, 0);
+ ASSERT_EQ(close(fd_ctl), 0);
+
+ snprintf(self->dev_path, sizeof(self->dev_path), "/dev/loop%d", loop_nr);
+ self->fd_dev = open(self->dev_path, O_RDWR);
+ ASSERT_GE(self->fd_dev, 0);
+
+ config.fd = self->fd_backing;
+ ASSERT_EQ(ioctl(self->fd_dev, LOOP_CONFIGURE, &config), 0);
+ ASSERT_EQ(ioctl(self->fd_dev, BLKBSZSET, &blksize), 0);
+}
+
+FIXTURE_TEARDOWN(blkdev_writeback)
+{
+ if (self->fd_dev >= 0) {
+ fsync(self->fd_dev);
+ ioctl(self->fd_dev, LOOP_CLR_FD);
+ close(self->fd_dev);
+ }
+ if (self->fd_backing >= 0)
+ close(self->fd_backing);
+}
+
+/*
+ * A single write spanning many contiguous folios should still read back
+ * correctly regardless of how many bios blkdev_writepages() coalesced it
+ * into.
+ */
+TEST_F(blkdev_writeback, contiguous_multi_folio)
+{
+ write_pattern(_metadata, self, 0, 512 * 1024, 0);
+ verify_pattern(_metadata, self, 0, 512 * 1024, 0);
+}
+
+/*
+ * Two writes separated by an untouched gap must each land correctly,this means the
+ * non-contiguous block run must close out the current bio/run rather than merge across
+ * the hole.
+ */
+TEST_F(blkdev_writeback, gapped_regions)
+{
+ const off_t region_a = 0;
+ const off_t region_b = 192 * 1024;
+ const size_t region_len = 64 * 1024;
+ const off_t gap = 64 * 1024;
+ const size_t gap_len = region_b - gap;
+
+ write_pattern(_metadata, self, region_a, region_len, 0xa5);
+ write_pattern(_metadata, self, region_b, region_len, 0x3c);
+
+ verify_pattern(_metadata, self, region_a, region_len, 0xa5);
+ verify_pattern(_metadata, self, region_b, region_len, 0x3c);
+ verify_zero(_metadata, self, gap, gap_len);
+}
+
+/*
+ * Many small, mutually non-adjacent writes force repeated run/bio splits
+ * in __bh_bio_append(), stressing the tracking of buffer runs within a
+ * coalesced bio.
+ */
+TEST_F(blkdev_writeback, scattered_writes)
+{
+ const size_t chunk_len = 4096;
+ const unsigned nr_chunks = 32;
+ const size_t stride = DEV_SIZE / nr_chunks;
+ unsigned i;
+
+ for (i = 0; i < nr_chunks; i++) {
+ /* write in reverse order so nothing is naturally contiguous */
+ unsigned idx = nr_chunks - 1 - i;
+
+ write_pattern(_metadata, self, idx * stride, chunk_len, idx);
+ }
+
+ for (i = 0; i < nr_chunks; i++)
+ verify_pattern(_metadata, self, i * stride, chunk_len, i);
+}
+
+static void stress_writer(FIXTURE_DATA(blkdev_writeback) *self)
+{
+ char buf[STRESS_MAX_CHUNK_BLKS * SOFT_BLKSIZE];
+ unsigned seed = getpid();
+
+ srand(seed);
+ for (;;) {
+ size_t blks = 1 + rand_r(&seed) % STRESS_MAX_CHUNK_BLKS;
+ size_t len = blks * SOFT_BLKSIZE;
+ size_t max_off_blks = (DEV_SIZE - len) / SOFT_BLKSIZE;
+ off_t off = (rand_r(&seed) % max_off_blks) * SOFT_BLKSIZE;
+
+ fill_pattern(buf, len, seed);
+ if (pwrite(self->fd_dev, buf, len, off) != (ssize_t)len)
+ exit(1);
+ }
+}
+
+static void stress_syncer(FIXTURE_DATA(blkdev_writeback) *self)
+{
+ for (;;) {
+ if (fsync(self->fd_dev) < 0)
+ exit(1);
+ }
+}
+
+/*
+ * Concurrent writers dirtying overlapping, adjacent and disjoint ranges
+ * while syncers repeatedly force writing the bio. Data content is not checked
+ * (writes overlap by design), the point is to survive the duration without a
+ * kernel-side warning or crash.
+ */
+TEST_F_TIMEOUT(blkdev_writeback, stress_concurrent_writeback, STRESS_DURATION_SEC + 30)
+{
+ long nr_procs = sysconf(_SC_NPROCESSORS_ONLN);
+ pid_t pids[2 * nr_procs];
+ int i;
+
+ for (i = 0; i < nr_procs; i++) {
+ pids[i] = fork();
+ ASSERT_GE(pids[i], 0);
+ if (pids[i] == 0)
+ stress_writer(self);
+ }
+ for (i = 0; i < nr_procs; i++) {
+ pids[nr_procs + i] = fork();
+ ASSERT_GE(pids[nr_procs + i], 0);
+ if (pids[nr_procs + i] == 0)
+ stress_syncer(self);
+ }
+
+ ASSERT_EQ(clock_nanosleep(CLOCK_MONOTONIC, 0,
+ &(struct timespec){ .tv_sec = STRESS_DURATION_SEC }, NULL), 0);
+
+ for (i = 0; i < 2 * nr_procs; i++)
+ kill(pids[i], SIGKILL);
+
+ for (i = 0; i < 2 * nr_procs; i++) {
+ int wstatus;
+
+ ASSERT_EQ(waitpid(pids[i], &wstatus, 0), pids[i]);
+ /* an exit before we killed it means it hit a real error */
+ ASSERT_FALSE(WIFEXITED(wstatus) && WEXITSTATUS(wstatus) != 0);
+ }
+}
+
+TEST_HARNESS_MAIN