Skip to content

fix: decide swap state from the kernel, and act on devices rather than mountpoints - #4865

Open
xander-lin wants to merge 2 commits into
archlinux:masterfrom
xander-lin:fix/swap-state-not-display-marker
Open

xander-lin wants to merge 2 commits into
archlinux:masterfrom
xander-lin:fix/swap-state-not-display-marker

Conversation

@xander-lin

@xander-lin xander-lin commented Oct 28, 2026 •

Copy link
Copy Markdown

Summary

Three things go wrong around swap, and they share one cause: the code decides what swap is doing
from a value lsblk prints for display, instead of from what the kernel reports.

1. Cleanup cannot unmount a filesystem that has an active swap file on it

umount_all_existing() walks the partitions of the target disk. A partition holding an active swap
file is not a swap partition - its fs_type is the filesystem - so it reaches
umount(partition.path, recursive=True), which the kernel refuses because the swap file is still
active:

SysCallError: ['/usr/bin/umount', '-R', '/tmp/swap_file_mnt'] exited with abnormal exit code [32]:
umount: /tmp/swap_file_mnt: target is busy.

The partition is still mounted afterwards, so the installation stops here.

2. swapon() is not idempotent, and the mount stage only calls swapon()

Swap is machine-wide state, not per-installation state. A previous run - or the live system - can
leave the very area this layout wants active, and then the mount stage aborts:

DiskError: Could not enable swap /dev/loop0p1:
['/usr/bin/swapon', '/dev/loop0p1'] exited with abnormal exit code [255]:
swapon: /dev/loop0p1: swapon failed: Device or resource busy

Re-running an installation is the common case after a failure, which makes this one of the more
likely ways to lose an hour.

3. Luks2.lock() hands mountpoints to umount()

umount() starts with get_lsblk_info(), which takes a device. lock() iterates a child's
mountpoints and passes each one:

for mountpoint in child.mountpoints:
	umount(mountpoint, recursive=True)

When the child is an active swap area, that mountpoint is the string [SWAP]:

DiskError: Failed to read disk "[SWAP]" with lsblk

Fix

Read the active set from /proc/swaps, and act on devices rather than on displayed mountpoints.

--- a/archinstall/lib/disk/utils.py
+++ b/archinstall/lib/disk/utils.py
+def active_swaps() -> list[Path]:
+	"""Currently active swap areas, as reported by the kernel.
+
+	The first column of /proc/swaps is the area itself, either a block device
+	(e.g. /dev/dm-0) or a regular file (e.g. /mnt/swapfile). lsblk's '[SWAP]'
+	marker is a display convention, not state, and must not be used to decide
+	whether swap is active.
+	"""
+	entries: list[Path] = []
+	for line in Path('/proc/swaps').read_text().splitlines()[1:]:
+		if not (fields := line.split()):
+			continue
+		entries.append(Path(fields[0]))
+
+	return entries
+
+
+def _swap_areas_of(target: Path, mountpoints: list[Path] | None = None) -> list[Path]:
+	...  # the area itself, or anything living below the given mountpoints
+
 def swapon(path: Path) -> None:
+	# swap is machine-wide: a previous run may have left this area active
+	if _swap_areas_of(path):
+		debug(f'Swap is already active on {path}')
+		return
+
 	try:
 		SysCommand(['swapon', str(path)])
 	except SysCallError as err:
 		raise DiskError(f'Could not enable swap {path}:\n{err.message}')
 
+	if not _swap_areas_of(path):
+		raise DiskError(f'Swap was not activated on {path}, the kernel reports no swap area for it')
+
+
+def swapoff(path: Path, mountpoints: list[Path] | None = None) -> None:
+	# `mountpoints` is how a swap file living on `path` is found as well
+	areas = _swap_areas_of(path, mountpoints)
+	if not areas:
+		return
+
+	for area in areas:
+		SysCommand(['swapoff', str(area)])
+
+	if remaining := _swap_areas_of(path, mountpoints):
+		raise DiskError(f'Swap is still active on {path}: {[str(a) for a in remaining]}')

umount_all_existing() then dispatches on the filesystem type, and passes the mountpoints for the
filesystem case so a swap file on it is deactivated first:

if partition.fs_type == FilesystemType.CRYPTO_LUKS:
	Luks2(partition.path).lock()
elif partition.fs_type == FilesystemType.LINUX_SWAP:
	swapoff(partition.path)
else:
	swapoff(partition.path, get_lsblk_info(partition.path).mountpoints)
	umount(partition.path, recursive=True)

And lock() deactivates swap on the child, then unmounts the child by device path, so no
mountpoint reaches lsblk:

 	for child in lsblk_info.children:
-			for mountpoint in child.mountpoints:
-				umount(mountpoint, recursive=True)
+			swapoff(child.path, child.mountpoints)
+			umount(child.path, recursive=True)

lock() also raises if a mapper is still present after cryptsetup close, instead of carrying on.

from_existing_partition() no longer turns the marker into a mountpoint

The same marker reached the layout model, because from_existing_partition() took the first entry
of mountpoints as the mountpoint. A swap area has no mountpoint, so it now takes the first
absolute path:

