]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
loop-util: work around kernel loop driver partition scan race
authorDaan De Meyer <daan@amutable.com>
Mon, 30 Mar 2026 08:43:38 +0000 (08:43 +0000)
committerLuca Boccassi <luca.boccassi@gmail.com>
Wed, 1 Apr 2026 11:32:10 +0000 (12:32 +0100)
The kernel loop driver has a race condition in LOOP_CONFIGURE when
LO_FLAGS_PARTSCAN is set: it sends a KOBJ_CHANGE uevent (with
GD_NEED_PART_SCAN set) before calling loop_reread_partitions(). If
udev opens the device in response to the uevent before
loop_reread_partitions() runs, the kernel's blkdev_get_whole() sees
GD_NEED_PART_SCAN and triggers a first partition scan. Then
loop_reread_partitions() runs a second scan that drops all partitions
from the first scan (via blk_drop_partitions()) before re-adding them.
This causes partition devices to briefly disappear (plugged -> dead ->
plugged), which breaks systemd units with BindsTo= on the partition
device: systemd observes the dead transition, fails the dependent
units with 'dependency', and does not retry when the device reappears.

Work around this in loop_device_make_internal() by splitting the loop
device setup into two steps: first LOOP_CONFIGURE without
LO_FLAGS_PARTSCAN, then LOOP_SET_STATUS64 to enable partscan. This
avoids the race because:

1. LOOP_CONFIGURE without partscan: disk_force_media_change() sets
   GD_NEED_PART_SCAN, but GD_SUPPRESS_PART_SCAN remains set. If udev
   opens the device, blkdev_get_whole() calls bdev_disk_changed()
   which clears GD_NEED_PART_SCAN, but blk_add_partitions() returns
   early because disk_has_partscan() is false — no partitions appear,
   the flag is drained harmlessly.

2. Between the two ioctls, we open and close the device to ensure
   GD_NEED_PART_SCAN is drained regardless of whether udev processed
   the uevent yet.

3. LOOP_SET_STATUS64 with LO_FLAGS_PARTSCAN: clears
   GD_SUPPRESS_PART_SCAN and calls loop_reread_partitions() for a
   single clean scan. Crucially, loop_set_status() does not call
   disk_force_media_change(), so GD_NEED_PART_SCAN is never set again.

A proper kernel fix has been submitted:
https://lore.kernel.org/linux-block/20260330081819.652890-1-daan@amutable.com/T/#u

This workaround should be dropped once the fix is widely available.

Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
src/shared/loop-util.c

index fbed79bb2ec16efc56a89c86fb69ddc0ab11d6b0..4a759f4a7e24e522f3d6afa835a1bf4bd26827c4 100644 (file)
@@ -541,12 +541,26 @@ static int loop_device_make_internal(
                         return r;
         }
 
+        /* Strip LO_FLAGS_PARTSCAN from LOOP_CONFIGURE and enable it afterwards via
+         * LOOP_SET_STATUS64 to work around a kernel race: LOOP_CONFIGURE sends a uevent with
+         * GD_NEED_PART_SCAN set before calling loop_reread_partitions(). If udev opens the device in
+         * response, blkdev_get_whole() triggers a first scan, then loop_reread_partitions() does a
+         * second scan that briefly drops all partitions. By configuring without partscan,
+         * GD_SUPPRESS_PART_SCAN stays set, making any concurrent open harmless. LOOP_SET_STATUS64
+         * doesn't call disk_force_media_change() so it doesn't set GD_NEED_PART_SCAN.
+         *
+         * See: https://lore.kernel.org/linux-block/20260330081819.652890-1-daan@amutable.com/T/#u
+         * Drop this workaround once the kernel fix is widely available. */
+        bool deferred_partscan = FLAGS_SET(loop_flags, LO_FLAGS_PARTSCAN);
+
         config = (struct loop_config) {
                 .fd = fd,
                 .block_size = sector_size,
                 .info = {
                         /* Use the specified flags, but configure the read-only flag from the open flags, and force autoclear */
-                        .lo_flags = (loop_flags & ~LO_FLAGS_READ_ONLY) | ((open_flags & O_ACCMODE_STRICT) == O_RDONLY ? LO_FLAGS_READ_ONLY : 0) | LO_FLAGS_AUTOCLEAR,
+                        .lo_flags = ((loop_flags & ~(LO_FLAGS_READ_ONLY|LO_FLAGS_PARTSCAN)) |
+                                     ((open_flags & O_ACCMODE_STRICT) == O_RDONLY ? LO_FLAGS_READ_ONLY : 0) |
+                                     LO_FLAGS_AUTOCLEAR),
                         .lo_offset = offset,
                         .lo_sizelimit = size == UINT64_MAX ? 0 : size,
                 },
@@ -638,6 +652,24 @@ static int loop_device_make_internal(
                 }
         }
 
+        if (deferred_partscan) {
+                /* Open+close to drain GD_NEED_PART_SCAN harmlessly (GD_SUPPRESS_PART_SCAN is still
+                 * set so no partitions appear). Then enable partscan via LOOP_SET_STATUS64. */
+                int tmp_fd = fd_reopen(d->fd, O_RDONLY|O_CLOEXEC|O_NONBLOCK);
+                if (tmp_fd < 0)
+                        return log_debug_errno(tmp_fd, "Failed to reopen loop device to drain partscan flag: %m");
+                safe_close(tmp_fd);
+
+                struct loop_info64 info;
+                if (ioctl(d->fd, LOOP_GET_STATUS64, &info) < 0)
+                        return log_debug_errno(errno, "Failed to get loop device status: %m");
+
+                info.lo_flags |= LO_FLAGS_PARTSCAN;
+
+                if (ioctl(d->fd, LOOP_SET_STATUS64, &info) < 0)
+                        return log_debug_errno(errno, "Failed to enable partscan on loop device: %m");
+        }
+
         d->backing_file = TAKE_PTR(backing_file);
         d->backing_inode = st.st_ino;
         d->backing_devno = st.st_dev;