From 91e1eff8a6742b594d06c6f725bf395b9897926d Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 23 Oct 2026 10:36:04 +0200 Subject: [PATCH] fix(btrfs): allocate the swapfile with fallocate instead of dd On a btrfs root the swapfile was written with dd from /dev/zero, so a swapfile the size of the RAM cost tens of GiB of writes and stalled every other writer on the filesystem. A caller that bounds the run, such as an async Ansible task, killed dd halfway, and the next run found the wrong size and started over. btrfs accepts a preallocated NOCOW swapfile and the swap directory already carries +C, so every filesystem now allocates with fallocate. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 13 ++++++------- src/swap_forge/core.py | 21 ++++----------------- tests/e2e/run.sh | 2 +- tests/unit/test_core.py | 17 ++++++++--------- 4 files changed, 19 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 29e728b..76734a3 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,10 @@ flowchart TD I -- yes --> J["swapoff, delete it, drop its fstab line"] I -- no --> K J --> K{"root filesystem is btrfs?"} - K -- yes --> L["chattr +C, turn compression off, allocate with dd"] + K -- yes --> L["chattr +C, turn compression off"] K -- no --> M["allocate with fallocate"] - L --> N["chmod 600"] - M --> N + L --> M + M --> N["chmod 600"] N --> O["mkswap"] O --> P["swapon"] P --> Q["append the entry to /etc/fstab"] @@ -43,7 +43,7 @@ The fstab entry is written **after** activation succeeds, so a failed run never ## 🛠 Features - **Automated Swapfile Creation:** Easily create a swapfile with a specified size. -- **Btrfs Aware:** Disables copy-on-write and compression on the swap directory and allocates with `dd`; other filesystems use `fallocate`. +- **Btrfs Aware:** Disables copy-on-write and compression on the swap directory before allocating; every filesystem allocates with `fallocate`. - **FSTAB Integration:** Automatically updates `/etc/fstab` to ensure the swapfile is mounted at boot. - **Safety Checks:** Skips creation when a swapfile of the correct size already exists, and recreates it when the size differs. - **Simple CLI Interface:** Run the command with a single argument. @@ -66,9 +66,8 @@ The package installs **two** identical commands: `swap-forge` (primary) and `swa - **Python 3.10+** 🐍 - **root privileges** — the tool writes `/etc/fstab` and activates swap -- Always required on `PATH`: `findmnt`, `swapoff`, `chmod`, `mkswap`, `swapon` -- On a **btrfs** root additionally: `chattr`, `btrfs`, `dd` -- On any **other** root filesystem additionally: `fallocate` +- Always required on `PATH`: `findmnt`, `swapoff`, `fallocate`, `chmod`, `mkswap`, `swapon` +- On a **btrfs** root additionally: `chattr`, `btrfs` If a required command is missing, the tool exits with code `127` and a one‑line error instead of a traceback. diff --git a/src/swap_forge/core.py b/src/swap_forge/core.py index c32558a..c92f5a8 100644 --- a/src/swap_forge/core.py +++ b/src/swap_forge/core.py @@ -10,9 +10,8 @@ DEFAULT_SWAP_PATH = Path("/swapfile") SIZE_PATTERN = re.compile(r"^(\d+)([gGmM]?[bB]?)?$") MIB_PER_GIB = 1024 -REQUIRED_BINARIES = ("findmnt", "swapoff", "chmod", "mkswap", "swapon") -BTRFS_BINARIES = ("chattr", "btrfs", "dd") -NON_BTRFS_BINARIES = ("fallocate",) +REQUIRED_BINARIES = ("findmnt", "swapoff", "fallocate", "chmod", "mkswap", "swapon") +BTRFS_BINARIES = ("chattr", "btrfs") EXIT_MISSING_BINARY = 127 EXIT_COMMAND_FAILED = 1 @@ -117,8 +116,8 @@ def create_swap(path, size_mib, fs, fstab=FSTAB_FILE): fs: Filesystem type of the root mount. fstab: Path of the fstab file to append to. """ + print(f"Creating {size_mib} MiB swapfile on {fs} at {path}") if fs == "btrfs": - print(f"Creating {size_mib} MiB swapfile on btrfs at {path}") path.parent.mkdir(parents=True, exist_ok=True) run(["chattr", "+C", str(path.parent)], check=False) run( @@ -133,20 +132,8 @@ def create_swap(path, size_mib, fs, fstab=FSTAB_FILE): ], check=False, ) - run( - [ - "dd", - "if=/dev/zero", - f"of={path}", - "bs=1M", - f"count={size_mib}", - "status=progress", - ] - ) - else: - print(f"Creating {size_mib} MiB swapfile on {fs} at {path}") - run(["fallocate", "-l", f"{size_mib}M", str(path)]) + run(["fallocate", "-l", f"{size_mib}M", str(path)]) run(["chmod", "600", str(path)]) run(["mkswap", str(path)]) run(["swapon", str(path)]) diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index b1a2130..0282ff4 100644 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -21,7 +21,7 @@ case "${SWAPFORGE}" in esac echo "ok: resolved from the installed package, not the source tree" -for binary in findmnt swapoff chmod mkswap swapon fallocate chattr btrfs dd; do +for binary in findmnt swapoff fallocate chmod mkswap swapon chattr btrfs; do command -v "${binary}" >/dev/null || fail "${binary} is missing from the image" done echo "ok: every declared binary is present" diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py index 28c4388..43fa4bb 100644 --- a/tests/unit/test_core.py +++ b/tests/unit/test_core.py @@ -7,7 +7,6 @@ from swap_forge.core import ( BTRFS_BINARIES, BTRFS_SWAP_PATH, DEFAULT_SWAP_PATH, - NON_BTRFS_BINARIES, REQUIRED_BINARIES, MissingBinaryError, create_swap, @@ -22,28 +21,24 @@ from swap_forge.core import ( class TestRequiredBinaries(unittest.TestCase): def test_declares_every_external_command_the_package_calls(self): - declared = ( - set(REQUIRED_BINARIES) | set(BTRFS_BINARIES) | set(NON_BTRFS_BINARIES) - ) + declared = set(REQUIRED_BINARIES) | set(BTRFS_BINARIES) self.assertEqual( declared, { "findmnt", "swapoff", + "fallocate", "chmod", "mkswap", "swapon", "chattr", "btrfs", - "dd", - "fallocate", }, ) def test_filesystem_specific_binaries_are_not_declared_as_always_required(self): self.assertFalse(set(REQUIRED_BINARIES) & set(BTRFS_BINARIES)) - self.assertFalse(set(REQUIRED_BINARIES) & set(NON_BTRFS_BINARIES)) def test_missing_binary_is_reported_with_its_name(self): with mock.patch("swap_forge.core.subprocess.run") as subprocess_run: @@ -153,7 +148,7 @@ class TestCreateSwap(unittest.TestCase): self.assertEqual(commands, ["fallocate", "chmod", "mkswap", "swapon"]) self.assertEqual(fstab.read_text(), f"{swapfile} none swap sw 0 0\n") - def test_btrfs_disables_copy_on_write_and_uses_dd(self): + def test_btrfs_disables_copy_on_write_before_fallocate(self): with tempfile.TemporaryDirectory() as base: swapfile = Path(base) / "swap" / "swapfile" fstab = Path(base) / "fstab" @@ -164,7 +159,11 @@ class TestCreateSwap(unittest.TestCase): commands = [call.args[0][0] for call in run.call_args_list] self.assertEqual( - commands, ["chattr", "btrfs", "dd", "chmod", "mkswap", "swapon"] + commands, ["chattr", "btrfs", "fallocate", "chmod", "mkswap", "swapon"] + ) + self.assertEqual( + run.call_args_list[2].args[0], + ["fallocate", "-l", "1024M", str(swapfile)], ) self.assertTrue(swapfile.parent.is_dir())