-			mountpoint = partition_info.mountpoints[0] if partition_info.mountpoints else None
+			mountpoint = next((p for p in partition_info.mountpoints if p.is_absolute()), None)

Measurements

Four scenarios, run on loop devices with a real swap partition and a real swap file:

master #4857 #4745 this PR
1. active swap partition on the disk fail fixed fixed fixed
2. active swap file on a partition fail fail fail fixed
3. swapon() on an already-active area fail fail fail fixed
4. can [SWAP] reach lock() yes yes no no

Row 4 differs in mechanism rather than in outcome: #4745 filters the marker at the LsblkInfo
boundary, this PR stops lock() from passing mountpoints at all. Both work.

What this PR does not fix

Three things, so that it is not read as a complete answer:

A swap file is only deactivated through umount_all_existing(). swapoff() accepts the
mountpoints of a path, but nothing else in the tree passes them yet.

add_swapfile() is untouched. It still writes dd bs=<size> count=1, which allocates a buffer
of that size, and it does not set nodatacow on btrfs. It has no caller in this tree today, so
this PR leaves it alone rather than changing code nothing reaches.

Luks2.lock() still looks one level below the mapper. On LUKS-on-LVM the mountpoints live on
the logical volumes, which are grandchildren, so a re-run can still fail to close that mapper. This
is a separate change (#4864) because it needs a subtree walk, and I did not want to bundle it here.

Relation to the two open PRs

#4745 and #4857 already fix row 1, and both are correct for that row - the umount_all_existing()
change they carry is byte-identical. This PR overlaps them there.

The reason it exists is rows 2 and 3, which neither covers, and which are not about the same line:
swapon() is in the mount stage and a swap file is not a swap partition, so neither of them comes
near it. If either of those PRs is merged first, this one wants to shrink to rows 2 and 3 - there
would be nothing left to do for row 1.

I am not proposing that this replaces either of them, and if this PR cannot be merged on its own
that is a real objection to it rather than something to work around: shipping two overlapping
cleanups is worse than shipping one, and the row 1 change is not the part of this PR worth
defending.

One conflict, if #4864 lands first

#4864 also rewrites the for child in lsblk_info.children: body in Luks2.lock(), so the two
touches overlap there. Neither side is a superset: #4864 walks the subtree, this PR deactivates swap
and unmounts by device path. The merged body is both, and it is what I ran while checking the two
together:

for child in lsblk_info.children:
	# swap first, over the whole subtree
	for node in _lsblk_subtree(child):
		swapoff(node.path, node.mountpoints)

	# then unmount children before parents, by device path
	for node in reversed(_lsblk_subtree(child)):
		umount(node.path, recursive=True)

	debug(f'Closing crypt device {child.name}')
	SysCommand(f'cryptsetup close {child.name}')

if remaining := get_lsblk_info(self.luks_dev_path).children:
	raise DiskError(f'Could not close {self.luks_dev_path}: {[c.name for c in remaining]} still active')

_lsblk_subtree() comes from #4864; the swapoff() line and the trailing check are from this PR.

One thing to watch when resolving by hand: #4864's tests/test_luks_lock_subtree.py patches
get_lsblk_info() with a fixed return_value, so the re-read added here still sees the mapper and
the new check raises. That test needs children=[] if closed else [...] - the product code is
fine, the stub just has to model the close. I hit exactly this while checking the combination.

Tests

tests/test_swapoff_existing.py covers: swapoff() as a no-op when nothing is active, deactivating
both a device and a swap file found below a mountpoint, raising when an area survives the call, and
the umount_all_existing() dispatch (which path gets swapoff(), which gets umount(), and that
the filesystem case passes its mountpoints). lock() is covered for the same reason: swap is
deactivated with the mountpoints and unmounting uses the device path.

`swapoff()` decided whether swap was active from lsblk, which reports a
`[SWAP]` pseudo mountpoint for an active area. That marker is display
state, not kernel state, and it is also what `umount()` had to filter
out to avoid running `umount -R [SWAP]`.

Read the active swap areas from /proc/swaps instead. `swapoff()` now
takes the mountpoints of the device so a swapfile living on it is found
too, and raises if an area survives the call. `swapon()` skips an area
that is already active - swap is machine-wide state, so a re-run used to
abort with "Device or resource busy" - and verifies that activation
actually happened.
`Luks2.lock()` passed the mapper's mountpoints - directories - to
`umount()`, which starts with `get_lsblk_info()`; lsblk refuses a
directory, so a re-run that still had an unlocked and mounted LUKS
partition crashed during cleanup. Read the children and act on them as
devices: deactivate any swap area that lives on a child, unmount the
child, then close it, and raise if a mapper survives the close.

`umount_all_existing()` called `umount()` on swap partitions, which
walks the `[SWAP]` marker; it now calls `swapoff()` for them.
@xander-lin
xander-lin requested a review from Torxed as a code owner October 28, 2026 05:35
@Torxed

Torxed commented Oct 28, 2026

Copy link
Copy Markdown
Member

It's most likely LLM generated code, perhaps #4857 is more reasonable to merge?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants