fix(swap): roll back partial teardown, resize without a swapless gap, lock the disable path [P1]

Four defects from the follow-up audit.

1. Legacy migration swallowed every error.

   drop_legacy_managed_file was `let _ = file.remove(); Ok(())`, which tolerated not only
   the expected "that path is someone else's now" but also PermissionDenied, read errors
   and failed unlinks. If /etc/sysctl.d/99-awg2-harness.conf survived, it would keep
   winning over the new 90- file - it sorts later - while the reconcile reported success,
   so vm.swappiness would silently stay at the old value. The three cases are now
   distinguished: Absent is fine, Foreign warns and continues, Managed removes and any
   failure propagates.

2. The disable branches ran outside the lock.

   Both ensure_swap and ensure_sysctl_tuning checked `enabled` before acquiring their
   lock, so the cleanup path - which deletes managed files - was unsynchronised. A manual
   CLI invocation and the daemon's periodic reconcile could write and delete the same file
   concurrently. The lock is now taken first in both.

3. A failed teardown left foreign swaps half-disabled.

   disable_foreign_swaps aborted the loop on the first failure, so a host with two foreign
   swaps could end with the first one off and the second still up - a state nobody asked
   for and the error message did not describe. It now tracks what it disabled and
   re-enables it, in reverse order and with the original priority, before returning the
   error. The restore is best-effort and warns per entry rather than replacing the reported
   failure with a rollback failure.

4. Resizing dropped swap for the whole allocation.

   A live swapfile cannot be resized in place, and the previous code did swapoff, remove,
   fallocate/dd, mkswap, swapon. On a host with no other swap that is a swapless window
   spanning the slowest and most failure-prone part of the operation - on a machine that
   was given swap precisely because memory is tight, with no way back if any step failed.

   The replacement is now built and activated at a staging sibling while the old swap is
   still serving, and only then is the old file released. The remaining gap is
   swapoff/rename/swapon, which allocates nothing. A failure while staging removes the
   staging file and leaves the original swap untouched; a failure at the final rename says
   explicitly that the file is in place and can be activated with swapon.

   Related, same function: changing only swap.priority used to fall through to the full
   recreate path, destroying and rebuilding a correct multi-gigabyte file to change one
   integer. Size and priority are now separate decisions - a priority-only change is a
   reactivation.

The reconciliation is now decided by an explicit match over (size correct, currently
active) rather than a single `geometry_ok` flag, which is what hid the priority case.

Three tests added around the new decision surface: the priority-only case is not a resize,
the staging path is a sibling of the swapfile, and the managed swap is never part of the
foreign teardown set.

cargo test --workspace: 179 + 4 passed. clippy -D warnings clean.
This commit is contained in:
2026-08-07 04:15:40 +05:00
parent 322f67f69a
commit 116b1ea91a
3 changed files with 233 additions and 37 deletions
+4
View File
@@ -40,6 +40,10 @@ impl ManagedFile {
}
}
pub fn path(&self) -> &std::path::Path {
&self.path
}
/// `Ok(None)` only when the file is genuinely absent. Any other failure propagates.
pub fn read(&self) -> anyhow::Result<Option<String>> {
match fs::read_to_string(&self.path) {