Linux XFS filesystem development
 help / color / mirror / Atom feed
* [PATCH] xfs: Test sparse inode allocation near AGFL limits
@ 2026-07-30 13:45 Matt Fleming
  2026-08-26 14:35 ` Brian Foster
  0 siblings, 1 reply; 3+ messages in thread
From: Matt Fleming @ 2026-07-30 13:45 UTC (permalink / raw)
  To: fstests
  Cc: Zorro Lang, Darrick J. Wong, linux-xfs, Brian Foster,
	Christoph Hellwig, kernel-team

From: Matt Fleming <mfleming@cloudflare.com>

Add a regression test for a shutdown in the sparse inode allocation
path. The test builds an AG with no free inodes, a full inobt leaf, full
bnobt/cntbt leaves, and little allocatable space outside the AG
reservations.

The final create reaches the sparse inode fallback. Removing that extent
from free space can grow the free-space btrees and drain the AGFL. The
subsequent inobt insert currently trips a corruption check and shuts
down the filesystem:

  Internal error i != 1 at line 3768 of file fs/xfs/libxfs/xfs_btree.c.

Link: https://lore.kernel.org/linux-xfs/20260717130429.1838767-1-matt@readmodwrite.com/
Signed-off-by: Matt Fleming <mfleming@cloudflare.com>
---
 .gitignore            |   1 +
 src/Makefile          |   2 +-
 src/xfs_inode_alloc.c | 285 +++++++++++++++++++++++++++++++++++
 tests/xfs/842         | 341 ++++++++++++++++++++++++++++++++++++++++++
 tests/xfs/842.out     |   2 +
 5 files changed, 630 insertions(+), 1 deletion(-)
 create mode 100644 src/xfs_inode_alloc.c
 create mode 100755 tests/xfs/842
 create mode 100644 tests/xfs/842.out

diff --git a/.gitignore b/.gitignore
index dd77ee30..7c92e5eb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -185,6 +185,7 @@ tags
 /src/uuid_ioctl
 /src/writemod
 /src/writev_on_pagefault
+/src/xfs_inode_alloc
 /src/xfsctl
 /src/xfsfind
 /src/aio-dio-regress/aio-dio-append-write-fallocate-race
diff --git a/src/Makefile b/src/Makefile
index 31ac43b2..9e26ebd1 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -36,7 +36,7 @@ LINUX_TARGETS = xfsctl bstat t_mtab getdevicesize preallo_rw_pattern_reader \
 	fscrypt-crypt-util bulkstat_null_ocount splice-test chprojid_fail \
 	detached_mounts_propagation ext4_resize t_readdir_3 splice2pipe \
 	uuid_ioctl t_snapshot_deleted_subvolume fiemap-fault min_dio_alignment \
-	rw_hint fs-monitor
+	rw_hint fs-monitor xfs_inode_alloc
 
 EXTRA_EXECS = dmerror fill2attr fill2fs fill2fs_check scaleread.sh \
 	      btrfs_crc32c_forged_name.py popdir.pl popattr.py \
diff --git a/src/xfs_inode_alloc.c b/src/xfs_inode_alloc.c
new file mode 100644
index 00000000..9995a047
--- /dev/null
+++ b/src/xfs_inode_alloc.c
@@ -0,0 +1,285 @@
+/* SPDX-License-Identifier: GPL-2.0 */
+#include <errno.h>
+#include <fcntl.h>
+#include <inttypes.h>
+#include <linux/falloc.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/prctl.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+#define BLOCK_SIZE 4096ULL
+#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0]))
+
+static void usage(const char *prog)
+{
+	fprintf(stderr,
+		"usage: %s --selftest\n"
+		"       %s create DIR COUNT\n"
+		"       %s fill FILE\n"
+		"       %s consume FILE BLOCKS\n"
+		"       %s fragment DIR COUNT\n"
+		"       %s punch FILE OFFSET_BLOCKS LENGTH_BLOCKS\n"
+		"       %s trigger DIR\n",
+		prog, prog, prog, prog, prog, prog, prog);
+}
+
+static unsigned long long parse_ull(const char *value, const char *what)
+{
+	char *end;
+	unsigned long long result;
+
+	errno = 0;
+	result = strtoull(value, &end, 0);
+	if (errno || *value == '\0' || *end != '\0') {
+		fprintf(stderr, "invalid %s: %s\n", what, value);
+		exit(EXIT_FAILURE);
+	}
+	return result;
+}
+
+static int create_files(const char *dir, unsigned long long count)
+{
+	char path[4096];
+	unsigned long long i;
+
+	for (i = 0; i < count; i++) {
+		int fd;
+		int len = snprintf(path, sizeof(path), "%s/inode-%08llu", dir, i);
+
+		if (len < 0 || (size_t)len >= sizeof(path)) {
+			fprintf(stderr, "path too long\n");
+			return 1;
+		}
+		fd = open(path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
+		if (fd < 0) {
+			fprintf(stderr, "create %s failed after %llu files: %s\n",
+				path, i, strerror(errno));
+			return 1;
+		}
+		close(fd);
+		if ((i + 1) % 1024 == 0)
+			printf("repro: created %llu/%llu files\n", i + 1, count);
+	}
+	printf("repro: created %llu files\n", count);
+	return 0;
+}
+
+static int fill_file(const char *path)
+{
+	static const unsigned long long chunks[] = {
+		256ULL << 20, 64ULL << 20, 16ULL << 20, 4ULL << 20,
+		1ULL << 20, 256ULL << 10, 64ULL << 10, 16ULL << 10,
+		BLOCK_SIZE,
+	};
+	struct stat st;
+	off_t offset;
+	int fd;
+	size_t i;
+
+	fd = open(path, O_RDWR | O_CREAT | O_CLOEXEC, 0600);
+	if (fd < 0) {
+		perror("open filler");
+		return 1;
+	}
+	if (fstat(fd, &st) < 0) {
+		perror("fstat filler");
+		close(fd);
+		return 1;
+	}
+	offset = st.st_size;
+
+	for (i = 0; i < ARRAY_SIZE(chunks); i++) {
+		unsigned long long allocated = 0;
+		unsigned long long chunk = chunks[i];
+
+		for (;;) {
+			if (fallocate(fd, 0, offset, (off_t)chunk) == 0) {
+				offset += (off_t)chunk;
+				allocated += chunk;
+				if (allocated >= (256ULL << 20)) {
+					printf("repro: filler size=%" PRIu64 " MiB\n",
+						(uint64_t)offset >> 20);
+					allocated = 0;
+				}
+				continue;
+			}
+			if (errno != ENOSPC) {
+				fprintf(stderr, "fallocate %llu bytes at %jd: %s\n",
+					chunk, (intmax_t)offset, strerror(errno));
+				close(fd);
+				return 1;
+			}
+			break;
+		}
+		printf("repro: allocation stopped at chunk=%llu bytes, size=%" PRIu64
+		       " MiB\n", chunk, (uint64_t)offset >> 20);
+	}
+	if (fsync(fd) < 0)
+		perror("fsync filler");
+	close(fd);
+	return 0;
+}
+
+static int fragment_free_space(const char *dir, unsigned long long count)
+{
+	char path[4096];
+	unsigned long long i;
+	int dirfd;
+
+	for (i = 0; i < count; i++) {
+		int fd;
+
+		snprintf(path, sizeof(path), "%s/inode-%08llu", dir, i);
+		fd = open(path, O_RDWR | O_CLOEXEC);
+		if (fd < 0 || fallocate(fd, 0, 0, BLOCK_SIZE) < 0) {
+			fprintf(stderr, "fragment allocation %s failed: %s\n",
+				path, strerror(errno));
+			if (fd >= 0)
+				close(fd);
+			return 1;
+		}
+		close(fd);
+		if ((i + 1) % 2048 == 0)
+			printf("repro: allocated fragment blocks %llu/%llu\n",
+			       i + 1, count);
+	}
+
+	for (i = 0; i < count; i += 2) {
+		int fd;
+
+		snprintf(path, sizeof(path), "%s/inode-%08llu", dir, i);
+		fd = open(path, O_RDWR | O_CLOEXEC);
+		if (fd < 0 || fallocate(fd, FALLOC_FL_PUNCH_HOLE |
+					      FALLOC_FL_KEEP_SIZE,
+					      0, BLOCK_SIZE) < 0) {
+			fprintf(stderr, "fragment punch %s failed: %s\n",
+				path, strerror(errno));
+			if (fd >= 0)
+				close(fd);
+			return 1;
+		}
+		close(fd);
+	}
+
+	dirfd = open(dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+	if (dirfd >= 0) {
+		syncfs(dirfd);
+		close(dirfd);
+	}
+	printf("repro: created %llu one-block free-space fragments\n",
+	       (count + 1) / 2);
+	return 0;
+}
+
+static int consume_blocks(const char *path, unsigned long long blocks)
+{
+	struct stat st;
+	unsigned long long i;
+	off_t offset;
+	int fd = open(path, O_RDWR | O_CLOEXEC);
+
+	if (fd < 0 || fstat(fd, &st) < 0) {
+		perror("open consumer file");
+		if (fd >= 0)
+			close(fd);
+		return 1;
+	}
+	offset = st.st_size;
+	for (i = 0; i < blocks; i++) {
+		if (fallocate(fd, 0, offset, BLOCK_SIZE) < 0) {
+			fprintf(stderr, "one-block allocation failed at %llu/%llu: %s\n",
+				i, blocks, strerror(errno));
+			close(fd);
+			return 1;
+		}
+		offset += BLOCK_SIZE;
+		if ((i + 1) % 512 == 0)
+			printf("repro: consumed %llu/%llu blocks\n", i + 1, blocks);
+	}
+	fsync(fd);
+	close(fd);
+	printf("repro: consumed %llu blocks\n", blocks);
+	return 0;
+}
+
+static int punch_file(const char *path, unsigned long long offset_blocks,
+		      unsigned long long length_blocks)
+{
+	off_t offset = (off_t)(offset_blocks * BLOCK_SIZE);
+	off_t length = (off_t)(length_blocks * BLOCK_SIZE);
+	int fd = open(path, O_RDWR | O_CLOEXEC);
+
+	if (fd < 0) {
+		perror("open filler for punch");
+		return 1;
+	}
+	if (fallocate(fd, FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE,
+		      offset, length) < 0) {
+		perror("punch hole");
+		close(fd);
+		return 1;
+	}
+	if (fsync(fd) < 0)
+		perror("fsync punched filler");
+	close(fd);
+	printf("repro: punched %llu blocks at file block %llu\n",
+	       length_blocks, offset_blocks);
+	return 0;
+}
+
+static int trigger_create(const char *dir)
+{
+	char path[4096];
+	int fd;
+
+	if (prctl(PR_SET_NAME, "xfs-ialloc-test", 0, 0, 0) < 0) {
+		perror("prctl PR_SET_NAME");
+		return 1;
+	}
+	if (snprintf(path, sizeof(path), "%s/trigger", dir) >= (int)sizeof(path)) {
+		fprintf(stderr, "trigger path too long\n");
+		return 1;
+	}
+	fd = open(path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
+	if (fd < 0) {
+		printf("trigger create failed: errno=%d (%s)\n",
+		       errno, strerror(errno));
+		/* Clean ENOSPC is an acceptable result after the bug is fixed. */
+		return errno == ENOSPC ? 0 : 1;
+	}
+	close(fd);
+	printf("trigger create succeeded\n");
+	return 0;
+}
+
+int main(int argc, char **argv)
+{
+	setvbuf(stdout, NULL, _IOLBF, 0);
+
+	if (argc == 2 && strcmp(argv[1], "--selftest") == 0) {
+		puts("selftest: ok");
+		return 0;
+	}
+	if (argc == 4 && strcmp(argv[1], "create") == 0)
+		return create_files(argv[2], parse_ull(argv[3], "file count"));
+	if (argc == 3 && strcmp(argv[1], "fill") == 0)
+		return fill_file(argv[2]);
+	if (argc == 4 && strcmp(argv[1], "consume") == 0)
+		return consume_blocks(argv[2], parse_ull(argv[3], "block count"));
+	if (argc == 4 && strcmp(argv[1], "fragment") == 0)
+		return fragment_free_space(argv[2],
+					   parse_ull(argv[3], "file count"));
+	if (argc == 5 && strcmp(argv[1], "punch") == 0)
+		return punch_file(argv[2], parse_ull(argv[3], "hole offset"),
+				  parse_ull(argv[4], "hole length"));
+	if (argc == 3 && strcmp(argv[1], "trigger") == 0)
+		return trigger_create(argv[2]);
+
+	usage(argv[0]);
+	return 2;
+}
diff --git a/tests/xfs/842 b/tests/xfs/842
new file mode 100755
index 00000000..fdab240a
--- /dev/null
+++ b/tests/xfs/842
@@ -0,0 +1,341 @@
+#! /bin/bash
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 Cloudflare, Inc.  All Rights Reserved.
+#
+# FS QA Test No. 842
+#
+# Regression test for an inode allocation failure after a sparse inode extent
+# grows full free-space btrees and raises the AGFL minimum.  The subsequent
+# inobt leaf split must not trip the i != 1 corruption check.
+#
+. ./common/preamble
+_begin_fstest auto enospc punch prealloc
+
+_cleanup()
+{
+	cd /
+	_scratch_unmount 2>/dev/null
+	rm -f "$tmp".*
+}
+
+. ./common/filter
+
+_require_scratch
+_require_scratch_size $((1024 * 1024))
+_require_check_dmesg
+_require_xfs_db_command agresv
+_require_xfs_io_command bmap
+_require_xfs_io_command fsync
+
+# The state builder depends on an internal log and no realtime section.
+unset SCRATCH_LOGDEV
+unset SCRATCH_RTDEV
+
+_require_xfs_sparse_inodes
+_require_xfs_mkfs_finobt
+
+REPRO=$here/src/xfs_inode_alloc
+TARGET_ALLOCATED=16128 # 252 inode records * 64 inodes
+INOBT_MAXLEVELS=2
+FRAGMENT_FILES=1002
+
+[[ -x $REPRO ]] || _notrun "xfs_inode_alloc helper is not built"
+
+mkfs_opts="-q -K -s size=4096 \
+	-m crc=1,finobt=1,rmapbt=1,reflink=1,inobtcount=1,bigtime=1 \
+	-i size=512,sparse=1,maxpct=25,nrext64=1 \
+	-d size=1g,agcount=2,su=512k,sw=2"
+_scratch_mkfs_xfs_supported $mkfs_opts >/dev/null 2>&1 || \
+	_notrun "mkfs.xfs does not support the required feature set"
+
+min_value()
+{
+	if [ "$1" -lt "$2" ]; then
+		printf '%s\n' "$1"
+	else
+		printf '%s\n' "$2"
+	fi
+}
+
+min_freelist()
+{
+	local bno_level=$1 cnt_level=$2 rmap_level=$3
+	local alloc_maxlevels=$4 rmap_maxlevels=$5 has_rmap=$6
+	local bno_next cnt_next rmap_next total
+
+	bno_next=$(min_value $((bno_level + 1)) "$alloc_maxlevels")
+	cnt_next=$(min_value $((cnt_level + 1)) "$alloc_maxlevels")
+	total=$((bno_next * 2 - 2 + cnt_next * 2 - 2))
+	if [ "$has_rmap" -ne 0 ]; then
+		rmap_next=$(min_value $((rmap_level + 1)) "$rmap_maxlevels")
+		total=$((total + rmap_next * 2 - 2))
+	fi
+	printf '%s\n' "$total"
+}
+
+space_available()
+{
+	local freeblks=$1 flcount=$2 reservation=$3 min_free=$4 minleft=$5
+	local agflcount
+
+	agflcount=$(min_value "$flcount" "$min_free")
+	printf '%s\n' $((freeblks + agflcount - reservation - min_free - minleft))
+}
+
+agi_field()
+{
+	local field=$1
+	$XFS_DB_PROG -r -c 'agi 0' -c "p $field" "$SCRATCH_DEV" 2>/dev/null |
+		awk -v key="$field" '$1 == key { print $3; exit }'
+}
+
+inobt_root_field()
+{
+	local field=$1
+	$XFS_DB_PROG -r -c 'agi 0' -c 'addr root' -c "p $field" \
+		"$SCRATCH_DEV" 2>/dev/null |
+		awk -v key="$field" '$1 == key { print $3; exit }'
+}
+
+agf_field()
+{
+	local field=$1
+	$XFS_DB_PROG -r -c 'agf 0' -c "p $field" "$SCRATCH_DEV" 2>/dev/null |
+		awk -v key="$field" '$1 == key { print $3; exit }'
+}
+
+alloc_root_field()
+{
+	local root=$1 field=$2
+	$XFS_DB_PROG -r -c 'agf 0' -c "addr $root" -c "p $field" \
+		"$SCRATCH_DEV" 2>/dev/null |
+		awk -v key="$field" '$1 == key { print $3; exit }'
+}
+
+ag0_reservation_state()
+{
+	$XFS_DB_PROG -r -c agresv "$SCRATCH_DEV" 2>/dev/null |
+		awk '$1 == "AG" && $2 == "0:" { print $6, $8, $10; exit }'
+}
+
+free_record_covering()
+{
+	local agbno=$1
+	$XFS_DB_PROG -r -c 'agf 0' -c 'addr bnoroot' -c 'type bnobt' \
+		-c 'btdump -i' "$SCRATCH_DEV" 2>/dev/null |
+		awk -v target="$agbno" '/^[0-9]+:\[/ {
+			record = $0
+			sub(/^[^[]*\[/, "", record)
+			sub(/\].*$/, "", record)
+			split(record, value, /,/)
+			start = value[1] + 0
+			blocks = value[2] + 0
+			if (start <= target && target < start + blocks) {
+				print 0, start, blocks
+				exit
+			}
+		}'
+}
+
+largest_ag0_free_extent()
+{
+	$XFS_DB_PROG -r -c 'agf 0' -c 'addr bnoroot' -c 'type bnobt' \
+		-c 'btdump -i' "$SCRATCH_DEV" 2>/dev/null |
+		awk '/^[0-9]+:\[/ {
+			record = $0
+			sub(/^[^[]*\[/, "", record)
+			sub(/\].*$/, "", record)
+			split(record, value, /,/)
+			if (value[2] + 0 > max) {
+				start = value[1] + 0
+				max = value[2] + 0
+			}
+		} END { if (max) print start, max }'
+}
+
+inspect_unmounted()
+{
+	sync -f "$SCRATCH_MNT"
+	_scratch_unmount
+}
+
+mount_fs()
+{
+	_scratch_mount -o noatime,inode64,logbufs=8,logbsize=32k,noquota
+}
+
+release_ag1()
+{
+	local cutoff bytes
+
+	cutoff=$($XFS_IO_PROG -r -c 'bmap -v' "$SCRATCH_MNT/filler" |
+		awk '$4 == 1 {
+			range = $2
+			gsub(/[\[\]:]/, "", range)
+			split(range, sector, /\.\./)
+			printf "%.0f\n", sector[1] / 8
+			exit
+		}')
+	[[ -n $cutoff ]] || return 1
+	bytes=$((cutoff * 4096))
+	truncate -s "$bytes" "$SCRATCH_MNT/filler"
+	$XFS_IO_PROG -c fsync "$SCRATCH_MNT/filler"
+}
+
+align_filler_eof()
+{
+	local target=$1 cutoff current bytes
+
+	cutoff=$($XFS_IO_PROG -r -c 'bmap -v' "$SCRATCH_MNT/filler" 2>/dev/null |
+		awk -v target="$target" '$4 == 0 {
+			file_range = $2
+			ag_range = $5
+			gsub(/[\[\]:]/, "", file_range)
+			gsub(/[()]/, "", ag_range)
+			split(file_range, file_sector, /\.\./)
+			split(ag_range, ag_sector, /\.\./)
+			if (int(ag_sector[2] / 8) + 1 == target) {
+				printf "%.0f\n", (file_sector[2] + 1) / 8
+				exit
+			}
+		}')
+	[[ -n $cutoff ]] || return 1
+	current=$(( $(_get_filesize "$SCRATCH_MNT/filler") / 4096 ))
+	if (( cutoff < current )); then
+		bytes=$((cutoff * 4096))
+		truncate -s "$bytes" "$SCRATCH_MNT/filler"
+		$XFS_IO_PROG -c fsync "$SCRATCH_MNT/filler"
+	fi
+}
+
+find_trigger_hole_offset()
+{
+	$XFS_IO_PROG -r -c 'bmap -v' "$SCRATCH_MNT/filler" |
+		awk '$4 == 0 {
+			file_range = $2
+			ag_range = $5
+			gsub(/[\[\]:]/, "", file_range)
+			gsub(/[()]/, "", ag_range)
+			split(file_range, f, /\.\./)
+			split(ag_range, a, /\.\./)
+			file_start = f[1] / 8
+			ag_start = a[1] / 8
+			map_blocks = (f[2] - f[1] + 1) / 8
+			delta = (2 - (ag_start % 8) + 8) % 8
+			if (map_blocks >= delta + 16) {
+				printf "%.0f %.0f\n", file_start + delta, ag_start + delta
+				exit
+			}
+		}'
+}
+
+consume_ag0_headroom()
+{
+	local values free reserved used outside
+
+	values=$(ag0_reservation_state)
+	read -r free reserved used <<<"$values"
+	outside=$((free - (reserved - used)))
+	(( outside <= 0 )) && return 0
+
+	mount_fs
+	"$REPRO" consume "$SCRATCH_MNT/filler" "$outside" >>"$seqres.full" 2>&1 || true
+	inspect_unmounted
+}
+
+_scratch_mkfs_xfs $mkfs_opts >>"$seqres.full" 2>&1
+mount_fs
+: > "$SCRATCH_MNT/filler"
+inspect_unmounted
+
+count=$(agi_field count)
+freecount=$(agi_field freecount)
+allocated=$((count - freecount))
+need=$((TARGET_ALLOCATED - allocated))
+(( need > 0 )) || _fail "invalid initial inode state"
+
+mount_fs
+"$REPRO" create "$SCRATCH_MNT" "$need" >>"$seqres.full" 2>&1 || \
+	_fail "inode population failed"
+inspect_unmounted
+
+count=$(agi_field count)
+freecount=$(agi_field freecount)
+inobt_level=$(inobt_root_field level)
+inobt_recs=$(inobt_root_field numrecs)
+if (( count != TARGET_ALLOCATED || freecount != 0 ||
+      inobt_level != 0 || inobt_recs != 252 )); then
+	_fail "failed to build one full inobt leaf"
+fi
+
+mount_fs
+"$REPRO" fragment "$SCRATCH_MNT" "$FRAGMENT_FILES" >>"$seqres.full" 2>&1 || \
+	_fail "free-space fragmentation failed"
+"$REPRO" fill "$SCRATCH_MNT/filler" >>"$seqres.full" 2>&1 || \
+	_fail "free-space fill failed"
+release_ag1 || _fail "failed to release AG1 filler mappings"
+inspect_unmounted
+
+consume_ag0_headroom
+read -r largest_start _ <<<"$(largest_ag0_free_extent)"
+mount_fs
+align_filler_eof "$largest_start" || _fail "cannot align filler EOF"
+inspect_unmounted
+consume_ag0_headroom
+
+mount_fs
+read -r file_offset agblock <<<"$(find_trigger_hole_offset)"
+[[ -n $file_offset && -n $agblock ]] || _fail "no suitable sparse inode extent"
+"$REPRO" punch "$SCRATCH_MNT/filler" "$file_offset" 8 >>"$seqres.full" 2>&1 || \
+	_fail "failed to create sparse inode extent"
+inspect_unmounted
+
+read -r free reserved used <<<"$(ag0_reservation_state)"
+reservation=$((reserved - used))
+outside=$((free - reservation))
+bno_level=$(agf_field bnolevel)
+cnt_level=$(agf_field cntlevel)
+rmap_level=$(agf_field rmaplevel)
+flcount=$(agf_field flcount)
+bno_recs=$(alloc_root_field bnoroot numrecs)
+cnt_recs=$(alloc_root_field cntroot numrecs)
+inobt_level=$(inobt_root_field level)
+inobt_recs=$(inobt_root_field numrecs)
+minfree=$(min_freelist "$bno_level" "$cnt_level" "$rmap_level" 5 5 1)
+pagf_free=$((free - flcount))
+available=$(space_available "$pagf_free" "$flcount" "$reservation" \
+	"$minfree" "$INOBT_MAXLEVELS")
+cover=$(free_record_covering "$agblock")
+
+printf 'gate free=%d reservation=%d outside=%d fl=%d minfree=%d available=%d levels=%d/%d/%d alloc_recs=%d/%d inobt=%d/%d hole=%d record="%s"\n' \
+	"$free" "$reservation" "$outside" "$flcount" "$minfree" \
+	"$available" "$bno_level" "$cnt_level" "$rmap_level" \
+	"$bno_recs" "$cnt_recs" "$inobt_level" "$inobt_recs" \
+	"$agblock" "$cover" >>"$seqres.full"
+
+if (( available < 7 || available >= 15 || bno_level != 1 ||
+      cnt_level != 1 || bno_recs != 505 || cnt_recs != 505 ||
+      inobt_level != 0 || inobt_recs != 252 || flcount != minfree )) ||
+   [[ $cover != "0 $agblock 8" ]]; then
+	_fail "combined inode allocation precondition not met"
+fi
+
+mount_fs
+set +e
+"$REPRO" trigger "$SCRATCH_MNT" >>"$seqres.full" 2>&1
+trigger_status=$?
+set -e
+sleep 1
+
+if _check_dmesg_for 'Internal error i != 1|invalid sparse inode record|Shutting down filesystem'; then
+	_fail "sparse inode allocation shut down the filesystem"
+fi
+(( trigger_status == 0 )) || _fail "inode allocation failed with an unexpected error"
+
+# Verify that the filesystem remains usable after the allocation attempt.
+$XFS_IO_PROG -c stat "$SCRATCH_MNT" >>"$seqres.full" 2>&1 || \
+	_fail "filesystem is not usable after inode allocation"
+
+echo Silence is golden
+status=0
+exit
diff --git a/tests/xfs/842.out b/tests/xfs/842.out
new file mode 100644
index 00000000..643d0528
--- /dev/null
+++ b/tests/xfs/842.out
@@ -0,0 +1,2 @@
+QA output created by 842
+Silence is golden
-- 
2.43.0


^ permalink raw reply related	[flat|nested] 3+ messages in thread

* Re: [PATCH] xfs: Test sparse inode allocation near AGFL limits
  2026-07-30 13:45 [PATCH] xfs: Test sparse inode allocation near AGFL limits Matt Fleming
@ 2026-08-26 14:35 ` Brian Foster
  2026-08-26 15:22   ` Darrick J. Wong
  0 siblings, 1 reply; 3+ messages in thread
From: Brian Foster @ 2026-08-26 14:35 UTC (permalink / raw)
  To: Matt Fleming
  Cc: fstests, Zorro Lang, Darrick J. Wong, linux-xfs,
	Christoph Hellwig, kernel-team

On Thu, Jul 30, 2026 at 02:45:20PM +0100, Matt Fleming wrote:
> From: Matt Fleming <mfleming@cloudflare.com>
> 
> Add a regression test for a shutdown in the sparse inode allocation
> path. The test builds an AG with no free inodes, a full inobt leaf, full
> bnobt/cntbt leaves, and little allocatable space outside the AG
> reservations.
> 
> The final create reaches the sparse inode fallback. Removing that extent
> from free space can grow the free-space btrees and drain the AGFL. The
> subsequent inobt insert currently trips a corruption check and shuts
> down the filesystem:
> 
>   Internal error i != 1 at line 3768 of file fs/xfs/libxfs/xfs_btree.c.
> 
> Link: https://lore.kernel.org/linux-xfs/20260717130429.1838767-1-matt@readmodwrite.com/
> Signed-off-by: Matt Fleming <mfleming@cloudflare.com>
> ---

First off thanks for the test. Whether you end up getting this included
or not, it is very useful to have a reproducer for the prospective
fixes.

One thing of note.. it doesn't appear able to reproduce with
CONFIG_XFS_DEBUG enabled. I haven't dug into why, but it doesn't end up
passing the "one full inobt leaf" stage.

>  .gitignore            |   1 +
>  src/Makefile          |   2 +-
>  src/xfs_inode_alloc.c | 285 +++++++++++++++++++++++++++++++++++
>  tests/xfs/842         | 341 ++++++++++++++++++++++++++++++++++++++++++
>  tests/xfs/842.out     |   2 +
>  5 files changed, 630 insertions(+), 1 deletion(-)
>  create mode 100644 src/xfs_inode_alloc.c
>  create mode 100755 tests/xfs/842
>  create mode 100644 tests/xfs/842.out
> 
> diff --git a/.gitignore b/.gitignore
> index dd77ee30..7c92e5eb 100644
> --- a/.gitignore
> +++ b/.gitignore
> @@ -185,6 +185,7 @@ tags
>  /src/uuid_ioctl
>  /src/writemod
>  /src/writev_on_pagefault
> +/src/xfs_inode_alloc
>  /src/xfsctl
>  /src/xfsfind
>  /src/aio-dio-regress/aio-dio-append-write-fallocate-race
> diff --git a/src/Makefile b/src/Makefile
> index 31ac43b2..9e26ebd1 100644
> --- a/src/Makefile
> +++ b/src/Makefile
> @@ -36,7 +36,7 @@ LINUX_TARGETS = xfsctl bstat t_mtab getdevicesize preallo_rw_pattern_reader \
>  	fscrypt-crypt-util bulkstat_null_ocount splice-test chprojid_fail \
>  	detached_mounts_propagation ext4_resize t_readdir_3 splice2pipe \
>  	uuid_ioctl t_snapshot_deleted_subvolume fiemap-fault min_dio_alignment \
> -	rw_hint fs-monitor
> +	rw_hint fs-monitor xfs_inode_alloc
>  
>  EXTRA_EXECS = dmerror fill2attr fill2fs fill2fs_check scaleread.sh \
>  	      btrfs_crc32c_forged_name.py popdir.pl popattr.py \
> diff --git a/src/xfs_inode_alloc.c b/src/xfs_inode_alloc.c
> new file mode 100644
> index 00000000..9995a047
> --- /dev/null
> +++ b/src/xfs_inode_alloc.c
...

The thing that stands out most with this supporting helper program is
that it seems to duplicate or reimplement a bunch of functionality. For
example, the inode creation should be easy enough to do in a bash loop,
the punch and consume commands look like they could be reduced to single
xfs_io falloc or fpunch commands, and trigger is also just another
simple file creation.

The fill and fragment commands look potentially more unique, but I still
wonder if we have mechanisms that can be reused. For fragment, we have
src/punch-alternating, which looks to me it could achieve the same basic
results. I.e., rather than alloc and free single blocks across inodes,
alloc a bunch of blocks to a single file and punch out every other and
fragment free space that way. We also have _fill_fs() under
common/populate that looks like it can consume space in general.

I could be missing some details, but all in all I suspect this whole
helper program could be simplified away.

> diff --git a/tests/xfs/842 b/tests/xfs/842
> new file mode 100755
> index 00000000..fdab240a
> --- /dev/null
> +++ b/tests/xfs/842
> @@ -0,0 +1,341 @@
> +#! /bin/bash
> +# SPDX-License-Identifier: GPL-2.0
> +# Copyright (c) 2026 Cloudflare, Inc.  All Rights Reserved.
> +#
> +# FS QA Test No. 842
> +#
> +# Regression test for an inode allocation failure after a sparse inode extent
> +# grows full free-space btrees and raises the AGFL minimum.  The subsequent
> +# inobt leaf split must not trip the i != 1 corruption check.
> +#
> +. ./common/preamble
> +_begin_fstest auto enospc punch prealloc
> +
> +_cleanup()
> +{
> +	cd /
> +	_scratch_unmount 2>/dev/null
> +	rm -f "$tmp".*
> +}
> +
> +. ./common/filter
> +
> +_require_scratch
> +_require_scratch_size $((1024 * 1024))
> +_require_check_dmesg
> +_require_xfs_db_command agresv
> +_require_xfs_io_command bmap
> +_require_xfs_io_command fsync
> +
> +# The state builder depends on an internal log and no realtime section.
> +unset SCRATCH_LOGDEV
> +unset SCRATCH_RTDEV
> +
> +_require_xfs_sparse_inodes
> +_require_xfs_mkfs_finobt
> +
> +REPRO=$here/src/xfs_inode_alloc
> +TARGET_ALLOCATED=16128 # 252 inode records * 64 inodes
> +INOBT_MAXLEVELS=2
> +FRAGMENT_FILES=1002
> +
> +[[ -x $REPRO ]] || _notrun "xfs_inode_alloc helper is not built"
> +
> +mkfs_opts="-q -K -s size=4096 \
> +	-m crc=1,finobt=1,rmapbt=1,reflink=1,inobtcount=1,bigtime=1 \
> +	-i size=512,sparse=1,maxpct=25,nrext64=1 \
> +	-d size=1g,agcount=2,su=512k,sw=2"
> +_scratch_mkfs_xfs_supported $mkfs_opts >/dev/null 2>&1 || \
> +	_notrun "mkfs.xfs does not support the required feature set"
> +
> +min_value()
> +{
> +	if [ "$1" -lt "$2" ]; then
> +		printf '%s\n' "$1"
> +	else
> +		printf '%s\n' "$2"
> +	fi
> +}
> +
> +min_freelist()
> +{
> +	local bno_level=$1 cnt_level=$2 rmap_level=$3
> +	local alloc_maxlevels=$4 rmap_maxlevels=$5 has_rmap=$6
> +	local bno_next cnt_next rmap_next total
> +
> +	bno_next=$(min_value $((bno_level + 1)) "$alloc_maxlevels")
> +	cnt_next=$(min_value $((cnt_level + 1)) "$alloc_maxlevels")
> +	total=$((bno_next * 2 - 2 + cnt_next * 2 - 2))
> +	if [ "$has_rmap" -ne 0 ]; then
> +		rmap_next=$(min_value $((rmap_level + 1)) "$rmap_maxlevels")
> +		total=$((total + rmap_next * 2 - 2))
> +	fi
> +	printf '%s\n' "$total"
> +}
> +
> +space_available()
> +{
> +	local freeblks=$1 flcount=$2 reservation=$3 min_free=$4 minleft=$5
> +	local agflcount
> +
> +	agflcount=$(min_value "$flcount" "$min_free")
> +	printf '%s\n' $((freeblks + agflcount - reservation - min_free - minleft))
> +}
> +
> +agi_field()
> +{
> +	local field=$1
> +	$XFS_DB_PROG -r -c 'agi 0' -c "p $field" "$SCRATCH_DEV" 2>/dev/null |
> +		awk -v key="$field" '$1 == key { print $3; exit }'
> +}
> +
> +inobt_root_field()
> +{
> +	local field=$1
> +	$XFS_DB_PROG -r -c 'agi 0' -c 'addr root' -c "p $field" \
> +		"$SCRATCH_DEV" 2>/dev/null |
> +		awk -v key="$field" '$1 == key { print $3; exit }'
> +}
> +
> +agf_field()
> +{
> +	local field=$1
> +	$XFS_DB_PROG -r -c 'agf 0' -c "p $field" "$SCRATCH_DEV" 2>/dev/null |
> +		awk -v key="$field" '$1 == key { print $3; exit }'
> +}
> +
> +alloc_root_field()
> +{
> +	local root=$1 field=$2
> +	$XFS_DB_PROG -r -c 'agf 0' -c "addr $root" -c "p $field" \
> +		"$SCRATCH_DEV" 2>/dev/null |
> +		awk -v key="$field" '$1 == key { print $3; exit }'
> +}
> +
> +ag0_reservation_state()
> +{
> +	$XFS_DB_PROG -r -c agresv "$SCRATCH_DEV" 2>/dev/null |
> +		awk '$1 == "AG" && $2 == "0:" { print $6, $8, $10; exit }'
> +}

Seems like a lot of these helpers could be replaced with calls to
_scratch_xfs_get_metadata_field()..?

> +
> +free_record_covering()
> +{
> +	local agbno=$1
> +	$XFS_DB_PROG -r -c 'agf 0' -c 'addr bnoroot' -c 'type bnobt' \
> +		-c 'btdump -i' "$SCRATCH_DEV" 2>/dev/null |
> +		awk -v target="$agbno" '/^[0-9]+:\[/ {
> +			record = $0
> +			sub(/^[^[]*\[/, "", record)
> +			sub(/\].*$/, "", record)
> +			split(record, value, /,/)
> +			start = value[1] + 0
> +			blocks = value[2] + 0
> +			if (start <= target && target < start + blocks) {
> +				print 0, start, blocks
> +				exit
> +			}
> +		}'
> +}
> +
> +largest_ag0_free_extent()
> +{
> +	$XFS_DB_PROG -r -c 'agf 0' -c 'addr bnoroot' -c 'type bnobt' \
> +		-c 'btdump -i' "$SCRATCH_DEV" 2>/dev/null |
> +		awk '/^[0-9]+:\[/ {
> +			record = $0
> +			sub(/^[^[]*\[/, "", record)
> +			sub(/\].*$/, "", record)
> +			split(record, value, /,/)
> +			if (value[2] + 0 > max) {
> +				start = value[1] + 0
> +				max = value[2] + 0
> +			}
> +		} END { if (max) print start, max }'
> +}
> +
> +inspect_unmounted()
> +{
> +	sync -f "$SCRATCH_MNT"
> +	_scratch_unmount
> +}
> +
> +mount_fs()
> +{
> +	_scratch_mount -o noatime,inode64,logbufs=8,logbsize=32k,noquota
> +}
> +

Both of the above seem kind of spurious. Could they just be replaced
with direct calls to the underlying helpers?

> +release_ag1()
> +{
> +	local cutoff bytes
> +
> +	cutoff=$($XFS_IO_PROG -r -c 'bmap -v' "$SCRATCH_MNT/filler" |
> +		awk '$4 == 1 {
> +			range = $2
> +			gsub(/[\[\]:]/, "", range)
> +			split(range, sector, /\.\./)
> +			printf "%.0f\n", sector[1] / 8
> +			exit
> +		}')
> +	[[ -n $cutoff ]] || return 1
> +	bytes=$((cutoff * 4096))
> +	truncate -s "$bytes" "$SCRATCH_MNT/filler"
> +	$XFS_IO_PROG -c fsync "$SCRATCH_MNT/filler"
> +}
> +
> +align_filler_eof()
> +{
> +	local target=$1 cutoff current bytes
> +
> +	cutoff=$($XFS_IO_PROG -r -c 'bmap -v' "$SCRATCH_MNT/filler" 2>/dev/null |
> +		awk -v target="$target" '$4 == 0 {
> +			file_range = $2
> +			ag_range = $5
> +			gsub(/[\[\]:]/, "", file_range)
> +			gsub(/[()]/, "", ag_range)
> +			split(file_range, file_sector, /\.\./)
> +			split(ag_range, ag_sector, /\.\./)
> +			if (int(ag_sector[2] / 8) + 1 == target) {
> +				printf "%.0f\n", (file_sector[2] + 1) / 8
> +				exit
> +			}
> +		}')
> +	[[ -n $cutoff ]] || return 1
> +	current=$(( $(_get_filesize "$SCRATCH_MNT/filler") / 4096 ))
> +	if (( cutoff < current )); then
> +		bytes=$((cutoff * 4096))
> +		truncate -s "$bytes" "$SCRATCH_MNT/filler"
> +		$XFS_IO_PROG -c fsync "$SCRATCH_MNT/filler"
> +	fi
> +}
> +
> +find_trigger_hole_offset()
> +{
> +	$XFS_IO_PROG -r -c 'bmap -v' "$SCRATCH_MNT/filler" |
> +		awk '$4 == 0 {
> +			file_range = $2
> +			ag_range = $5
> +			gsub(/[\[\]:]/, "", file_range)
> +			gsub(/[()]/, "", ag_range)
> +			split(file_range, f, /\.\./)
> +			split(ag_range, a, /\.\./)
> +			file_start = f[1] / 8
> +			ag_start = a[1] / 8
> +			map_blocks = (f[2] - f[1] + 1) / 8
> +			delta = (2 - (ag_start % 8) + 8) % 8
> +			if (map_blocks >= delta + 16) {
> +				printf "%.0f %.0f\n", file_start + delta, ag_start + delta
> +				exit
> +			}
> +		}'
> +}
> +
> +consume_ag0_headroom()
> +{
> +	local values free reserved used outside
> +
> +	values=$(ag0_reservation_state)
> +	read -r free reserved used <<<"$values"
> +	outside=$((free - (reserved - used)))
> +	(( outside <= 0 )) && return 0
> +
> +	mount_fs
> +	"$REPRO" consume "$SCRATCH_MNT/filler" "$outside" >>"$seqres.full" 2>&1 || true
> +	inspect_unmounted
> +}
> +
> +_scratch_mkfs_xfs $mkfs_opts >>"$seqres.full" 2>&1
> +mount_fs
> +: > "$SCRATCH_MNT/filler"
> +inspect_unmounted
> +
> +count=$(agi_field count)
> +freecount=$(agi_field freecount)
> +allocated=$((count - freecount))
> +need=$((TARGET_ALLOCATED - allocated))
> +(( need > 0 )) || _fail "invalid initial inode state"
> +

This check doesn't seem all that useful.. verify we need to allocate
inodes on a newly formatted fs..?

> +mount_fs
> +"$REPRO" create "$SCRATCH_MNT" "$need" >>"$seqres.full" 2>&1 || \
> +	_fail "inode population failed"
> +inspect_unmounted
> +
> +count=$(agi_field count)
> +freecount=$(agi_field freecount)
> +inobt_level=$(inobt_root_field level)
> +inobt_recs=$(inobt_root_field numrecs)
> +if (( count != TARGET_ALLOCATED || freecount != 0 ||
> +      inobt_level != 0 || inobt_recs != 252 )); then
> +	_fail "failed to build one full inobt leaf"
> +fi
> +

Something I'm curious about in general is whether this can be made more
generic beyond creating/expecting a very specific geometry and
allocation state, but I understand this is quite hard to reproduce and
may not be possible.

That aside, I think some brief comments for some of the helpers used
below would be helpful to review/maintain this sort of test long term.
I.e., things like release_ag1, consume_ag0_headroom, etc. aren't very
self explanatory.

> +mount_fs
> +"$REPRO" fragment "$SCRATCH_MNT" "$FRAGMENT_FILES" >>"$seqres.full" 2>&1 || \
> +	_fail "free-space fragmentation failed"
> +"$REPRO" fill "$SCRATCH_MNT/filler" >>"$seqres.full" 2>&1 || \
> +	_fail "free-space fill failed"
> +release_ag1 || _fail "failed to release AG1 filler mappings"
> +inspect_unmounted
> +
> +consume_ag0_headroom
> +read -r largest_start _ <<<"$(largest_ag0_free_extent)"
> +mount_fs
> +align_filler_eof "$largest_start" || _fail "cannot align filler EOF"
> +inspect_unmounted
> +consume_ag0_headroom
> +
> +mount_fs
> +read -r file_offset agblock <<<"$(find_trigger_hole_offset)"
> +[[ -n $file_offset && -n $agblock ]] || _fail "no suitable sparse inode extent"
> +"$REPRO" punch "$SCRATCH_MNT/filler" "$file_offset" 8 >>"$seqres.full" 2>&1 || \
> +	_fail "failed to create sparse inode extent"
> +inspect_unmounted
> +
> +read -r free reserved used <<<"$(ag0_reservation_state)"
> +reservation=$((reserved - used))
> +outside=$((free - reservation))
> +bno_level=$(agf_field bnolevel)
> +cnt_level=$(agf_field cntlevel)
> +rmap_level=$(agf_field rmaplevel)
> +flcount=$(agf_field flcount)
> +bno_recs=$(alloc_root_field bnoroot numrecs)
> +cnt_recs=$(alloc_root_field cntroot numrecs)
> +inobt_level=$(inobt_root_field level)
> +inobt_recs=$(inobt_root_field numrecs)
> +minfree=$(min_freelist "$bno_level" "$cnt_level" "$rmap_level" 5 5 1)
> +pagf_free=$((free - flcount))
> +available=$(space_available "$pagf_free" "$flcount" "$reservation" \
> +	"$minfree" "$INOBT_MAXLEVELS")
> +cover=$(free_record_covering "$agblock")
> +
> +printf 'gate free=%d reservation=%d outside=%d fl=%d minfree=%d available=%d levels=%d/%d/%d alloc_recs=%d/%d inobt=%d/%d hole=%d record="%s"\n' \
> +	"$free" "$reservation" "$outside" "$flcount" "$minfree" \
> +	"$available" "$bno_level" "$cnt_level" "$rmap_level" \
> +	"$bno_recs" "$cnt_recs" "$inobt_level" "$inobt_recs" \
> +	"$agblock" "$cover" >>"$seqres.full"
> +
> +if (( available < 7 || available >= 15 || bno_level != 1 ||
> +      cnt_level != 1 || bno_recs != 505 || cnt_recs != 505 ||
> +      inobt_level != 0 || inobt_recs != 252 || flcount != minfree )) ||
> +   [[ $cover != "0 $agblock 8" ]]; then
> +	_fail "combined inode allocation precondition not met"
> +fi

This is kind of where I wonder if we've made this so specific and
targeted that while it works right now, some future change to allocation
behavior, geometry, etc. could render it ineffective long term. This
somewhat relates to the question of why this doesn't seem to work for
XFS_DEBUG.

Also FWIW it might not be worth having these various setup condition
failures trigger an actual test failure like this. Perhaps we could
replace some or most of these with _notrun?

Getting back to sparse inodes in general, it looks like we only really
have one other test in xfs/076, and that looks like a basic
functionality test. It fills up the fs, punches out fragments of free
space, then confirms we can allocate inodes into the fragmented space.
Clearly that hasn't been sufficient to reproduce this insert failure
problem, even though it repeatedly runs into ENOSPC. It would be great
IMO if we could come up with something generic to better test these
boundary conditions, even if it wasn't an immediate reproducer. Hmm..
maybe there's something we could do in kernel (i.e. debug mode or with
injection knobs) that would help produce more AGFL and/or allocbt
split/merge activity at < max levels or something. More thought
required...

Anyways, I'm not opposed to having a super specific test like this on
its own. I do think this will probably require a few rounds of
significant simplification and whatnot though to make it a bit more
palatable for upstream merge. Thanks.

Brian

> +
> +mount_fs
> +set +e
> +"$REPRO" trigger "$SCRATCH_MNT" >>"$seqres.full" 2>&1
> +trigger_status=$?
> +set -e
> +sleep 1
> +
> +if _check_dmesg_for 'Internal error i != 1|invalid sparse inode record|Shutting down filesystem'; then
> +	_fail "sparse inode allocation shut down the filesystem"
> +fi
> +(( trigger_status == 0 )) || _fail "inode allocation failed with an unexpected error"
> +
> +# Verify that the filesystem remains usable after the allocation attempt.
> +$XFS_IO_PROG -c stat "$SCRATCH_MNT" >>"$seqres.full" 2>&1 || \
> +	_fail "filesystem is not usable after inode allocation"
> +
> +echo Silence is golden
> +status=0
> +exit
> diff --git a/tests/xfs/842.out b/tests/xfs/842.out
> new file mode 100644
> index 00000000..643d0528
> --- /dev/null
> +++ b/tests/xfs/842.out
> @@ -0,0 +1,2 @@
> +QA output created by 842
> +Silence is golden
> -- 
> 2.43.0
> 


^ permalink raw reply	[flat|nested] 3+ messages in thread

* Re: [PATCH] xfs: Test sparse inode allocation near AGFL limits
  2026-08-26 14:35 ` Brian Foster
@ 2026-08-26 15:22   ` Darrick J. Wong
  0 siblings, 0 replies; 3+ messages in thread
From: Darrick J. Wong @ 2026-08-26 15:22 UTC (permalink / raw)
  To: Brian Foster
  Cc: Matt Fleming, fstests, Zorro Lang, linux-xfs, Christoph Hellwig,
	kernel-team

On Wed, Aug 26, 2026 at 10:35:50AM -0400, Brian Foster wrote:
> On Thu, Jul 30, 2026 at 02:45:20PM +0100, Matt Fleming wrote:
> > From: Matt Fleming <mfleming@cloudflare.com>
> > 
> > Add a regression test for a shutdown in the sparse inode allocation
> > path. The test builds an AG with no free inodes, a full inobt leaf, full
> > bnobt/cntbt leaves, and little allocatable space outside the AG
> > reservations.
> > 
> > The final create reaches the sparse inode fallback. Removing that extent
> > from free space can grow the free-space btrees and drain the AGFL. The
> > subsequent inobt insert currently trips a corruption check and shuts
> > down the filesystem:
> > 
> >   Internal error i != 1 at line 3768 of file fs/xfs/libxfs/xfs_btree.c.
> > 
> > Link: https://lore.kernel.org/linux-xfs/20260717130429.1838767-1-matt@readmodwrite.com/
> > Signed-off-by: Matt Fleming <mfleming@cloudflare.com>
> > ---
> 
> First off thanks for the test. Whether you end up getting this included
> or not, it is very useful to have a reproducer for the prospective
> fixes.
> 
> One thing of note.. it doesn't appear able to reproduce with
> CONFIG_XFS_DEBUG enabled. I haven't dug into why, but it doesn't end up
> passing the "one full inobt leaf" stage.
> 
> >  .gitignore            |   1 +
> >  src/Makefile          |   2 +-
> >  src/xfs_inode_alloc.c | 285 +++++++++++++++++++++++++++++++++++
> >  tests/xfs/842         | 341 ++++++++++++++++++++++++++++++++++++++++++
> >  tests/xfs/842.out     |   2 +
> >  5 files changed, 630 insertions(+), 1 deletion(-)
> >  create mode 100644 src/xfs_inode_alloc.c
> >  create mode 100755 tests/xfs/842
> >  create mode 100644 tests/xfs/842.out
> > 
> > diff --git a/.gitignore b/.gitignore
> > index dd77ee30..7c92e5eb 100644
> > --- a/.gitignore
> > +++ b/.gitignore
> > @@ -185,6 +185,7 @@ tags
> >  /src/uuid_ioctl
> >  /src/writemod
> >  /src/writev_on_pagefault
> > +/src/xfs_inode_alloc
> >  /src/xfsctl
> >  /src/xfsfind
> >  /src/aio-dio-regress/aio-dio-append-write-fallocate-race
> > diff --git a/src/Makefile b/src/Makefile
> > index 31ac43b2..9e26ebd1 100644
> > --- a/src/Makefile
> > +++ b/src/Makefile
> > @@ -36,7 +36,7 @@ LINUX_TARGETS = xfsctl bstat t_mtab getdevicesize preallo_rw_pattern_reader \
> >  	fscrypt-crypt-util bulkstat_null_ocount splice-test chprojid_fail \
> >  	detached_mounts_propagation ext4_resize t_readdir_3 splice2pipe \
> >  	uuid_ioctl t_snapshot_deleted_subvolume fiemap-fault min_dio_alignment \
> > -	rw_hint fs-monitor
> > +	rw_hint fs-monitor xfs_inode_alloc
> >  
> >  EXTRA_EXECS = dmerror fill2attr fill2fs fill2fs_check scaleread.sh \
> >  	      btrfs_crc32c_forged_name.py popdir.pl popattr.py \
> > diff --git a/src/xfs_inode_alloc.c b/src/xfs_inode_alloc.c
> > new file mode 100644
> > index 00000000..9995a047
> > --- /dev/null
> > +++ b/src/xfs_inode_alloc.c
> ...
> 
> The thing that stands out most with this supporting helper program is
> that it seems to duplicate or reimplement a bunch of functionality. For
> example, the inode creation should be easy enough to do in a bash loop,

Don't use bash loops if you can avoid it, they're very slow as compared
to...

> the punch and consume commands look like they could be reduced to single
> xfs_io falloc or fpunch commands, and trigger is also just another
> simple file creation.
> 
> The fill and fragment commands look potentially more unique, but I still
> wonder if we have mechanisms that can be reused. For fragment, we have
> src/punch-alternating, which looks to me it could achieve the same basic
> results. I.e., rather than alloc and free single blocks across inodes,
> alloc a bunch of blocks to a single file and punch out every other and
> fragment free space that way. We also have _fill_fs() under
> common/populate that looks like it can consume space in general.

...src/pop{dir,attr}.pl, where you only pay the exec overhead once.

--D

> I could be missing some details, but all in all I suspect this whole
> helper program could be simplified away.
> 
> > diff --git a/tests/xfs/842 b/tests/xfs/842
> > new file mode 100755
> > index 00000000..fdab240a
> > --- /dev/null
> > +++ b/tests/xfs/842
> > @@ -0,0 +1,341 @@
> > +#! /bin/bash
> > +# SPDX-License-Identifier: GPL-2.0
> > +# Copyright (c) 2026 Cloudflare, Inc.  All Rights Reserved.
> > +#
> > +# FS QA Test No. 842
> > +#
> > +# Regression test for an inode allocation failure after a sparse inode extent
> > +# grows full free-space btrees and raises the AGFL minimum.  The subsequent
> > +# inobt leaf split must not trip the i != 1 corruption check.
> > +#
> > +. ./common/preamble
> > +_begin_fstest auto enospc punch prealloc
> > +
> > +_cleanup()
> > +{
> > +	cd /
> > +	_scratch_unmount 2>/dev/null
> > +	rm -f "$tmp".*
> > +}
> > +
> > +. ./common/filter
> > +
> > +_require_scratch
> > +_require_scratch_size $((1024 * 1024))
> > +_require_check_dmesg
> > +_require_xfs_db_command agresv
> > +_require_xfs_io_command bmap
> > +_require_xfs_io_command fsync
> > +
> > +# The state builder depends on an internal log and no realtime section.
> > +unset SCRATCH_LOGDEV
> > +unset SCRATCH_RTDEV
> > +
> > +_require_xfs_sparse_inodes
> > +_require_xfs_mkfs_finobt
> > +
> > +REPRO=$here/src/xfs_inode_alloc
> > +TARGET_ALLOCATED=16128 # 252 inode records * 64 inodes
> > +INOBT_MAXLEVELS=2
> > +FRAGMENT_FILES=1002
> > +
> > +[[ -x $REPRO ]] || _notrun "xfs_inode_alloc helper is not built"
> > +
> > +mkfs_opts="-q -K -s size=4096 \
> > +	-m crc=1,finobt=1,rmapbt=1,reflink=1,inobtcount=1,bigtime=1 \
> > +	-i size=512,sparse=1,maxpct=25,nrext64=1 \
> > +	-d size=1g,agcount=2,su=512k,sw=2"
> > +_scratch_mkfs_xfs_supported $mkfs_opts >/dev/null 2>&1 || \
> > +	_notrun "mkfs.xfs does not support the required feature set"
> > +
> > +min_value()
> > +{
> > +	if [ "$1" -lt "$2" ]; then
> > +		printf '%s\n' "$1"
> > +	else
> > +		printf '%s\n' "$2"
> > +	fi
> > +}
> > +
> > +min_freelist()
> > +{
> > +	local bno_level=$1 cnt_level=$2 rmap_level=$3
> > +	local alloc_maxlevels=$4 rmap_maxlevels=$5 has_rmap=$6
> > +	local bno_next cnt_next rmap_next total
> > +
> > +	bno_next=$(min_value $((bno_level + 1)) "$alloc_maxlevels")
> > +	cnt_next=$(min_value $((cnt_level + 1)) "$alloc_maxlevels")
> > +	total=$((bno_next * 2 - 2 + cnt_next * 2 - 2))
> > +	if [ "$has_rmap" -ne 0 ]; then
> > +		rmap_next=$(min_value $((rmap_level + 1)) "$rmap_maxlevels")
> > +		total=$((total + rmap_next * 2 - 2))
> > +	fi
> > +	printf '%s\n' "$total"
> > +}
> > +
> > +space_available()
> > +{
> > +	local freeblks=$1 flcount=$2 reservation=$3 min_free=$4 minleft=$5
> > +	local agflcount
> > +
> > +	agflcount=$(min_value "$flcount" "$min_free")
> > +	printf '%s\n' $((freeblks + agflcount - reservation - min_free - minleft))
> > +}
> > +
> > +agi_field()
> > +{
> > +	local field=$1
> > +	$XFS_DB_PROG -r -c 'agi 0' -c "p $field" "$SCRATCH_DEV" 2>/dev/null |
> > +		awk -v key="$field" '$1 == key { print $3; exit }'
> > +}
> > +
> > +inobt_root_field()
> > +{
> > +	local field=$1
> > +	$XFS_DB_PROG -r -c 'agi 0' -c 'addr root' -c "p $field" \
> > +		"$SCRATCH_DEV" 2>/dev/null |
> > +		awk -v key="$field" '$1 == key { print $3; exit }'
> > +}
> > +
> > +agf_field()
> > +{
> > +	local field=$1
> > +	$XFS_DB_PROG -r -c 'agf 0' -c "p $field" "$SCRATCH_DEV" 2>/dev/null |
> > +		awk -v key="$field" '$1 == key { print $3; exit }'
> > +}
> > +
> > +alloc_root_field()
> > +{
> > +	local root=$1 field=$2
> > +	$XFS_DB_PROG -r -c 'agf 0' -c "addr $root" -c "p $field" \
> > +		"$SCRATCH_DEV" 2>/dev/null |
> > +		awk -v key="$field" '$1 == key { print $3; exit }'
> > +}
> > +
> > +ag0_reservation_state()
> > +{
> > +	$XFS_DB_PROG -r -c agresv "$SCRATCH_DEV" 2>/dev/null |
> > +		awk '$1 == "AG" && $2 == "0:" { print $6, $8, $10; exit }'
> > +}
> 
> Seems like a lot of these helpers could be replaced with calls to
> _scratch_xfs_get_metadata_field()..?
> 
> > +
> > +free_record_covering()
> > +{
> > +	local agbno=$1
> > +	$XFS_DB_PROG -r -c 'agf 0' -c 'addr bnoroot' -c 'type bnobt' \
> > +		-c 'btdump -i' "$SCRATCH_DEV" 2>/dev/null |
> > +		awk -v target="$agbno" '/^[0-9]+:\[/ {
> > +			record = $0
> > +			sub(/^[^[]*\[/, "", record)
> > +			sub(/\].*$/, "", record)
> > +			split(record, value, /,/)
> > +			start = value[1] + 0
> > +			blocks = value[2] + 0
> > +			if (start <= target && target < start + blocks) {
> > +				print 0, start, blocks
> > +				exit
> > +			}
> > +		}'
> > +}
> > +
> > +largest_ag0_free_extent()
> > +{
> > +	$XFS_DB_PROG -r -c 'agf 0' -c 'addr bnoroot' -c 'type bnobt' \
> > +		-c 'btdump -i' "$SCRATCH_DEV" 2>/dev/null |
> > +		awk '/^[0-9]+:\[/ {
> > +			record = $0
> > +			sub(/^[^[]*\[/, "", record)
> > +			sub(/\].*$/, "", record)
> > +			split(record, value, /,/)
> > +			if (value[2] + 0 > max) {
> > +				start = value[1] + 0
> > +				max = value[2] + 0
> > +			}
> > +		} END { if (max) print start, max }'
> > +}
> > +
> > +inspect_unmounted()
> > +{
> > +	sync -f "$SCRATCH_MNT"
> > +	_scratch_unmount
> > +}
> > +
> > +mount_fs()
> > +{
> > +	_scratch_mount -o noatime,inode64,logbufs=8,logbsize=32k,noquota
> > +}
> > +
> 
> Both of the above seem kind of spurious. Could they just be replaced
> with direct calls to the underlying helpers?
> 
> > +release_ag1()
> > +{
> > +	local cutoff bytes
> > +
> > +	cutoff=$($XFS_IO_PROG -r -c 'bmap -v' "$SCRATCH_MNT/filler" |
> > +		awk '$4 == 1 {
> > +			range = $2
> > +			gsub(/[\[\]:]/, "", range)
> > +			split(range, sector, /\.\./)
> > +			printf "%.0f\n", sector[1] / 8
> > +			exit
> > +		}')
> > +	[[ -n $cutoff ]] || return 1
> > +	bytes=$((cutoff * 4096))
> > +	truncate -s "$bytes" "$SCRATCH_MNT/filler"
> > +	$XFS_IO_PROG -c fsync "$SCRATCH_MNT/filler"
> > +}
> > +
> > +align_filler_eof()
> > +{
> > +	local target=$1 cutoff current bytes
> > +
> > +	cutoff=$($XFS_IO_PROG -r -c 'bmap -v' "$SCRATCH_MNT/filler" 2>/dev/null |
> > +		awk -v target="$target" '$4 == 0 {
> > +			file_range = $2
> > +			ag_range = $5
> > +			gsub(/[\[\]:]/, "", file_range)
> > +			gsub(/[()]/, "", ag_range)
> > +			split(file_range, file_sector, /\.\./)
> > +			split(ag_range, ag_sector, /\.\./)
> > +			if (int(ag_sector[2] / 8) + 1 == target) {
> > +				printf "%.0f\n", (file_sector[2] + 1) / 8
> > +				exit
> > +			}
> > +		}')
> > +	[[ -n $cutoff ]] || return 1
> > +	current=$(( $(_get_filesize "$SCRATCH_MNT/filler") / 4096 ))
> > +	if (( cutoff < current )); then
> > +		bytes=$((cutoff * 4096))
> > +		truncate -s "$bytes" "$SCRATCH_MNT/filler"
> > +		$XFS_IO_PROG -c fsync "$SCRATCH_MNT/filler"
> > +	fi
> > +}
> > +
> > +find_trigger_hole_offset()
> > +{
> > +	$XFS_IO_PROG -r -c 'bmap -v' "$SCRATCH_MNT/filler" |
> > +		awk '$4 == 0 {
> > +			file_range = $2
> > +			ag_range = $5
> > +			gsub(/[\[\]:]/, "", file_range)
> > +			gsub(/[()]/, "", ag_range)
> > +			split(file_range, f, /\.\./)
> > +			split(ag_range, a, /\.\./)
> > +			file_start = f[1] / 8
> > +			ag_start = a[1] / 8
> > +			map_blocks = (f[2] - f[1] + 1) / 8
> > +			delta = (2 - (ag_start % 8) + 8) % 8
> > +			if (map_blocks >= delta + 16) {
> > +				printf "%.0f %.0f\n", file_start + delta, ag_start + delta
> > +				exit
> > +			}
> > +		}'
> > +}
> > +
> > +consume_ag0_headroom()
> > +{
> > +	local values free reserved used outside
> > +
> > +	values=$(ag0_reservation_state)
> > +	read -r free reserved used <<<"$values"
> > +	outside=$((free - (reserved - used)))
> > +	(( outside <= 0 )) && return 0
> > +
> > +	mount_fs
> > +	"$REPRO" consume "$SCRATCH_MNT/filler" "$outside" >>"$seqres.full" 2>&1 || true
> > +	inspect_unmounted
> > +}
> > +
> > +_scratch_mkfs_xfs $mkfs_opts >>"$seqres.full" 2>&1
> > +mount_fs
> > +: > "$SCRATCH_MNT/filler"
> > +inspect_unmounted
> > +
> > +count=$(agi_field count)
> > +freecount=$(agi_field freecount)
> > +allocated=$((count - freecount))
> > +need=$((TARGET_ALLOCATED - allocated))
> > +(( need > 0 )) || _fail "invalid initial inode state"
> > +
> 
> This check doesn't seem all that useful.. verify we need to allocate
> inodes on a newly formatted fs..?
> 
> > +mount_fs
> > +"$REPRO" create "$SCRATCH_MNT" "$need" >>"$seqres.full" 2>&1 || \
> > +	_fail "inode population failed"
> > +inspect_unmounted
> > +
> > +count=$(agi_field count)
> > +freecount=$(agi_field freecount)
> > +inobt_level=$(inobt_root_field level)
> > +inobt_recs=$(inobt_root_field numrecs)
> > +if (( count != TARGET_ALLOCATED || freecount != 0 ||
> > +      inobt_level != 0 || inobt_recs != 252 )); then
> > +	_fail "failed to build one full inobt leaf"
> > +fi
> > +
> 
> Something I'm curious about in general is whether this can be made more
> generic beyond creating/expecting a very specific geometry and
> allocation state, but I understand this is quite hard to reproduce and
> may not be possible.
> 
> That aside, I think some brief comments for some of the helpers used
> below would be helpful to review/maintain this sort of test long term.
> I.e., things like release_ag1, consume_ag0_headroom, etc. aren't very
> self explanatory.
> 
> > +mount_fs
> > +"$REPRO" fragment "$SCRATCH_MNT" "$FRAGMENT_FILES" >>"$seqres.full" 2>&1 || \
> > +	_fail "free-space fragmentation failed"
> > +"$REPRO" fill "$SCRATCH_MNT/filler" >>"$seqres.full" 2>&1 || \
> > +	_fail "free-space fill failed"
> > +release_ag1 || _fail "failed to release AG1 filler mappings"
> > +inspect_unmounted
> > +
> > +consume_ag0_headroom
> > +read -r largest_start _ <<<"$(largest_ag0_free_extent)"
> > +mount_fs
> > +align_filler_eof "$largest_start" || _fail "cannot align filler EOF"
> > +inspect_unmounted
> > +consume_ag0_headroom
> > +
> > +mount_fs
> > +read -r file_offset agblock <<<"$(find_trigger_hole_offset)"
> > +[[ -n $file_offset && -n $agblock ]] || _fail "no suitable sparse inode extent"
> > +"$REPRO" punch "$SCRATCH_MNT/filler" "$file_offset" 8 >>"$seqres.full" 2>&1 || \
> > +	_fail "failed to create sparse inode extent"
> > +inspect_unmounted
> > +
> > +read -r free reserved used <<<"$(ag0_reservation_state)"
> > +reservation=$((reserved - used))
> > +outside=$((free - reservation))
> > +bno_level=$(agf_field bnolevel)
> > +cnt_level=$(agf_field cntlevel)
> > +rmap_level=$(agf_field rmaplevel)
> > +flcount=$(agf_field flcount)
> > +bno_recs=$(alloc_root_field bnoroot numrecs)
> > +cnt_recs=$(alloc_root_field cntroot numrecs)
> > +inobt_level=$(inobt_root_field level)
> > +inobt_recs=$(inobt_root_field numrecs)
> > +minfree=$(min_freelist "$bno_level" "$cnt_level" "$rmap_level" 5 5 1)
> > +pagf_free=$((free - flcount))
> > +available=$(space_available "$pagf_free" "$flcount" "$reservation" \
> > +	"$minfree" "$INOBT_MAXLEVELS")
> > +cover=$(free_record_covering "$agblock")
> > +
> > +printf 'gate free=%d reservation=%d outside=%d fl=%d minfree=%d available=%d levels=%d/%d/%d alloc_recs=%d/%d inobt=%d/%d hole=%d record="%s"\n' \
> > +	"$free" "$reservation" "$outside" "$flcount" "$minfree" \
> > +	"$available" "$bno_level" "$cnt_level" "$rmap_level" \
> > +	"$bno_recs" "$cnt_recs" "$inobt_level" "$inobt_recs" \
> > +	"$agblock" "$cover" >>"$seqres.full"
> > +
> > +if (( available < 7 || available >= 15 || bno_level != 1 ||
> > +      cnt_level != 1 || bno_recs != 505 || cnt_recs != 505 ||
> > +      inobt_level != 0 || inobt_recs != 252 || flcount != minfree )) ||
> > +   [[ $cover != "0 $agblock 8" ]]; then
> > +	_fail "combined inode allocation precondition not met"
> > +fi
> 
> This is kind of where I wonder if we've made this so specific and
> targeted that while it works right now, some future change to allocation
> behavior, geometry, etc. could render it ineffective long term. This
> somewhat relates to the question of why this doesn't seem to work for
> XFS_DEBUG.
> 
> Also FWIW it might not be worth having these various setup condition
> failures trigger an actual test failure like this. Perhaps we could
> replace some or most of these with _notrun?
> 
> Getting back to sparse inodes in general, it looks like we only really
> have one other test in xfs/076, and that looks like a basic
> functionality test. It fills up the fs, punches out fragments of free
> space, then confirms we can allocate inodes into the fragmented space.
> Clearly that hasn't been sufficient to reproduce this insert failure
> problem, even though it repeatedly runs into ENOSPC. It would be great
> IMO if we could come up with something generic to better test these
> boundary conditions, even if it wasn't an immediate reproducer. Hmm..
> maybe there's something we could do in kernel (i.e. debug mode or with
> injection knobs) that would help produce more AGFL and/or allocbt
> split/merge activity at < max levels or something. More thought
> required...
> 
> Anyways, I'm not opposed to having a super specific test like this on
> its own. I do think this will probably require a few rounds of
> significant simplification and whatnot though to make it a bit more
> palatable for upstream merge. Thanks.
> 
> Brian
> 
> > +
> > +mount_fs
> > +set +e
> > +"$REPRO" trigger "$SCRATCH_MNT" >>"$seqres.full" 2>&1
> > +trigger_status=$?
> > +set -e
> > +sleep 1
> > +
> > +if _check_dmesg_for 'Internal error i != 1|invalid sparse inode record|Shutting down filesystem'; then
> > +	_fail "sparse inode allocation shut down the filesystem"
> > +fi
> > +(( trigger_status == 0 )) || _fail "inode allocation failed with an unexpected error"
> > +
> > +# Verify that the filesystem remains usable after the allocation attempt.
> > +$XFS_IO_PROG -c stat "$SCRATCH_MNT" >>"$seqres.full" 2>&1 || \
> > +	_fail "filesystem is not usable after inode allocation"
> > +
> > +echo Silence is golden
> > +status=0
> > +exit
> > diff --git a/tests/xfs/842.out b/tests/xfs/842.out
> > new file mode 100644
> > index 00000000..643d0528
> > --- /dev/null
> > +++ b/tests/xfs/842.out
> > @@ -0,0 +1,2 @@
> > +QA output created by 842
> > +Silence is golden
> > -- 
> > 2.43.0
> > 
> 
> 

^ permalink raw reply	[flat|nested] 3+ messages in thread

end of thread, other threads:[~2026-08-26 15:22 UTC | newest]

Thread overview: 3+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-07-30 13:45 [PATCH] xfs: Test sparse inode allocation near AGFL limits Matt Fleming
2026-08-26 14:35 ` Brian Foster
2026-08-26 15:22   ` Darrick J. Wong

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox