> diff --git a/tools/testing/selftests/bpf/cgroup_iter_io.h 
> b/tools/testing/selftests/bpf/cgroup_iter_io.h
> new file mode 100644
> index 000000000000..f4bbaaccdf71
> --- /dev/null
> +++ b/tools/testing/selftests/bpf/cgroup_iter_io.h

[ ... ]

> diff --git a/tools/testing/selftests/bpf/config 
> b/tools/testing/selftests/bpf/config
> index ea7044f30adc..270e6bf9194d 100644
> --- a/tools/testing/selftests/bpf/config
> +++ b/tools/testing/selftests/bpf/config

[ ... ]

> diff --git a/tools/testing/selftests/bpf/prog_tests/cgroup_iter_io.c 
> b/tools/testing/selftests/bpf/prog_tests/cgroup_iter_io.c
> new file mode 100644
> index 000000000000..5d27e5d28379
> --- /dev/null
> +++ b/tools/testing/selftests/bpf/prog_tests/cgroup_iter_io.c
> @@ -0,0 +1,277 @@

[ ... ]

> +/* Set up a loop device for cgroup-charged I/O. */
> +static int loop_setup(char *loop_path, size_t sz, int *ctl_fd, int *loop_fd,
> +                   int *back_fd)
> +{
> +     char back_path[] = "/tmp/cgroup_iter_io.XXXXXX";
> +     int nr;
> +
> +     *ctl_fd = *loop_fd = *back_fd = -1;
> +
> +     *ctl_fd = open("/dev/loop-control", O_RDWR | O_CLOEXEC);
> +     if (*ctl_fd < 0)
> +             return -1;
> +
> +     nr = ioctl(*ctl_fd, LOOP_CTL_GET_FREE);
> +     if (nr < 0)
> +             goto err;
> +     snprintf(loop_path, sz, "/dev/loop%d", nr);
> +
> +     *back_fd = mkstemp(back_path);
> +     if (*back_fd < 0)
> +             goto err;
> +     unlink(back_path);
> +     if (ftruncate(*back_fd, (off_t)IO_SIZE * 4))
> +             goto err;
> +
> +     *loop_fd = open(loop_path, O_RDWR | O_CLOEXEC);
> +     if (*loop_fd < 0)
> +             goto err;
> +     if (ioctl(*loop_fd, LOOP_SET_FD, *back_fd))
> +             goto err;
> +
> +     return 0;
> +err:
> +     if (*loop_fd >= 0)
> +             close(*loop_fd);
> +     if (*back_fd >= 0)
> +             close(*back_fd);
> +     close(*ctl_fd);
> +     *ctl_fd = *loop_fd = *back_fd = -1;
> +     return -1;
> +}

Does the err: cleanup path leak the loop device?

LOOP_CTL_GET_FREE can allocate a new loop device when no unbound device
exists, and nr is stored. Every failure after that point (mkstemp,
ftruncate, open(/dev/loopN), LOOP_SET_FD) leaves that device behind.

The caller jumps straight to cleanup_cgroup_fd on loop_setup() failure,
bypassing cleanup_loop, so loop_teardown() never runs and the device
remains allocated.

Recording nr and calling ioctl(*ctl_fd, LOOP_CTL_REMOVE, nr) on the err:
path would fix this. Additionally, loop_teardown() ignores the return of
both LOOP_CLR_FD and LOOP_CTL_REMOVE, so a device left in Lo_bound state
also leaks silently.

> +/* Read @dev's io.stat counters. @dev uses kernel dev_t encoding. */
> +static int parse_io_stat(int cgroup_fd, __u64 dev, struct io_query *out)
> +{
> +     unsigned int want_maj = dev >> 20, want_min = dev & ((1U << 20) - 1);
> +     char buf[4096], *line, *saveptr;
> +     int fd, n, ret = -1;
> +
> +     fd = openat(cgroup_fd, "io.stat", O_RDONLY);
> +     if (fd < 0)
> +             return -1;
> +     n = read(fd, buf, sizeof(buf) - 1);
> +     close(fd);
> +     if (n <= 0)
> +             return -1;
> +     buf[n] = '\0';

Will a single 4095-byte read() always capture the complete io.stat output?

io.stat is a seq_file with one line per blkg, and seq_read_iter() copies
with byte granularity, not record boundaries. Two failure modes:

1. If the cgroup's blkg list produces more than 4095 bytes, the loop
device's line may not be in the buffer, and parse_io_stat() returns -1.

2. The cut can land mid-line. A truncated line like "7:0 rbytes=4194304
wbytes=41" satisfies the '< 2' check below and is accepted with the
remaining counters silently zero, producing a bogus counter mismatch
against the kfunc rather than a parse error.

Reading in a loop until read() returns 0, or at least treating a non-
terminated final line as an error, would avoid both.

> +     for (line = strtok_r(buf, "\n", &saveptr); line;
> +          line = strtok_r(NULL, "\n", &saveptr)) {
> +             unsigned long long rb = 0, wb = 0, ri = 0, wi = 0, db = 0, di = 
> 0;
> +             unsigned int maj, min;
> +
> +             /* Only the device id is required; missing counters stay zero. 
> */
> +             if (sscanf(line,
> +                        "%u:%u rbytes=%llu wbytes=%llu rios=%llu wios=%llu 
> dbytes=%llu dios=%llu",
> +                        &maj, &min, &rb, &wb, &ri, &wi, &db, &di) < 2)
> +                     continue;
> +             if (maj != want_maj || min != want_min)
> +                     continue;
> +
> +             out->rbytes = rb;
> +             out->wbytes = wb;
> +             out->rios = ri;
> +             out->wios = wi;
> +             out->dbytes = db;
> +             out->dios = di;
> +             ret = 0;
> +             break;
> +     }
> +     return ret;
> +}

Should the partial conversion check be stricter?

Accepting '< 2' and then returning success means a line that matched the
device but whose counters were not parsed is reported as all-zero rather
than an error. The caller immediately uses strict equality checks:

        ASSERT_EQ(q->dbytes, filev.dbytes, "dbytes");

so a parse problem is reported as a counter mismatch against the kfunc,
pointing at the kernel instead of the parser.

This is reachable because blkcg_print_one_stat() emits the device name
unconditionally but gates the counter group with:

        if (rbytes || wbytes || rios || wios)
                seq_printf(s, "rbytes=%llu wbytes=%llu ... dios=%llu", ...);

A cgroup whose only traffic to a device was discard produces "MAJ:MIN "
with no key=value pairs. sscanf() returns 2, parse_io_stat() returns
success with dbytes=dios=0, and the match subtest fails on "dbytes" even
though the kernel is correct.

Requiring all eight conversions (== 8) once maj:min matches would surface
the real problem.

[ ... ]

> +     if (loop_setup(loop_path, sizeof(loop_path), &ctl_fd, &loop_fd, 
> &back_fd)) {
> +             test__skip();   /* needs root + CONFIG_BLK_DEV_LOOP */
> +             goto cleanup_cgroup_fd;
> +     }

A subsystem pattern flags this as potentially concerning:

loop_setup() collapses eight distinct failures into a single -1, and the
caller turns every one of them into a bare test__skip() with no runtime
reason. The source comment says "needs root + CONFIG_BLK_DEV_LOOP", but
the same return value is produced by: open("/dev/loop-control") failing,
LOOP_CTL_GET_FREE failing, mkstemp() failing, ftruncate() failing (ENOSPC
on a small tmpfs /tmp), open("/dev/loopN") failing, and LOOP_SET_FD
failing.

The LOOP_SET_FD case is a genuine race: LOOP_CTL_GET_FREE is not a
reservation, so any concurrent consumer (another test_progs worker under
-j, udev, systemd, losetup) can bind /dev/loopN between GET_FREE and
SET_FD, and SET_FD then returns EBUSY. The result is that the whole test,
including all five subtests exercising the new blkcg kfuncs, silently
reports SKIP. Since nothing is printed, CI shows a green skip and a broken
kfunc goes unnoticed indefinitely.

Could the errno be printed before skipping, and "prerequisite absent"
(ENOENT/ENODEV/EPERM on /dev/loop-control) be distinguished from a real
failure (EBUSY, ENOSPC, ENOMEM)?

[ ... ]

> diff --git a/tools/testing/selftests/bpf/progs/cgroup_iter_io.c 
> b/tools/testing/selftests/bpf/progs/cgroup_iter_io.c
> new file mode 100644
> index 000000000000..2cd538068987
> --- /dev/null
> +++ b/tools/testing/selftests/bpf/progs/cgroup_iter_io.c

[ ... ]


---
AI reviewed your patch. Please fix the bug or email reply why it's not a bug.
See: https://github.com/kernel-patches/vmtest/blob/master/ci/claude/README.md

CI run summary: https://github.com/kernel-patches/bpf/actions/runs/32073368069

Reply via email to