#!/usr/bin/env python3
#
# Copyright (c) 2025-2026 openmediavault plugin developers
#
# This file is licensed under the terms of the GNU General Public
# License version 2. This program is licensed "as is" without any
# warranty of any kind, whether express or implied.
#
import os, sys, subprocess, time, re, shutil
import stat
import pwd, grp
import atexit, signal
import errno, fcntl
import contextlib
from pathlib import Path
from collections import namedtuple

PathEntry = namedtuple("PathEntry", ["path", "mode", "uid", "gid", "perms"])
ServiceEntry = namedtuple("ServiceEntry", ["name", "action"])

try:
    import yaml  # type: ignore
except Exception:
    yaml = None

SCRIPT_VERSION = "0.1.7"
CONFIG = Path("/etc/omv-writecache/config.yaml")

# Workspace root for overlay metadata (lower binds, upperdirs, workdirs).
# Default behavior uses a tmpfs at /run/omv-writecache (mounted by this tool).
# You can instead point this to a persistent path (e.g. a data disk) by setting:
#   workspace_type: path
#   workspace_root: /srv/dev-disk-by-uuid-.../omv-writecache
#
# IMPORTANT: overlayfs requires upperdir and workdir be on the same filesystem.

# Backwards-compatible constant name; actual workspace root is derived from config.
DEFAULT_WORKSPACE_ROOT = Path("/run/omv-writecache")

DEFAULT_LOG_FILE = "/var/log/omv-writecache.log"
DEFAULTS_FILE = "/etc/default/omv-writecache"

# Mountpoints whose writer is a supervised daemon that would just respawn and
# reopen the mount within milliseconds (journald/rsyslog on /var/log). Killing
# holders there never lets a plain umount win -- it only SIGKILLs journald plus
# every other process with an fd on that filesystem, twice, before falling back
# to the lazy unmount anyway. For these, skip the kill and go straight to lazy.
#
# /var/cache/apt/archives, /var/lib/apt/lists and /var/lib/dpkg/updates get the
# same treatment for a sharper reason: 'unmount' is invoked directly from this
# package's own prerm during an apt/dpkg removal, and apt/dpkg always holds
# these paths open for the very transaction that is running. A plain umount
# failing EBUSY there and escalating to 'fuser -km' SIGKILLs apt/dpkg itself,
# aborting the transaction mid-purge (observed: fuser -km killed the PID of the
# 'apt-get purge' invocation that triggered this script in the first place).
LAZY_UNMOUNT_PATHS = {
    "/var/log",
    "/var/cache/apt/archives",
    "/var/lib/apt/lists",
    "/var/lib/dpkg/updates",
}

def get_log_file():
    log_file = DEFAULT_LOG_FILE
    if os.path.exists(DEFAULTS_FILE):
        try:
            with open(DEFAULTS_FILE, "r") as f:
                for line in f:
                    line = line.strip()
                    if not line or line.startswith("#"):
                        continue
                    if line.startswith("OMV_WRITECACHE_LOG="):
                        _, value = line.split("=", 1)
                        log_file = value.strip().strip('"').strip("'")
        except Exception:
            pass

    return log_file

LOG_FILE = get_log_file()

LOCK_FILE = None
ARRAY_LOCK = None
_LOCK_FD = None

NO_FLUSH = False
RESTART_SERVICES = False
DAILY_FLUSH = False
RECONFIGURE = False
LOG_ENABLED = True

# Lines held back while the overlay containing LOG_FILE is being flushed; see
# log_held_during().
_LOG_HELD = None

def _write_log_lines(lines):
    try:
        os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
        with open(LOG_FILE, "a") as f:
            f.writelines(lines)
    except Exception:
        pass

def log(msg: str, level: str = "INFO"):
    if not LOG_ENABLED:
        return
    ts = time.strftime("%Y-%m-%d %H:%M:%S")
    print(f"{ts} - {msg}")
    line = f"{ts} [{level}] {msg}\n"
    if _LOG_HELD is not None:
        _LOG_HELD.append(line)
    else:
        _write_log_lines([line])

@contextlib.contextmanager
def log_held_during(p: Path):
    """Hold log lines back while the overlay at p, which contains LOG_FILE, is
    flushed. Written while it is unmounted they would land in the file on disk,
    and the rsync of the upper's copy over it would then erase them."""
    global _LOG_HELD
    try:
        under = p in Path(LOG_FILE).resolve().parents
    except Exception:
        under = False
    if not under or _LOG_HELD is not None:
        yield
        return
    _LOG_HELD = []
    try:
        yield
    finally:
        lines, _LOG_HELD = _LOG_HELD, None
        _write_log_lines(lines)

# locking
def get_lock_filename(lockname):
    return f"/run/{lockname}.lock"

def check_and_create_lock(lockname):
    global LOCK_FILE
    global ARRAY_LOCK
    global _LOCK_FD
    LOCK_FILE = get_lock_filename(lockname)
    ARRAY_LOCK = lockname

    # Open (create if missing) and keep fd for the whole process
    _LOCK_FD = os.open(LOCK_FILE, os.O_RDWR | os.O_CREAT, 0o644)
    try:
        fcntl.flock(_LOCK_FD, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except BlockingIOError:
        # Someone else holds it; read PID for diagnostics if possible
        try:
            # Use pread to avoid seek races
            owner_pid = os.pread(_LOCK_FD, 32, 0).decode("ascii").strip() or "unknown"
        except Exception:
            owner_pid = "unknown"
        os.close(_LOCK_FD)
        _LOCK_FD = None
        log(f"Instance with lock name {lockname} and PID {owner_pid} is already running!", "WARN")
        sys.exit(1)

    # We own the lock now; write our PID
    try:
        os.ftruncate(_LOCK_FD, 0)
        os.lseek(_LOCK_FD, 0, os.SEEK_SET)
        os.write(_LOCK_FD, str(os.getpid()).encode("ascii"))
        os.fsync(_LOCK_FD)
    except Exception as e:
        log(f"Failed to write PID to lock file: {e}", "WARN")

    atexit.register(release_lock)  # Ensure lock is removed on normal exit
    log(f"Lock file {LOCK_FILE} created and locked.")

def release_lock():
    """Release lock; only unlink if the PID matches ours."""
    global _LOCK_FD
    if not LOCK_FILE:
        return
    try:
        if _LOCK_FD is not None:
            try:
                fcntl.flock(_LOCK_FD, fcntl.LOCK_UN)
            finally:
                os.close(_LOCK_FD)
                _LOCK_FD = None
        # Unlink only if we still own it
        try:
            with open(LOCK_FILE, "r") as f:
                existing_pid = (f.read() or "").strip()
            if existing_pid == str(os.getpid()):
                os.remove(LOCK_FILE)
                log(f"Lock file {LOCK_FILE} released.")
            elif existing_pid:
                log(f"Lock file {LOCK_FILE} not removed (owned by PID {existing_pid}).")
        except FileNotFoundError:
            pass
    except Exception as e:
        log(f"Lock release error: {e}", "WARN")

_DEFER_SIGNALS = 0
_PENDING_SIGNAL = None

def handle_signal(signum, frame):
    # Handle termination signals to release the lock properly
    global _PENDING_SIGNAL
    if _DEFER_SIGNALS:
        # Exiting now could leave a path unmounted with its pending writes hidden
        # in the upper; signals_deferred() exits once the path is consistent.
        _PENDING_SIGNAL = signum
        log(f"SIGNAL_DEFERRED: {signum} (finishing current path first)", "WARN")
        return
    print(f"Received signal {signum}, cleaning up...")
    sys.exit(1)

@contextlib.contextmanager
def signals_deferred():
    """Postpone SIGINT/SIGTERM handling until the wrapped block completes."""
    global _DEFER_SIGNALS
    _DEFER_SIGNALS += 1
    try:
        yield
    finally:
        _DEFER_SIGNALS -= 1
    if not _DEFER_SIGNALS and _PENDING_SIGNAL is not None:
        log(f"SIGNAL_EXIT: acting on deferred signal {_PENDING_SIGNAL}", "WARN")
        sys.exit(1)


def get_workspace_type(cfg: dict) -> str:
    t = str(cfg.get("workspace_type", "tmpfs")).strip().lower()
    return t if t in ("tmpfs", "path", "zram") else "tmpfs"

def get_workspace_root(cfg: dict) -> Path:
    p = str(cfg.get("workspace_root", str(DEFAULT_WORKSPACE_ROOT))).strip() or str(DEFAULT_WORKSPACE_ROOT)
    return Path(p)

def lower_bind_for(workspace_root: Path, name: str) -> Path:
    return workspace_root / name / "lower"

def _unescape_mount_field(s: str) -> str:
    # /proc/mounts octal-escapes space, tab, newline and backslash (e.g. '\040').
    return re.sub(r"\\([0-7]{3})", lambda m: chr(int(m.group(1), 8)), s)

def get_mounts() -> list[dict]:
    mounts = []
    try:
        with open("/proc/mounts") as f:
            for line in f:
                parts = line.split()
                if len(parts) >= 4:
                    mounts.append({"device": _unescape_mount_field(parts[0]),
                                   "path": _unescape_mount_field(parts[1]),
                                   "fstype": parts[2],
                                   "opts": parts[3]})
    except Exception as e:
        log(f"Failed to read /proc/mounts: {e}", "WARN")
    return mounts

def findmnt_info(path: Path) -> dict:
    """
    Return dict with SOURCE, FSTYPE, OPTIONS for the given path using findmnt.
    Best-effort: returns {} on failure.
    """
    try:
        res = subprocess.run(
            ["findmnt", "-no", "SOURCE,FSTYPE,OPTIONS", str(path)],
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            text=True,
        )
        if res.returncode != 0:
            return {}
        out = (res.stdout or "").strip()
        if not out:
            return {}
        # findmnt prints: "<source> <fstype> <options>"
        parts = out.split(None, 2)
        if len(parts) < 2:
            return {}
        return {"source": parts[0], "fstype": parts[1], "opts": parts[2] if len(parts) > 2 else ""}
    except Exception:
        return {}

def root_is_readonly() -> bool:
    try:
        res = subprocess.run(["findmnt", "-no", "OPTIONS", "/"],
                             stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
        return res.returncode == 0 and "ro" in (res.stdout or "").strip().split(",")
    except Exception:
        return False

def lower_bind_is_safe(lower: Path) -> bool:
    """
    A 'safe' lower bind is NOT an overlay mount and NOT sourced from 'overlay'.
    (We expect something like /dev/XYZ[/var/log] on Debian, or a real path.)
    """
    info = findmnt_info(lower)
    if not info:
        return False
    src = (info.get("source") or "").strip()
    fst = (info.get("fstype") or "").strip()
    if fst == "overlay":
        return False
    # Some systems show SOURCE literally as "overlay" for overlay mounts.
    if src == "overlay":
        return False
    return True

def is_mountpoint(path: Path) -> bool:
    mounts = get_mounts()
    return any(m["path"] == str(path) for m in mounts)

def fmt_bytes(n: int) -> str:
    # human-friendly (base-2)
    if n is None:
        return "-"
    units = ["B", "K", "M", "G", "T", "P", "E"]
    v = float(n)
    for u in units:
        if v < 1024.0 or u == units[-1]:
            if u == "B":
                return f"{int(v)}B"
            return f"{v:.1f}{u}"
        v /= 1024.0
    return f"{v:.1f}P"

def get_dir_size_bytes(path: Path) -> int:
    total = 0
    try:
        res = subprocess.run(["du", "-sb", str(path)], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
        if res.returncode == 0 and res.stdout:
            return int(res.stdout.split()[0])
    except Exception:
        pass

    # fallback walk
    try:
        for root, dirs, files in os.walk(path, topdown=True):
            for name in files:
                p = Path(root) / name
                try:
                    st = os.lstat(p)
                    total += st.st_size
                except FileNotFoundError:
                    continue
                except Exception:
                    continue
    except Exception:
        pass
    return total

def is_tmpfs_mounted_at(path: Path) -> bool:
    # ensure it's actually tmpfs mounted at path
    for m in get_mounts():
        if m["path"] == str(path) and m["fstype"] == "tmpfs":
            return True
    return False

def is_zram_mounted_at(path: Path) -> bool:
    # ensure a zram-backed filesystem is mounted at path
    for m in get_mounts():
        if m["path"] == str(path) and m["device"].startswith("/dev/zram"):
            return True
    return False

def device_for_mount(path: Path) -> str:
    for m in get_mounts():
        if m["path"] == str(path):
            return m["device"]
    return ""

ZRAM_CONTROL = "/sys/class/zram-control"
# zram devices whose reset failed, retried by reclaim_pending_zram(). In /run
# so it is cleared on reboot, along with the devices themselves.
ZRAM_PENDING_FILE = Path("/run/omv-writecache-zram-pending")

def _zram_backend_modules(algo: str) -> list:
    """Candidate kernel module names that provide a zram compression backend.

    Some backends (notably zstd) are modular and not loaded this early in boot,
    which makes a comp_algorithm write silently fall back to the built-in
    default (lzo-rle). lzo/lzo-rle are built-in and need no module.
    """
    return {
        "zstd":    ["zstd"],
        "lz4":     ["lz4", "lz4_compress"],
        "lz4hc":   ["lz4hc", "lz4hc_compress"],
        "lzo":     [],
        "lzo-rle": [],
        "842":     ["842", "842_compress"],
    }.get(algo, [])

def alloc_zram_device(size: str, algo: str) -> str:
    """Create and configure a fresh, dedicated zram device.

    Allocate via the zram-control 'hot_add' interface so we always get a
    brand-new device, rather than 'zramctl -f' which can collide with a zram
    device already claimed for swap (zram-tools / systemd-zram-generator). That
    collision is common on Proxmox kernels and races at early boot, where the
    swap device may be mid-setup and reported busy.

    Returns the device node path (e.g. /dev/zram1) or '' on failure.
    """
    run(["modprobe", "zram"], check=False)

    idx = None
    hot_add = os.path.join(ZRAM_CONTROL, "hot_add")
    if os.path.exists(hot_add):
        try:
            with open(hot_add) as f:
                idx = int(f.read().strip())
        except Exception as e:
            log(f"ZRAM_HOT_ADD_FAILED: {e}", "WARN")
            idx = None

    if idx is None:
        # Fallback for kernels without zram-control: let zramctl find/create one.
        r = run(["zramctl", "-f", "-a", algo, "-s", size], check=False)
        for tok in (r.stdout or "").split():
            if tok.startswith("/dev/zram"):
                return tok
        return ""

    dev = f"/dev/zram{idx}"
    # Best-effort: load the compression backend so the requested algorithm is
    # available this early in boot. Without this, zstd (modular) silently falls
    # back to the built-in lzo-rle default.
    for mod in _zram_backend_modules(algo):
        run(["modprobe", mod], check=False)
    # comp_algorithm must be set before disksize: once the device is sized it
    # is initialised and the algorithm becomes read-only.
    try:
        with open(f"/sys/block/zram{idx}/comp_algorithm", "w") as f:
            f.write(algo)
    except Exception as e:
        log(f"ZRAM_ALGO_WARN: {dev} algo={algo} - {e} (using kernel default)", "WARN")
    # Read back the active algorithm (marked in [brackets]) for transparency.
    try:
        avail = Path(f"/sys/block/zram{idx}/comp_algorithm").read_text()
        active = next((tok.strip("[]") for tok in avail.split() if tok.startswith("[")), algo)
        if active != algo:
            log(f"ZRAM_ALGO_FALLBACK: requested={algo} active={active}", "WARN")
        else:
            log(f"ZRAM_ALGO_OK: {active}", "DEBUG")
    except Exception:
        pass
    try:
        with open(f"/sys/block/zram{idx}/disksize", "w") as f:
            f.write(size)
    except Exception as e:
        log(f"ZRAM_DISKSIZE_FAILED: {dev} size={size} - {e}", "ERROR")
        free_zram_device(dev)
        return ""
    # Let udev finish probing the new device before we mkfs/mount it, so an
    # early-boot udev open doesn't make those operations race (EBUSY).
    run(["udevadm", "settle", "--timeout=10"], check=False)
    log(f"ZRAM_ALLOC: {dev} size={size} algo={algo}")
    return dev

def _zram_busy(dev: str) -> bool:
    """True while something still holds the device, typically the filesystem of
    a workspace that was lazily detached while overlays on it were busy. An
    exclusive open fails with EBUSY for as long as that filesystem exists."""
    try:
        os.close(os.open(dev, os.O_RDONLY | os.O_EXCL))
        return False
    except OSError as e:
        return e.errno == errno.EBUSY

def _read_zram_pending() -> list:
    try:
        return [l.strip() for l in ZRAM_PENDING_FILE.read_text().splitlines() if l.strip()]
    except FileNotFoundError:
        return []
    except Exception as e:
        log(f"ZRAM_PENDING_READ_FAILED: {ZRAM_PENDING_FILE} - {e}", "WARN")
        return []

def _write_zram_pending(devs: list) -> None:
    try:
        if devs:
            ZRAM_PENDING_FILE.write_text("".join(f"{d}\n" for d in devs))
        else:
            ZRAM_PENDING_FILE.unlink(missing_ok=True)
    except Exception as e:
        log(f"ZRAM_PENDING_WRITE_FAILED: {ZRAM_PENDING_FILE} - {e}", "WARN")

def free_zram_device(dev: str) -> bool:
    """Reset and hot-remove a zram device so devices don't accumulate.

    A device still held (see _zram_busy) can't be reset. It is recorded in
    ZRAM_PENDING_FILE so reclaim_pending_zram() frees it on a later run, once
    the holders have closed their files, instead of keeping its RAM until
    reboot. Returns True once the device is reset.
    """
    if not dev.startswith("/dev/zram"):
        return False
    try:
        idx = int(dev.replace("/dev/zram", ""))
    except ValueError:
        return False
    pending = _read_zram_pending()
    if _zram_busy(dev):
        log(f"ZRAM_BUSY: {dev} is still held (lazily detached workspace); "
            "will retry the reset on a later run", "WARN")
        if dev not in pending:
            _write_zram_pending(pending + [dev])
        return False
    # Reset frees the compressed pages held in RAM.
    if run(["zramctl", "-r", dev], check=False).returncode != 0:
        if dev not in pending:
            _write_zram_pending(pending + [dev])
        return False
    if dev in pending:
        _write_zram_pending([d for d in pending if d != dev])
    # Hot-remove returns the device to the kernel. zramctl from util-linux
    # 2.39+ already did that as part of the reset.
    if not Path(f"/sys/block/zram{idx}").exists():
        log(f"ZRAM_HOT_REMOVE: {dev}")
        return True
    hot_remove = os.path.join(ZRAM_CONTROL, "hot_remove")
    if os.path.exists(hot_remove):
        try:
            with open(hot_remove, "w") as f:
                f.write(str(idx))
            log(f"ZRAM_HOT_REMOVE: {dev}")
        except Exception as e:
            log(f"ZRAM_HOT_REMOVE_FAILED: {dev} - {e}", "WARN")
    return True

def reclaim_pending_zram() -> None:
    """Free zram devices whose reset failed on an earlier run."""
    for dev in _read_zram_pending():
        if not Path(f"/sys/block/{Path(dev).name}").exists():
            log(f"ZRAM_PENDING_GONE: {dev}", "DEBUG")
            _write_zram_pending([d for d in _read_zram_pending() if d != dev])
        elif _zram_busy(dev):
            log(f"ZRAM_PENDING_BUSY: {dev} still held", "DEBUG")
        else:
            log(f"ZRAM_RECLAIM: {dev} no longer held; freeing it")
            free_zram_device(dev)

def list_zram_algorithms() -> list:
    """Return the zram compression algorithms supported by the running kernel.

    Modular backends (notably zstd) are loaded first so they appear in the
    advertised list even right after boot. The list is read from a zram
    device's comp_algorithm attribute, creating and removing a throwaway
    device if none currently exists.
    """
    run(["modprobe", "zram"], check=False)
    for a in ("zstd", "lz4", "lz4hc", "842"):
        for mod in _zram_backend_modules(a):
            run(["modprobe", mod], check=False)

    idx = None
    temp = False
    try:
        existing = sorted(Path("/sys/block").glob("zram*"),
                          key=lambda p: int(p.name.replace("zram", "")))
        if existing:
            idx = int(existing[0].name.replace("zram", ""))
    except Exception:
        idx = None

    if idx is None:
        hot_add = os.path.join(ZRAM_CONTROL, "hot_add")
        if os.path.exists(hot_add):
            try:
                with open(hot_add) as f:
                    idx = int(f.read().strip())
                temp = True
            except Exception:
                idx = None

    algos = []
    if idx is not None:
        try:
            raw = Path(f"/sys/block/zram{idx}/comp_algorithm").read_text()
            algos = [tok.strip("[]") for tok in raw.split() if tok.strip()]
        except Exception:
            algos = []
        if temp:
            free_zram_device(f"/dev/zram{idx}")

    # Dedupe, preserve kernel order.
    seen = set(); out = []
    for a in algos:
        if a and a not in seen:
            out.append(a); seen.add(a)
    return out

def drop_pending_changes(cfg: dict):
    """
    For policy=drop targets, discard any pending upper/work content in a persistent workspace.
    This is required for workspace_type=path, otherwise 'drop' changes survive reboot.
    """
    ws_root = get_workspace_root(cfg)
    items = list(parse_paths(cfg.get("paths", "")))

    dropped = 0
    for entry in items:
        path, mode = entry.path, entry.mode
        if mode != "drop":
            continue
        p = Path(path).resolve()
        name = sanitize_name(str(p))
        upper = ws_root / name / "upper"
        work  = ws_root / name / "work"

        # Nuke pending changes
        try:
            if upper.exists():
                shutil.rmtree(upper, ignore_errors=True)
            upper.mkdir(parents=True, exist_ok=True)
            sync_upper_attrs(upper, p, entry.uid, entry.gid, entry.perms)

            if work.exists():
                shutil.rmtree(work, ignore_errors=True)
            work.mkdir(parents=True, exist_ok=True)

            log(f"DROP_CLEARED: {p} upper={upper}")
            dropped += 1
        except Exception as e:
            log(f"DROP_CLEAR_FAILED: {p} - {e}", "ERROR")

    log(f"DROP_CLEAR_COMPLETE: cleared={dropped}", "INFO")

def ensure_workspace(cfg: dict) -> Path:
    """Ensure workspace root exists and (when configured) is mounted."""
    ws_type = get_workspace_type(cfg)
    ws_root = get_workspace_root(cfg)

    if ws_type == "tmpfs":
        ws_root.mkdir(parents=True, exist_ok=True)
        if is_tmpfs_mounted_at(ws_root):
            log(f"WORKSPACE_EXISTS: {ws_root}")
            return ws_root
        size = cfg.get("tmpfs_size", "25%")
        log(f"WORKSPACE_MOUNT: tmpfs {ws_root} size={size}")
        run(["mount", "-t", "tmpfs", "-o", f"size={size},mode=0755", "tmpfs", str(ws_root)])
        return ws_root

    if ws_type == "zram":
        ws_root.mkdir(parents=True, exist_ok=True)
        if is_zram_mounted_at(ws_root):
            log(f"WORKSPACE_EXISTS: {ws_root} (zram)")
            return ws_root
        size = cfg.get("zram_size", "512M")
        algo = cfg.get("zram_algo", "zstd")
        log(f"WORKSPACE_MOUNT: zram {ws_root} size={size} algo={algo}")

        # Allocate a fresh, dedicated device via zram-control 'hot_add'. This
        # never reuses zram0, so it can't collide with a zram device already
        # claimed for swap (zram-tools / rpi-swap / systemd-zram-generator),
        # and it loads the requested compression backend first so a modular
        # algo (e.g. zstd) doesn't silently fall back to the kernel default.
        dev = alloc_zram_device(size, algo)
        if not dev:
            log("ZRAM_ALLOC_FAILED: could not allocate a zram device", "ERROR")
            raise SystemExit(1)

        run(["mkfs.ext4", "-F", "-q", "-m", "0", "-O", "^has_journal", dev], check=False)
        if run(["mount", "-o", "noatime,nosuid,nodev,discard", dev, str(ws_root)], check=False).returncode != 0:
            log(f"ZRAM_MOUNT_FAILED: {dev} -> {ws_root}", "ERROR")
            free_zram_device(dev)
            raise SystemExit(1)
        try:
            os.chmod(str(ws_root), 0o755)
        except Exception as e:
            log(f"ZRAM_CHMOD_WARN: {ws_root} - {e}", "WARN")
        log(f"ZRAM_READY: dev={dev} mounted at {ws_root}")
        return ws_root

    # ws_type == "path"
    ws_root.mkdir(parents=True, exist_ok=True)
    try:
        testfile = ws_root / ".writecache_test"
        testfile.write_text("ok")
        testfile.unlink(missing_ok=True)
    except Exception as e:
        log(f"WORKSPACE_NOT_WRITABLE: {ws_root}: {e}", "ERROR")
        raise SystemExit(1)
    return ws_root

def is_workspace_ready(cfg: dict) -> bool:
    ws_root = get_workspace_root(cfg)
    ws_type = get_workspace_type(cfg)
    if ws_type == "tmpfs":
        return is_tmpfs_mounted_at(ws_root)
    if ws_type == "zram":
        return is_zram_mounted_at(ws_root)
    # path
    return ws_root.exists() and os.access(str(ws_root), os.W_OK)

def wait_for_unmount(path: Path, timeout: int = 10) -> bool:
    """Wait for a path to be unmounted, with timeout"""
    for _ in range(timeout * 2):
        if not is_mountpoint(path) and not is_overlay_mounted(path):
            return True
        time.sleep(0.5)
    return False

def force_unmount_with_retry(path: Path, max_retries: int = 3, allow_kill: bool = True) -> bool:
    """Unmount 'path', escalating only as far as necessary.

    Ordering matters for data integrity. 'umount -l' detaches the mount from the
    namespace immediately and it disappears from /proc/mounts right away, so it
    looks like a success -- but the mount stays alive for every process that still
    holds an open fd into it. Those processes keep writing into the detached
    overlay's upper dir, which the caller is about to rsync and then delete, so
    their writes are silently lost.

    A plain umount fails cleanly with EBUSY instead, which is the honest answer.
    It succeeds outright for idle targets (/var/tmp, /var/cache/apt/archives), so
    those get a clean unmount with no loss window at all.

    Lazy unmount is kept as the final fallback for teardown and reconfigure,
    because busy targets like /var/log (nginx, rsyslog, samba... hold their
    logs open) cannot be unmounted any other way. A routine flush never gets
    here for a busy path: it uses unmount_if_idle() and syncs in place instead
    (see _flush_in_place). It is logged as a warning naming the fix: give that
    path a 'stopstart' entry in the services list so the writer releases its
    fds first.

    Paths in LAZY_UNMOUNT_PATHS skip the kill escalation entirely: their writer
    is a supervised daemon that respawns and reopens the mount before the retry
    umount runs, so the kill can never succeed and only harms bystanders. One
    honest plain umount is still attempted (it wins if the path happens to be
    idle), then it goes straight to the lazy fallback.

    allow_kill=False disables the SIGKILL escalation for this call regardless of
    the path. The flush of a 'flush'-policy directory passes this: SIGKILLing the
    daemon that owns the data (rrdcached, a database) to win an unmount risks a
    torn on-disk state and leaves a stale pidfile that blocks the restart -- far
    worse than falling through to the lazy detach, which at least keeps the
    writer alive. The real fix for a busy flush target is a 'stopstart' services
    entry so the writer releases its fds before the flush.
    """
    skip_kill = (not allow_kill) or (str(path).rstrip("/") in LAZY_UNMOUNT_PATHS)
    for attempt in range(max_retries):
        if not is_mountpoint(path) and not is_overlay_mounted(path):
            return True

        # Escalate to killing holders only after a plain unmount has already
        # failed once, so a momentarily busy mount doesn't take journald with it.
        if attempt > 0 and not skip_kill:
            log(f"FORCE_UNMOUNT_RETRY {attempt}: Killing processes using {path}")
            run(["fuser", "-km", str(path)], check=False)
            time.sleep(0.5)

        if run(["umount", str(path)], check=False).returncode == 0:
            if wait_for_unmount(path, 5):
                return True

    if not is_mountpoint(path) and not is_overlay_mounted(path):
        return True

    log(f"LAZY_UNMOUNT: {path} still busy after {max_retries} attempts; detaching. "
        f"Writes from processes still holding it may be lost -- consider a "
        f"'stopstart' services entry for whatever writes to {path}.", "WARN")
    if run(["umount", "-l", str(path)], check=False).returncode == 0:
        return wait_for_unmount(path, 5)

    return False

def unmount_if_idle(path: Path, attempts: int = 3, delay: float = 1.0) -> bool:
    """Plain umount only, retried briefly; never kills, never detaches lazily.

    The delay gives a writer that was just asked to let go (journald after
    --relinquish-var) time to close its files. Returns False if 'path' is
    still busy, leaving the overlay mounted and untouched.
    """
    for attempt in range(attempts):
        if attempt:
            time.sleep(delay)
        if not is_mountpoint(path) and not is_overlay_mounted(path):
            return True
        if run(["umount", str(path)], check=False, fail_level="DEBUG").returncode == 0:
            if wait_for_unmount(path, 5):
                return True
    return not is_mountpoint(path) and not is_overlay_mounted(path)

def mounts_under(prefix: Path) -> list[Path]:
    """Mountpoints strictly below 'prefix' (never 'prefix' itself).

    The workspace root is excluded on purpose: a RAM workspace is torn down
    separately (after its zram device is captured), and a 'path' workspace root
    may be a real data disk that must never be unmounted.
    """
    prefix_str = str(prefix).rstrip("/") + "/"
    pts = []
    for m in get_mounts():
        mp = m["path"]
        if mp.startswith(prefix_str):
            pts.append(Path(mp))
    # deepest-first so children go away before parents
    return sorted(pts, key=lambda p: len(str(p)), reverse=True)

def unmount_all_under_workspace(workspace_root: Path, allow_kill: bool = True):
    for mp in mounts_under(workspace_root):
        log(f"WORKSPACE_UNMOUNT: {mp}")
        force_unmount_with_retry(mp, allow_kill=allow_kill)

def remount_lower(lower: Path, rw: bool, check: bool = False) -> bool:
    """
    IMPORTANT:
      lower is a bind mount (mount --bind real_path -> lower).
      Never do: mount -o remount,ro|rw <lower>
        That can remount the *entire underlying filesystem* (e.g. /dev/vda1).
      Always do: mount -o remount,bind,ro|rw <lower>
        That only changes mountpoint flags.
    """
    if not is_mountpoint(lower):
        log(f"REMOUNT_SKIPPED: not a mountpoint: {lower}", "ERROR")
        if check:
            raise SystemExit(32)
        return False

    mode = "rw" if rw else "ro"

    # bind-only remount: modifies mount flags for the bind mount, not the superblock
    res = run(["mount", "-o", f"remount,bind,{mode}", str(lower)], check=False)

    if res.returncode != 0:
        log(f"REMOUNT_FAILED: {lower} mode={mode} exit={res.returncode}", "ERROR")
        run(["findmnt", "-no", "TARGET,SOURCE,FSTYPE,OPTIONS", str(lower)], check=False)
        if check:
            raise SystemExit(res.returncode)
        return False

    return True

def ensure_lower_bind(workspace_root: Path, real_path: Path, name: str) -> Path:
    lower = lower_bind_for(workspace_root, name)
    lower.mkdir(parents=True, exist_ok=True)

    if not is_mountpoint(lower):
        res = run(["mount", "--bind", str(real_path), str(lower)], check=False)
        if res.returncode != 0 or not is_mountpoint(lower):
            log(f"LOWER_BIND_FAILED: {real_path} -> {lower}", "ERROR")
            raise SystemExit(32)
        run(["mount", "--make-private", str(lower)], check=False)

    remount_lower(lower, rw=False, check=False)
    return lower

def run(cmd, check=True, quiet=False, fail_level="ERROR"):
    """fail_level lowers the log level of a failure the caller expects and
    handles (e.g. EBUSY from a plain umount probe)."""
    cmd_str = ' '.join(cmd)
    log(f"CMD: {cmd_str}", "DEBUG")
    res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd="/")
    if res.returncode != 0:
        log(f"CMD_FAILED: {cmd_str} (exit {res.returncode})", fail_level)
        if res.stdout.strip() and not quiet:
            log(f"OUTPUT: {res.stdout.strip()}", fail_level)
        if check:
            raise SystemExit(res.returncode)
    else:
        log(f"CMD_OK: {cmd_str}", "DEBUG")
        if res.stdout.strip() and not quiet:
            log(f"OUTPUT: {res.stdout.strip()}", "DEBUG")
    return res

def ensure_persistent_journal():
    conf_path = "/etc/systemd/journald.conf.d/50-writecache.conf"
    journal_dir = "/var/log/journal"

    # Check if Storage=persistent is set
    if os.path.exists(conf_path):
        with open(conf_path, "r") as f:
            for line in f:
                line = line.strip()
                if line.lower().startswith("storage=") and line.split("=", 1)[1].strip().lower() == "persistent":
                    # Ensure directory exists
                    if not os.path.isdir(journal_dir):
                        log(f"JRNDIR: {journal_dir} not found. Creating", "DEBUG")
                        os.makedirs(journal_dir, exist_ok=True)
                        # Set owner to root:systemd-journal and permissions to 2755
                        try:
                            gid = grp.getgrnam("systemd-journal").gr_gid
                        except KeyError:
                            gid = 0  # fallback to root if group missing
                        os.chown(journal_dir, 0, gid)
                        os.chmod(journal_dir, 0o2755)
                        # ensure machine id subdirectory exists
                        try:
                            mid = Path("/etc/machine-id").read_text().strip()
                        except FileNotFoundError:
                            mid = ""
                        if mid:
                            mid_dir = os.path.join(journal_dir, mid)
                            if not os.path.isdir(mid_dir):
                                log(f"MIDDIR: {mid_dir} not found. Creating", "DEBUG")
                                os.makedirs(mid_dir, exist_ok=True)
                            os.chown(mid_dir, 0, gid)
                            os.chmod(mid_dir, 0o2755)
                    break

def journalctl_rotate_sync():
    log("ROTATE_START: Beginning journalctl rotate and sync operation")
    try:
        run(["journalctl", "--rotate"], check=False)
        run(["journalctl", "--sync"], check=False)
    except Exception as e:
        log(f"journalctl rotate/sync failed: {e}", "WARN")

JOURNAL_DIR = Path("/var/log/journal")

def holds_journal(p: Path) -> bool:
    """True if the overlay at p contains journald's persistent journal."""
    return p == JOURNAL_DIR or p in JOURNAL_DIR.parents

def journal_relinquish():
    """Have journald close its files under /var/log and write to /run instead.

    journald keeps the active journal open, so /var/log could otherwise only be
    lazily detached, leaving journald writing into a detached overlay whose
    upper is then deleted. A no-op when the journal is not persistent.
    """
    log("JOURNAL_RELINQUISH: moving journald to /run/log/journal")
    run(["journalctl", "--relinquish-var"], check=False)

def journal_reclaim():
    """Move journald back to /var/log/journal, with what it wrote meanwhile."""
    log("JOURNAL_RECLAIM: moving journald back to /var/log/journal")
    run(["journalctl", "--flush"], check=False)

def load_config():
    defaults = {
        "enable": True,
        "workspace_type": "tmpfs",  # tmpfs | path
        "workspace_root": str(DEFAULT_WORKSPACE_ROOT),
        "tmpfs_size": "25%",
        "zram_size": "512M",
        "zram_algo": "zstd",
        "flush_on_boot": False,
        "journald_storage": "volatile",
        "flush_on_shutdown": True,
        "flush_daily": False,
        "paths": "/var/log = drop\n/var/tmp = drop\n/var/cache/apt/archives = drop\n/var/lib/apt/lists = drop\n/var/lib/dpkg/updates = flush\n",
        "services": "monit = stopdailystart\nrrdcached = stopstart\n",
    }
    if not CONFIG.exists() or yaml is None:
        log("CONFIG: Using defaults", "DEBUG")
        return defaults
    try:
        with open(CONFIG) as f:
            data = yaml.safe_load(f) or {}
        defaults.update(data)
        log(f"CONFIG: Loaded from {CONFIG}", "DEBUG")
    except Exception as e:
        log(f"CONFIG_ERROR: {e}", "ERROR")
    return defaults

def parse_owner_mode(attr_str):
    """Parse 'owner=user:group mode=0755' tokens into (uid, gid, perms).

    Each value is optional; returns None for any attribute not specified.
    owner= accepts 'user', 'user:group', or ':group'.
    mode= accepts an octal string such as '0755' or '755'.
    """
    uid = gid = perms = None
    for token in attr_str.split():
        if token.startswith("owner="):
            val = token[6:]
            user, sep, group = val.partition(":")
            if user:
                try:
                    uid = pwd.getpwnam(user).pw_uid
                except KeyError:
                    try:
                        uid = int(user)
                    except ValueError:
                        log(f"PARSE_OWNER_WARN: unknown user '{user}'", "WARN")
            if sep and group:
                try:
                    gid = grp.getgrnam(group).gr_gid
                except KeyError:
                    try:
                        gid = int(group)
                    except ValueError:
                        log(f"PARSE_OWNER_WARN: unknown group '{group}'", "WARN")
        elif token.startswith("mode="):
            val = token[5:]
            try:
                perms = int(val, 8)
            except ValueError:
                log(f"PARSE_MODE_WARN: invalid mode '{val}' (expected octal)", "WARN")
    return uid, gid, perms


# Policies that write the cached changes back to disk.
FLUSH_MODES = ("flush", "persist", "writeback")
VALID_MODES = ("drop",) + FLUSH_MODES

def parse_paths(paths_text):
    """Parse the paths config block into a list of PathEntry namedtuples.

    Each non-comment line has the form:
        /some/path = mode [owner=user:group] [mode=0755]

    The optional owner= and mode= attributes allow enforcing specific ownership
    and permissions on the managed directory root after every flush, overriding
    the default behaviour of restoring the pre-rsync attributes.
    """
    items = []
    for line in paths_text.splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        tokens = line.split()
        if "=" in tokens:
            # Format: /some/path = mode [owner=user:group] [mode=0755]
            # The standalone '=' token is the policy separator; everything
            # before it is the path (preserving any embedded spaces).
            eq_idx = tokens.index("=")
            p = " ".join(tokens[:eq_idx])
            rest = tokens[eq_idx + 1:]
            mode = rest[0].lower() if rest else "drop"
            uid, gid, perms = parse_owner_mode(" ".join(rest[1:]))
            if mode not in VALID_MODES:
                # Never guess: treating a typo'd 'flush' as drop would silently
                # discard its writes. Leave the path uncached instead, so writes
                # go straight to disk until the entry is corrected.
                log(f"PARSE_PATH_ERROR: unknown policy '{rest[0]}' for '{p}' "
                    f"(expected {', '.join(VALID_MODES)}); path not cached", "ERROR")
                continue
        else:
            # No policy separator — strip any trailing owner=/mode= tokens
            # (identifiable by containing '=') from the right so that paths
            # containing spaces are preserved intact.
            attr_tokens = []
            while tokens and "=" in tokens[-1] and not tokens[-1].startswith("/"):
                attr_tokens.insert(0, tokens.pop())
            p = " ".join(tokens)
            mode = "drop"
            uid, gid, perms = parse_owner_mode(" ".join(attr_tokens))
        items.append(PathEntry(p, mode, uid, gid, perms))
    # dedupe preserving order
    seen = set(); res = []
    for entry in items:
        if entry.path not in seen:
            res.append(entry)
            seen.add(entry.path)
    return res

def parse_services(services_text):
    """Parse the services config block into a list of ServiceEntry namedtuples.

    Each non-comment line has the form:
        service_name = reload|restart|stopstart|reloaddaily|restartdaily|stopdailystart
    The *daily variants only run when --daily is passed (i.e. on a daily flush).
    """
    items = []
    seen = set()
    for line in services_text.splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        if "=" in line:
            name, _, action = line.partition("=")
            name = name.strip()
            action = action.strip().lower()
            if action not in ("reload", "restart", "stopstart", "reloaddaily", "restartdaily", "stopdailystart"):
                log(f"PARSE_SERVICE_WARN: unknown action '{action}' for '{name}' (expected reload, restart, stopstart, reloaddaily, restartdaily, or stopdailystart)", "WARN")
                continue
        else:
            name = line.strip()
            action = "restart"
        if name and name not in seen:
            items.append(ServiceEntry(name, action))
            seen.add(name)
    return items

def _run_systemctl(action, name):
    """Run systemctl <action> <name>, log result. Returns True on success."""
    label = f"SERVICE_{action.upper()}"
    try:
        log(f"{label}: {name}")
        result = subprocess.run(
            ["systemctl", action, name],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
        )
        if result.returncode != 0:
            log(f"{label}_FAILED: {name} - {result.stdout.strip()}", "ERROR")
            return False
        log(f"{label}_OK: {name}")
        return True
    except Exception as e:
        log(f"{label}_ERROR: {name} - {e}", "ERROR")
        return False

def _unit_is_active(name):
    """True if the systemd unit is currently active (running)."""
    try:
        return subprocess.run(
            ["systemctl", "is-active", "--quiet", "--", name],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        ).returncode == 0
    except Exception:
        return False

def stop_services(cfg):
    """Stop services with action=stopstart/stopdailystart before a flush.

    Only services that are currently running are stopped, and the set of names
    actually taken down is returned so restart_services() starts back exactly
    those. A service the administrator deliberately left disabled (e.g.
    rrdcached on a system with OMV monitoring turned off) is never touched.
    """
    stopped = set()
    for entry in parse_services(cfg.get("services", "") or ""):
        if entry.action == "stopstart" or (entry.action == "stopdailystart" and DAILY_FLUSH):
            if not _unit_is_active(entry.name):
                log(f"SERVICE_STOP_SKIP: {entry.name} (not active)", "DEBUG")
                continue
            if _run_systemctl("stop", entry.name):
                stopped.add(entry.name)
    return stopped

def restart_services(cfg, stopped=None):
    """Reload, restart, or start services after a non-shutdown flush.

    Actions ending in 'daily' only run when DAILY_FLUSH is set. A stopstart /
    stopdailystart service is started again only if this run actually stopped it
    (tracked in 'stopped'). reload / restart actions are skipped when the unit
    is not active, so a flush never starts a service that was not already
    running.

    Stopped units are started back in reverse of the listed order: stop_services
    takes them down top-to-bottom, so a watchdog such as monit -- listed first
    and stopped first -- comes back up last, after the services it monitors, and
    does not briefly see them as down.
    """
    stopped = stopped or set()
    entries = parse_services(cfg.get("services", "") or "")

    for entry in entries:
        if entry.action in ("reload", "restart", "reloaddaily", "restartdaily"):
            if entry.action.endswith("daily") and not DAILY_FLUSH:
                continue
            action = "reload" if entry.action.startswith("reload") else "restart"
            if not _unit_is_active(entry.name):
                log(f"SERVICE_{action.upper()}_SKIP: {entry.name} (not active)", "DEBUG")
                continue
            _run_systemctl(action, entry.name)

    for entry in reversed(entries):
        if entry.action in ("stopstart", "stopdailystart") and entry.name in stopped:
            _run_systemctl("start", entry.name)

# Real paths under which systemd creates 'systemd-private-<boot>-<unit>-<rnd>'
# backing directories for units with PrivateTmp=yes.
PRIVATE_TMP_PATHS = {"/tmp", "/var/tmp"}

def find_active_private_tmp_units():
    """Return running service units with PrivateTmp=yes.

    Each such unit keeps its private /tmp and /var/tmp bind-mounted from a
    systemd-private-* directory that lives under the real /tmp and /var/tmp.
    A remount that recreates the overlay backing one of those paths (e.g. a
    'drop' policy wiping the upperdir) makes that directory vanish while the
    unit's mount namespace still references it: the unit keeps running until
    its next reload/start, which sets up a fresh private namespace and fails
    with 226/NAMESPACE because the source directory is gone. The remount
    command uses this to restart any such unit the admin has opted into via
    the Services list, and to warn about the rest -- we never touch a unit
    the admin hasn't explicitly listed.
    """
    try:
        result = subprocess.run(
            ["systemctl", "list-units", "--type=service", "--state=running",
             "--no-legend", "--plain", "--no-pager"],
            stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
        )
    except Exception as e:
        log(f"PRIVATE_TMP_SCAN_ERROR: {e}", "WARN")
        return []

    units = [line.split()[0] for line in result.stdout.splitlines() if line.strip()]
    stale = []
    for unit in units:
        try:
            r = subprocess.run(
                ["systemctl", "show", unit, "--property=PrivateTmp", "--value"],
                stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
            )
            if r.stdout.strip() == "yes":
                stale.append(unit)
        except Exception:
            continue
    return stale

def sanitize_name(p):
    return re.sub(r"[^A-Za-z0-9._-]+", "_", p.strip("/"))

def sync_upper_attrs(upper: Path, target: Path,
                     enforce_uid=None, enforce_gid=None, enforce_mode=None):
    """
    Set the upper directory's own mode/uid/gid so that overlayfs exposes the
    correct permissions on the overlay root inode.

    overlayfs derives the root directory attributes from the upper directory
    itself, so a freshly-created upper (root:root 0755) would make the mounted
    path appear as root:root 0755 regardless of the real directory's settings.

    Priority:
      1. enforce_* values from the path-policy entry (owner=/mode= attributes)
      2. The real target path's current stat
    Only applied when the upper directory is empty (no pending overlay data)
    so that intentional permission changes captured in an existing upper layer
    are not overwritten.
    """
    try:
        if any(upper.iterdir()):
            return  # upper has overlay content; don't clobber its attrs
        if enforce_uid is not None or enforce_gid is not None or enforce_mode is not None:
            t_stat = target.stat()
            uid  = enforce_uid  if enforce_uid  is not None else t_stat.st_uid
            gid  = enforce_gid  if enforce_gid  is not None else t_stat.st_gid
            mode = enforce_mode if enforce_mode is not None else stat.S_IMODE(t_stat.st_mode)
        else:
            t_stat = target.stat()
            uid  = t_stat.st_uid
            gid  = t_stat.st_gid
            mode = stat.S_IMODE(t_stat.st_mode)
        os.chmod(upper, mode)
        os.chown(upper, uid, gid)
        log(f"UPPER_ATTR_SYNC: {upper} uid={uid} gid={gid} mode={oct(mode)}", "DEBUG")
    except Exception as e:
        log(f"UPPER_ATTR_SYNC_WARN: {upper} - {e}", "WARN")

def is_overlay_mounted(path: Path) -> bool:
    mounts = get_mounts()
    return any(m["path"] == str(path) and m["fstype"] == "overlay" for m in mounts)

def live_overlay_dirs(path: Path):
    """Return {'lower','upper','work'} for the overlay currently mounted at 'path'.

    The values are parsed from the live mount options, so they reflect where the
    overlay was ACTUALLY mounted regardless of any later workspace_root config
    change. Returns None when no single-lower overlay is mounted at 'path'.
    """
    target = str(path)
    for m in get_mounts():
        if m["path"] != target or m["fstype"] != "overlay":
            continue
        d = _overlay_dirs(m)
        if d:
            return d
    return None

def _overlay_dirs(m: dict):
    """Parse lower/upper/work from an overlay mount entry, or None."""
    d = {}
    for kv in m["opts"].split(","):
        kv = _unescape_mount_field(kv)
        if kv.startswith("lowerdir="):
            d["lower"] = kv[len("lowerdir="):]
        elif kv.startswith("upperdir="):
            d["upper"] = kv[len("upperdir="):]
        elif kv.startswith("workdir="):
            d["work"] = kv[len("workdir="):]
    # Only handle the single-lower layout this tool creates; a colon-separated
    # lowerdir is not something we manage, so fall back to config-derived paths.
    if all(k in d for k in ("lower", "upper", "work")) and ":" not in d["lower"]:
        return d
    return None

POLICY_FILE = "policy"

def write_policy_marker(ws_root: Path, name: str, mode: str):
    """Record the policy an overlay was mounted with, next to its upper/work.

    If the path is later removed from the config while its overlay is live, this
    is the only record of whether its pending writes must be flushed or dropped.
    """
    try:
        (ws_root / name / POLICY_FILE).write_text(mode + "\n")
    except Exception as e:
        log(f"POLICY_MARKER_WARN: {ws_root / name} - {e}", "WARN")

def orphan_entries(cfg: dict) -> list:
    """Live overlays created by this tool whose path is no longer in the config.

    A path removed from the Paths list keeps its overlay mounted until something
    tears it down, but flush()/unmount() only iterate the configured paths. They
    use these entries too, so a removed 'flush' path is written back and every
    removed path is unmounted instead of pinning a lazily-detached workspace.

    Ours are recognised by the <dir>/{lower,upper,work} layout plus either <dir>
    being under the configured workspace or carrying a policy marker (a workspace
    root changed since mounting). The policy comes from that marker; overlays
    mounted before markers existed get 'unknown' and are never flushed.
    """
    ws_root = get_workspace_root(cfg)
    configured = {str(Path(e.path).resolve()) for e in parse_paths(cfg.get("paths", "") or "")}

    # A recursive bind of a tree containing our overlays (e.g. a backup tool
    # rbind-mounting / under /tmp) replicates them: the copies show the same
    # upperdir. Such a copy is NOT an orphan -- flushing it would clear and
    # recreate the upper the live overlay is still using, sending every later
    # write into a deleted directory. Group mounts by upperdir to spot them.
    by_upper = {}
    for m in get_mounts():
        if m["fstype"] == "overlay":
            d = _overlay_dirs(m)
            if d:
                by_upper.setdefault(d["upper"], []).append(m["path"])
    configured_uppers = {u for u, mps in by_upper.items() if any(mp in configured for mp in mps)}

    res = []
    seen = set()
    warned = set()
    for m in get_mounts():
        if m["fstype"] != "overlay" or m["path"] in configured or m["path"] in seen:
            continue
        d = _overlay_dirs(m)
        if not d:
            continue
        if d["upper"] in configured_uppers:
            log(f"OVERLAY_COPY: {m['path']} is a bind copy of a configured overlay; ignored", "DEBUG")
            continue
        if len(by_upper[d["upper"]]) > 1:
            # Several mountpoints of one removed overlay: acting on one would
            # pull the upper out from under the others. Leave them all alone.
            if d["upper"] not in warned:
                log(f"ORPHAN_OVERLAY_SHARED: {', '.join(by_upper[d['upper']])} share upper "
                    f"{d['upper']}; not touching them -- unmount the copies first", "WARN")
                warned.add(d["upper"])
            continue
        base = Path(d["upper"]).parent
        if (Path(d["upper"]).name != "upper" or Path(d["lower"]) != base / "lower"
                or Path(d["work"]) != base / "work"):
            continue
        marker = base / POLICY_FILE
        if not (base.parent == ws_root or marker.exists()):
            continue
        try:
            mode = marker.read_text().strip() or "unknown"
        except Exception:
            mode = "unknown"
        log(f"ORPHAN_OVERLAY: {m['path']} (no longer configured) policy={mode}", "WARN")
        res.append(PathEntry(m["path"], mode, None, None, None))
        seen.add(m["path"])
    return res

def is_overlayfs_whiteout(p: Path) -> bool:
    """
    overlayfs can represent whiteouts as character devices with rdev 0,0.
    We treat those as valid deletion markers and MUST NOT scrub them.
    """
    try:
        st = os.lstat(p)
    except FileNotFoundError:
        return False
    except Exception:
        return False

    if not stat.S_ISCHR(st.st_mode):
        return False
    try:
       return os.major(st.st_rdev) == 0 and os.minor(st.st_rdev) == 0
    except Exception:
        return False

def find_bad_specials(upper_dir: Path):
    bad = []
    for root, dirs, files in os.walk(upper_dir, topdown=True):
        for name in files:
            # skip proper overlay whiteouts
            if name.startswith(".wh."):
                continue
            p = Path(root) / name
            try:
                st = os.lstat(p)
            except FileNotFoundError:
                continue
            # stat.S_ISCHR -> character device
            if stat.S_ISCHR(st.st_mode):
                # DO NOT treat overlayfs whiteouts (chr 0,0) as "bad"
                if is_overlayfs_whiteout(p):
                    continue
                bad.append(str(p))
    return bad

def scrub_bad_specials(upper_dir: Path):
    bad = find_bad_specials(upper_dir)
    if not bad:
        return False, []
    for p in bad:
        try:
            os.remove(p)
        except IsADirectoryError:
            shutil.rmtree(p, ignore_errors=True)
        except FileNotFoundError:
            pass
        except Exception as e:
            log(f"SCRUB_ERROR: {p}: {e}", "ERROR")
    return True, bad

def clear_overlay_opq_and_whiteouts(upper_dir: Path) -> int:
    """Remove .wh..wh..opq and any .wh.* entries under upper_dir (depth 1)."""
    removed = 0
    try:
        opq = upper_dir / ".wh..wh..opq"
        if opq.exists():
            opq.unlink(missing_ok=True)
            removed += 1

        # Remove xattr if it exists
        try:
            os.removexattr(str(upper_dir), "trusted.overlay.opaque")
            removed += 1
        except (OSError, AttributeError):
            # OSError means attribute doesn't exist or not supported - both OK
            pass

        # Remove per-file whiteouts directly under this dir
        for p in upper_dir.glob(".wh*"):
            try:
                p.unlink()
                removed += 1
            except Exception:
                pass
    except Exception:
        pass
    return removed

def quarantine_stale_upper(target: Path, upper: Path, lower: Path) -> int:
    """Move cached copies that are older than the file on disk out of 'upper'.

    Call only for an upper no mounted overlay is using. While an overlay is up,
    the real directory is hidden and only changes through a flush, so pending
    cached files are never older than their disk copy. An older one means the
    disk changed while this upper sat unused -- typically a persistent (path)
    workspace left with pending data, then the backing switched to RAM and
    back. Flushing or overlaying it would roll the newer file back. Entries are
    moved (never deleted) to <workspace>/<name>/stale/<timestamp>/ for review.
    Covers files, symlinks and whiteouts (a deletion older than the disk file).
    """
    moved = 0
    stale_root = upper.parent / "stale" / time.strftime("%Y%m%d-%H%M%S")
    for root, dirs, files in os.walk(upper):
        names = files + [d for d in dirs if os.path.islink(os.path.join(root, d))]
        for f in names:
            up = os.path.join(root, f)
            rel = os.path.relpath(up, upper)
            try:
                u_mtime = os.lstat(up).st_mtime_ns
                l_mtime = os.lstat(os.path.join(lower, rel)).st_mtime_ns
            except FileNotFoundError:
                continue  # new file, or already gone from disk: nothing to compare
            except Exception as e:
                log(f"STALE_CHECK_WARN: {up} - {e}", "WARN")
                continue
            if u_mtime >= l_mtime:
                continue
            dest = stale_root / rel
            try:
                dest.parent.mkdir(parents=True, exist_ok=True)
                os.rename(up, dest)
                moved += 1
                log(f"STALE_UPPER_QUARANTINED: {target / rel} -> {dest} "
                    f"(cached copy older than the file on disk)", "WARN")
            except Exception as e:
                log(f"STALE_QUARANTINE_FAILED: {up} - {e}", "ERROR")
    return moved

def upper_has_content(upper_dir: Path) -> bool:
    """True if upper_dir contains any entries (including .wh.* markers)."""
    try:
        if not upper_dir.exists():
            return False
        with os.scandir(upper_dir) as it:
            for _ in it:
                return True
    except Exception:
        pass
    return False

def flush_pending_on_boot(cfg: dict, workspace_root: Path):
    """Flush any pending upperdirs found in a persistent workspace before mounting overlays.

    This is intended for workspace_type: path, where upper/work survive reboots.
    """
    log(f"BOOT_FLUSH_START: Checking persistent workspace for pending changes under {workspace_root}")
    errors = 0

    for entry in parse_paths(cfg.get("paths", "")):
        path, mode = entry.path, entry.mode
        # Only flush modes that are intended to write back.
        if mode not in FLUSH_MODES:
            continue
        p = Path(path).resolve()
        name = sanitize_name(str(p))
        upper = workspace_root / name / "upper"
        work = workspace_root / name / "work"

        if not upper_has_content(upper):
            continue

        log(f"BOOT_FLUSH_PENDING: {p} upper={upper}")
        try:
            lower = lower_bind_for(workspace_root, name)
            if not is_mountpoint(lower):
                lower = ensure_lower_bind(workspace_root, p, name)
            quarantine_stale_upper(p, upper, lower)

            ok = False
            try:
                if not remount_lower(lower, rw=True):
                    log(f"LOWER_RW_FAILED: {lower} - skipping boot flush of {p}", "ERROR")
                else:
                    changed, bad = scrub_bad_specials(upper)
                    if changed:
                        log(f"SCRUBBED_UPPER: specials={len(bad)} under {upper}")
                    ok = rsync_upper_to_lower(str(upper), str(lower),
                                              enforce_uid=entry.uid,
                                              enforce_gid=entry.gid,
                                              enforce_mode=entry.perms)
            finally:
                try:
                    remount_lower(lower, rw=False)
                except Exception as e2:
                    errors += 1
                    log(f"LOWER_RO_RESTORE_FAILED: {lower} - {e2}", "ERROR")

            if ok:
                try:
                    try:
                        clear_overlay_opq_and_whiteouts(upper)
                    except Exception:
                        pass
                    clear_upper_dir(str(upper))
                except Exception as e:
                    errors += 1
                    log(f"BOOT_FLUSH_CLEAR_UPPER_FAILED: {upper} - {e}", "ERROR")

                # Recreate workdir to keep overlayfs happy
                try:
                    if work.exists():
                        shutil.rmtree(work, ignore_errors=True)
                    work.mkdir(parents=True, exist_ok=True)
                except Exception as e:
                    errors += 1
                    log(f"BOOT_FLUSH_RECREATE_WORKDIR_FAILED: {work} - {e}", "ERROR")
            else:
                errors += 1
                log(f"BOOT_FLUSH_FAILED: {p}", "ERROR")
        except Exception as e:
            errors += 1
            log(f"BOOT_FLUSH_ERROR: {p} - {e}", "ERROR")

    if errors:
        log(f"BOOT_FLUSH_COMPLETE: {errors} errors", "ERROR")
    else:
        log("BOOT_FLUSH_COMPLETE: Success", "INFO")

def mount_overlays(cfg):
    if not cfg.get("enable", True):
        log("DISABLED: WriteCache disabled", "WARN")
        return
    ws_root = ensure_workspace(cfg)
    ws_type = get_workspace_type(cfg)

    # Optional: flush pending workspace data before mounting overlays (useful only
    # when workspace is persistent).
    if cfg.get("flush_on_boot", False) and ws_type == "path":
        flush_pending_on_boot(cfg, ws_root)

    journal_mounted = False
    for entry in parse_paths(cfg.get("paths", "")):
        path, mode = entry.path, entry.mode
        p = Path(path).resolve()
        # Do not allow overlay targets inside our workspace (e.g., lower/upper/work)
        if str(p).startswith(str(ws_root) + "/") or str(p) == str(ws_root):
            log(f"SKIP_WORKSPACE_PATH: refusing to overlay {p}", "ERROR")
            continue
        if not p.exists():
            log(f"SKIP_MISSING: {p}", "WARN")
            continue
        name = sanitize_name(str(p))
        upper = ws_root / name / "upper"
        work = ws_root / name / "work"
        upper.mkdir(parents=True, exist_ok=True)
        work.mkdir(parents=True, exist_ok=True)
        sync_upper_attrs(upper, p, entry.uid, entry.gid, entry.perms)
        write_policy_marker(ws_root, name, mode)
        changed, bad = scrub_bad_specials(upper)
        if changed:
            log(f"SCRUBBED_BAD_SPECIALS: {len(bad)} removed under {upper}")
        if is_overlay_mounted(p):
            log(f"ALREADY_MOUNTED: {p}")
            continue
        # Use a bind of the real path as the overlay's lowerdir
        lower = ensure_lower_bind(ws_root, p, name)
        # If something mounted overlay on the lower bind path, tear it down
        if is_overlay_mounted(lower):
            log(f"FIXUP: overlay was mounted on lower bind {lower}; unmounting", "ERROR")
            force_unmount_with_retry(lower)
        quarantine_stale_upper(p, upper, lower)
        opts = f"lowerdir={lower},upperdir={upper},workdir={work},metacopy=off,redirect_dir=off,index=off"
        log(f"OVERLAY_MOUNT: {p} policy={mode} lowerdir={lower}")
        res = run(["mount", "-t", "overlay", "overlay", "-o", opts, str(p)], check=False)
        if res.returncode == 0:
            log(f"MOUNTED: {p} policy={mode}")
            journal_mounted |= holds_journal(p)
        else:
            log(f"MOUNT_FAILED: {p}", "ERROR")

    # journald was moved to /run before the teardown (or is still there from
    # boot); bring it back onto the new overlay.
    if journal_mounted:
        journal_reclaim()

def is_opaque_dir(path: str, files) -> bool:
    """True if upper directory 'path' is opaque (hides everything below it in lower).

    Kernel overlayfs marks this with the trusted.overlay.opaque="y" xattr, set
    e.g. by 'rm -rf d; mkdir d'. The '.wh..wh..opq' marker file is the aufs
    convention and is only kept for compatibility.
    """
    if ".wh..wh..opq" in files:
        return True
    try:
        return os.getxattr(path, "trusted.overlay.opaque", follow_symlinks=False) == b"y"
    except OSError:
        return False

def apply_whiteouts(upper_dir, lower_dir) -> bool:
    """
    Apply overlayfs whiteouts from upper -> lower. Returns False if any could not
    be applied, so the caller keeps the upper for a retry instead of letting the
    deleted entries reappear.
    - char device (0,0) entries are overlayfs whiteouts: delete <name> in lower
    - opaque directories (trusted.overlay.opaque xattr, or .wh..wh..opq): remove
      lower entries not present in upper
    - .wh.<name> (aufs-style) deletes <name> in lower
    """
    upper_dir = os.path.abspath(upper_dir)
    lower_dir = os.path.abspath(lower_dir)
    log(f"Applying whiteouts from {upper_dir} to {lower_dir}")
    ok = True

    for root, dirs, files in os.walk(upper_dir, topdown=True):
        rel = os.path.relpath(root, upper_dir)
        lower_root = os.path.join(lower_dir, rel) if rel != "." else lower_dir

        # 'root' is a real directory in upper. If lower has a symlink or file by
        # that name, the upper directory replaced it: remove it now, so nothing
        # below follows a lower symlink out of the managed tree.
        if rel != "." and (os.path.islink(lower_root)
                           or (os.path.lexists(lower_root) and not os.path.isdir(lower_root))):
            try:
                os.remove(lower_root)
            except FileNotFoundError:
                pass
            except Exception as e:
                ok = False
                log(f"Replace of lower non-directory failed at {lower_root}: {e}", "ERROR")
                continue

        # Opaque directory handling
        if is_opaque_dir(root, files):
            try:
                os.makedirs(lower_root, exist_ok=True)
                # live names present in upper at this level (excluding whiteouts)
                upper_live = set(n for n in (files + dirs)
                                 if not n.startswith(".wh.")
                                 and not is_overlayfs_whiteout(Path(root) / n))
                try:
                    lower_entries = os.listdir(lower_root)
                except FileNotFoundError:
                    lower_entries = []
                for name in lower_entries:
                    if name not in upper_live:
                        target = os.path.join(lower_root, name)
                        if os.path.isdir(target) and not os.path.islink(target):
                            shutil.rmtree(target, ignore_errors=True)
                        else:
                            try:
                                os.remove(target)
                            except FileNotFoundError:
                                pass
                log(f"Applied opaque whiteout at {lower_root}")
            except Exception as e:
                ok = False
                log(f"Opaque cleanup failed at {lower_root}: {e}", "ERROR")

        # Regular whiteouts
        for f in files:
            if f.startswith(".wh.") and f != ".wh..wh..opq":
                target = os.path.join(lower_root, f[4:])
                if os.path.isdir(target) and not os.path.islink(target):
                    shutil.rmtree(target, ignore_errors=True)
                else:
                    try:
                        os.remove(target)
                    except FileNotFoundError:
                        pass
                log(f"Applied whiteout for {target}")

        # Char-device whiteouts (0,0): delete matching name in lower
        for f in files:
            if f.startswith(".wh."):
                continue
            up = Path(root) / f
            if is_overlayfs_whiteout(up):
                target = os.path.join(lower_root, f)
                if os.path.isdir(target) and not os.path.islink(target):
                    shutil.rmtree(target, ignore_errors=True)
                else:
                    try:
                        os.remove(target)
                    except FileNotFoundError:
                        pass
                log(f"Applied chrdev whiteout for {target}")

    return ok

def rsync_upper_to_lower(upper_dir, lower_dir, enforce_uid=None, enforce_gid=None, enforce_mode=None):
    """
    Sync overlayfs upper -> lower.
    - Apply whiteouts before rsync.
    - Preserve perms, times, ACLs, hardlinks (no xattrs).
    - Avoid specials/devices so we don't mknod transient files.
    - Treat rsync 24 (source files vanished) as non-fatal. 23 (some files could
      not be transferred) is a failure: the upper holds the only copy of those
      files, so it must be preserved for a retry, not cleared.
    - Report number of files transferred, and rsync's literal/matched bytes.

    If enforce_uid/enforce_gid/enforce_mode are given they are applied to
    lower_dir after rsync instead of the saved pre-rsync attributes.  This
    lets callers pin specific ownership/permissions on the managed directory
    root regardless of what overlayfs or rsync may have written.
    """
    os.makedirs(lower_dir, exist_ok=True)
    upper_dir = os.path.abspath(upper_dir)
    lower_dir = os.path.abspath(lower_dir)

    # Save lower root's own attributes before rsync. When overlayfs records a
    # chmod/chown on the managed path (e.g. an app chowns its log dir to
    # root:root 777), it stores that by modifying upper/'s own mode/owner.
    # rsync -a then copies those attributes onto lower/ at the end of the
    # transfer, permanently altering the real directory's ownership/mode.
    # We restore them afterwards so only the *contents* are affected.
    lower_root_stat = os.stat(lower_dir)

    log(f"RSYNC: {upper_dir} -> {lower_dir}", "DEBUG")
    whiteouts_ok = apply_whiteouts(upper_dir, lower_dir)

    ok = _rsync_into_lower(upper_dir, lower_dir, ["--ignore-times", "--exclude", ".wh.*"],
                           lower_root_stat, enforce_uid, enforce_gid, enforce_mode)
    if not whiteouts_ok:
        log(f"WHITEOUTS_FAILED: {upper_dir} -> {lower_dir} (deletions not fully applied)", "ERROR")
        return False
    return ok

def _rsync_exclude_pattern(rel: str) -> str:
    """Anchored rsync pattern matching exactly 'rel' (wildcards escaped)."""
    return "/" + re.sub(r"([*?\[\\])", r"\\\1", rel)

def rsync_merged_to_lower(merged_dir, lower_dir, enforce_uid=None, enforce_gid=None, enforce_mode=None):
    """
    Sync the live overlay's merged view -> lower, with the overlay still mounted.

    The merged view already has deletions applied, so --delete replaces the
    whiteout handling. Files not in the upper are the lower's own files seen
    through the overlay, identical in size and mtime, so the quick check skips
    them and rsync only writes lower files the upper has shadowed -- which the
    overlay no longer reads from lower. --ignore-times is left out for that
    reason: it would re-read every file under the path on every flush. The
    quick check compares mtimes to the nanosecond (--modify-window=-1): at the
    default whole-second precision, a same-size rewrite in the second of the
    previous flush (a state file) would be skipped on every later flush.

    Mounts below merged_dir (another managed path, a bind) are excluded, which
    also keeps --delete away from the lower's copy of them.
    """
    merged_dir = os.path.abspath(merged_dir)
    lower_dir = os.path.abspath(lower_dir)
    lower_root_stat = os.stat(lower_dir)

    excludes = []
    for m in get_mounts():
        rel = os.path.relpath(m["path"], merged_dir)
        if rel != "." and not rel.startswith(".."):
            excludes += ["--exclude", _rsync_exclude_pattern(rel)]

    log(f"RSYNC_MERGED: {merged_dir} -> {lower_dir}", "DEBUG")
    return _rsync_into_lower(merged_dir, lower_dir,
                             ["--delete", "--modify-window=-1"] + excludes,
                             lower_root_stat, enforce_uid, enforce_gid, enforce_mode)

def _rsync_into_lower(src_dir, lower_dir, extra_args, lower_root_stat,
                      enforce_uid=None, enforce_gid=None, enforce_mode=None) -> bool:
    """Run the rsync shared by both flush kinds, log its stats and restore the
    lower root's attributes. Returns True on success (or vanished files)."""
    args = [
        "rsync", "-aHA", "--inplace",
        "--no-specials", "--no-devices",
        "--no-whole-file",
    ] + extra_args + [
        "--stats", "--human-readable",
        src_dir.rstrip("/") + "/",
        lower_dir.rstrip("/") + "/",
    ]
    proc = run(args, check=False, quiet=True)
    output = proc.stdout or ""
    transferred = 0
    total_bytes = None
    # "transferred" is total size of files sent, regardless of rsync's delta
    # algorithm. Literal/matched split it into new data and data reused from
    # existing lower copy.
    literal_data = None
    matched_data = None

    for line in output.splitlines():
        if "Number of regular files transferred" in line:
            try:
                transferred = int(line.split(":")[1].strip())
            except (IndexError, ValueError):
                pass
        elif "Total transferred file size" in line:
            try:
                total_bytes = line.split(":")[1].strip()
            except IndexError:
                pass
        elif "Literal data" in line:
            literal_data = line.strip()
        elif "Matched data" in line:
            matched_data = line.strip()

    if transferred > 0 or total_bytes:
        info = []
        if transferred:
            info.append(f"{transferred} files")
        if total_bytes:
            info.append(f"{total_bytes} transferred")
        if literal_data:
            info.append(literal_data)
        if matched_data:
            info.append(matched_data)
        log(f"RSYNC_STATS: {src_dir} -> {lower_dir} :: {'; '.join(info)}", "INFO")

    # Restore the root directory's owner/mode.  rsync -a fixes up directory
    # attributes at the end of the transfer, including the destination root,
    # which would otherwise inherit the source root's owner/mode.
    # If the caller specified enforce_* values, use those; otherwise fall back
    # to the pre-rsync attributes captured by the caller so we never alter the
    # real directory's ownership/permissions.
    final_uid  = enforce_uid  if enforce_uid  is not None else lower_root_stat.st_uid
    final_gid  = enforce_gid  if enforce_gid  is not None else lower_root_stat.st_gid
    final_mode = enforce_mode if enforce_mode is not None else stat.S_IMODE(lower_root_stat.st_mode)
    try:
        os.chmod(lower_dir, final_mode)
        os.chown(lower_dir, final_uid, final_gid)
        log(f"RSYNC_ROOT_ATTR_RESTORED: {lower_dir} uid={final_uid} gid={final_gid} mode={oct(final_mode)}", "DEBUG")
    except Exception as e:
        log(f"RSYNC_ROOT_ATTR_RESTORE_FAILED: {lower_dir} - {e}", "WARN")

    if proc.returncode == 0:
        log(f"RSYNC_OK: {src_dir} -> {lower_dir}", "DEBUG")
        return True
    if proc.returncode == 24:
        # Source files vanished mid-transfer; everything still present was copied.
        log(f"RSYNC_WARN(24): {src_dir} -> {lower_dir} (vanished files)", "WARN")
        return True
    log(f"RSYNC_FAILED: exit {proc.returncode} ({output.strip()})", "ERROR")
    return False


def clear_upper_dir(upper_dir: str):
    """Remove all entries from the upper dir (including .wh.* and subdirs)."""
    for entry in os.listdir(upper_dir):
        if entry in ('.', '..'):
            continue
        p = os.path.join(upper_dir, entry)
        try:
            if os.path.isdir(p) and not os.path.islink(p):
                shutil.rmtree(p, ignore_errors=True)
            else:
                os.remove(p)
        except FileNotFoundError:
            pass
        except Exception as e:
            log(f"CLEAR_UPPER_WARN: failed to remove {p}: {e}", "WARN")

# Paths whose writer holds a lock for the whole of a transaction. A routine
# flush skips such a path while any of its locks is held, keeping the upper for
# the next flush: lazily detaching /var/lib/dpkg/updates under a running dpkg
# would lose its journal writes. A teardown flush still runs, since the
# workspace is about to go away regardless.
BUSY_LOCKS = {
    "/var/lib/dpkg/updates": ("/var/lib/dpkg/lock-frontend", "/var/lib/dpkg/lock"),
}

def lock_is_held(lock_path: str) -> bool:
    """True if another process holds a POSIX (fcntl) lock on lock_path."""
    try:
        fd = os.open(lock_path, os.O_RDWR)
    except OSError:
        return False
    try:
        fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        fcntl.lockf(fd, fcntl.LOCK_UN)
        return False
    except OSError:
        return True
    finally:
        os.close(fd)

def flush(cfg, teardown: bool = False):
    """Write pending changes of every flush-policy overlay back to disk.

    teardown=True is used right before unmount(): a successfully flushed overlay
    is left unmounted instead of being remounted only to be torn down again
    (anything written in between would land in an upper about to be discarded),
    and BUSY_LOCKS are ignored. A path whose flush failed is always remounted so
    its preserved upper stays visible.
    """
    log("FLUSH_START: Beginning flush operation")
    ws_root = get_workspace_root(cfg)
    if not is_workspace_ready(cfg):
        if not teardown:
            log(f"WORKSPACE_NOT_READY: {ws_root} (cannot flush)", "ERROR")
            return False
        # Expected before a teardown, e.g. a settings change after the RPC has
        # already unmounted the old workspace. Only live overlays can hold
        # pending data then, and _flush_path follows their own directories.
        log(f"FLUSH_WORKSPACE_ABSENT: {ws_root} not mounted; flushing live overlays only")
    errors = 0
    for entry in parse_paths(cfg.get("paths", "")) + orphan_entries(cfg):
        if entry.mode not in FLUSH_MODES:
            log(f"SKIP_MODE: {entry.path} mode={entry.mode}", "DEBUG")
            continue
        # A signal arriving mid-path is acted on only once this path is back in
        # a consistent state (flushed or preserved, overlay remounted).
        p = Path(entry.path).resolve()
        journal = holds_journal(p) and is_overlay_mounted(p)
        with signals_deferred():
            if journal:
                journal_relinquish()
            try:
                with log_held_during(p):
                    errors += _flush_path(entry, ws_root, teardown)
            finally:
                # On a teardown the overlay stays unmounted; the next mount
                # moves journald back.
                if journal and not teardown:
                    journal_reclaim()

    log("FLUSH_COMPLETE: Success" if errors == 0 else f"FLUSH_COMPLETE: {errors} errors",
        "ERROR" if errors else "INFO")
    return errors == 0

def _flush_path(entry, ws_root: Path, teardown: bool) -> int:
    """Flush one path; returns the number of errors."""
    errors = 0
    p = Path(entry.path).resolve()
    name = sanitize_name(str(p))

    if not teardown:
        held = [l for l in BUSY_LOCKS.get(str(p), ()) if lock_is_held(l)]
        if held:
            log(f"FLUSH_DEFERRED: {p} - {', '.join(held)} held; upper kept for the next flush", "WARN")
            return 0

    # Prefer the directories of the overlay CURRENTLY mounted at 'p'. The
    # configured workspace_root can drift after overlays are mounted (e.g. the
    # workspace type/root is changed in the UI while overlays are live). Flushing
    # the live overlay's own upper/lower/work avoids operating on the wrong
    # (usually empty) workspace and silently discarding cached data.
    live = live_overlay_dirs(p)
    copies = []
    if live:
        lower = Path(live["lower"])
        upper = Path(live["upper"])
        work = Path(live["work"])
        if upper.parent != (ws_root / name):
            log(f"WORKSPACE_DRIFT: {p} live workspace={upper.parent} configured={ws_root / name}; using live", "WARN")
        copies = [m["path"] for m in get_mounts()
                  if m["fstype"] == "overlay" and m["path"] != str(p)
                  and (_overlay_dirs(m) or {}).get("upper") == live["upper"]]
    else:
        upper = ws_root / name / "upper"
        work = ws_root / name / "work"
        lower = lower_bind_for(ws_root, name)

    if not upper.exists():
        log(f"NO_UPPER: {p}", "DEBUG")
        return 0

    # 1) Unmount overlay FIRST so that 'p' resolves to the real filesystem and so
    #    that (re)binding the lower below cannot accidentally capture our own
    #    overlay (which would make the lower bind 'overlay'-sourced and unsafe).
    if is_overlay_mounted(p):
        log(f"PRE-FLUSH_UNMOUNT: {p}")
        # A routine flush never detaches a busy overlay: every process holding
        # a file open in it would keep writing into the upper deleted below
        # (nginx, rsyslog, samba... on /var/log). Sync it in place instead.
        if not teardown and live and not unmount_if_idle(p):
            return _flush_in_place(entry, p, lower, upper)
        # allow_kill=False: never SIGKILL the daemon that owns this data just
        # to force the unmount. A clean 'stopstart' services entry is the way
        # to make a busy flush target idle; absent that, the lazy detach
        # fallback is still far safer than a torn database + stale pidfile.
        if not force_unmount_with_retry(p, allow_kill=False):
            log(f"PRE-FLUSH_UNMOUNT_FAILED: {p}", "ERROR")
            return 1

    # 2) If the lower bind exists but is "poisoned" (overlay), tear it down so it
    #    can be rebound from the now-real path.
    if is_mountpoint(lower) and not lower_bind_is_safe(lower):
        info = findmnt_info(lower)
        log(f"LOWER_BIND_BAD: {lower} source={info.get('source')} fstype={info.get('fstype')} - recreating", "ERROR")
        force_unmount_with_retry(lower)

    # 3) Bind the lower from the (now overlay-free) real path if it is missing.
    if not is_mountpoint(lower):
        lower.mkdir(parents=True, exist_ok=True)
        res = run(["mount", "--bind", str(p), str(lower)], check=False)
        if res.returncode != 0 or not is_mountpoint(lower):
            log(f"LOWER_BIND_FAILED: {p} -> {lower}", "ERROR")
            return 1
        run(["mount", "--make-private", str(lower)], check=False)
        remount_lower(lower, rw=False, check=False)

    # Final safety check: refuse to flush if lower is not a real mount.
    if not lower_bind_is_safe(lower):
        info = findmnt_info(lower)
        log(f"LOWER_BIND_UNSAFE: refusing to flush {p} -> {lower} (source={info.get('source')} fstype={info.get('fstype')})", "ERROR")
        return 1

    if not live:
        # No overlay was using this upper, so it may be left over from an
        # earlier session and older than what is now on disk.
        quarantine_stale_upper(p, upper, lower)

    ok = False
    try:
        # RW for rsync. If the lower cannot be made writable, rsync would fail
        # for every single file; skip the transfer instead of running it against
        # a read-only target. ok stays False so the upper is preserved below.
        if not remount_lower(lower, rw=True):
            log(f"LOWER_RW_FAILED: {lower} - skipping flush of {p}", "ERROR")
        else:
            # scrub before syncing (whiteouts/opaque markers)
            changed, bad = scrub_bad_specials(upper)
            if changed:
                log(f"SCRUBBED_UPPER: specials={len(bad)} under {upper}")

            # actual flush
            ok = rsync_upper_to_lower(str(upper), str(lower),
                                      enforce_uid=entry.uid,
                                      enforce_gid=entry.gid,
                                      enforce_mode=entry.perms)

    except Exception as e:
        errors += 1
        log(f"FLUSH_ERROR: {p} - {e}", "ERROR")
    finally:
        # ALWAYS go back to RO
        try:
            remount_lower(lower, rw=False)
        except Exception as e2:
            errors += 1
            log(f"LOWER_RO_RESTORE_FAILED: {lower} - {e2}", "ERROR")

    if ok:
        # post-sync cleanup of upper
        try:
            try:
                clear_overlay_opq_and_whiteouts(upper)
            except Exception:
                pass
            clear_upper_dir(str(upper))
            log(f"FLUSHED: {p}")
        except Exception as e:
            errors += 1
            log(f"CLEAR_UPPER_FAILED: {upper} - {e}", "ERROR")
    else:
        errors += 1
        log(f"FLUSH_FAILED: {p} - upper preserved for retry", "ERROR")

    # Recreate the upper ONLY after a successful flush. When the flush failed
    # the upper still holds the only copy of the cached writes: deleting it here
    # would discard them, and the overlay remounted below would then expose the
    # older lower content instead (files appear to revert to an older version).
    # flush_pending_on_boot() has always preserved the upper on failure; this
    # path is being brought in line with it.
    try:
        if ok:
            if upper.exists():
                shutil.rmtree(upper, ignore_errors=True)
            upper.mkdir(parents=True, exist_ok=True)
            sync_upper_attrs(upper, p, entry.uid, entry.gid, entry.perms)
            log(f"RECREATED_UPPERDIR: {upper}")
            # Only a cleared upper strands the copies: they still write into
            # the deleted one. An in-place flush never gets here.
            if copies:
                log(f"OVERLAY_COPIES: {p} is also mounted at {', '.join(copies)} (bind copies, e.g. "
                    f"from a backup tool); writes made through them after this flush are "
                    f"lost -- unmount them", "WARN")
        else:
            upper.mkdir(parents=True, exist_ok=True)
            log(f"PRESERVED_UPPERDIR: {upper} (pending flush retry)", "WARN")

        # The workdir is overlayfs scratch space and never holds user data, so
        # it is always recreated to give the remount below a clean one.
        if work.exists():
            shutil.rmtree(work, ignore_errors=True)
        work.mkdir(parents=True, exist_ok=True)
        log(f"RECREATED_WORKDIR: {work}")
    except Exception as e:
        log(f"DIR_RECREATE_FAILED: {e}", "ERROR")
        errors += 1

    if teardown and ok:
        log(f"POST-FLUSH_MOUNT_SKIPPED: {p} (teardown follows)", "DEBUG")
        return errors

    # Remount overlay so merged view sees the new lower state
    opts = f"lowerdir={lower},upperdir={upper},workdir={work},metacopy=off,redirect_dir=off,index=off"
    log(f"POST-FLUSH_MOUNT: {p} lowerdir={lower}")
    mount_result = run(["mount", "-t", "overlay", "overlay", "-o", opts, str(p)], check=False)

    if mount_result.returncode != 0:
        log(f"POST-FLUSH_MOUNT_FAILED: {p} - exit {mount_result.returncode}", "ERROR")
        errors += 1
    return errors

def _flush_in_place(entry, p: Path, lower: Path, upper: Path) -> int:
    """Flush a busy overlay without unmounting it; returns the number of errors.

    The merged view is synced to the lower and the overlay stays mounted, so
    open files keep working and nothing written to them is lost. The upper is
    not cleared, so its RAM is only given back by a flush that finds the path
    idle, or by the unmount at shutdown.
    """
    if not is_mountpoint(lower) or not lower_bind_is_safe(lower):
        # It cannot be rebound from 'p' while the overlay covers it.
        log(f"FLUSH_IN_PLACE_FAILED: {p} - lower bind {lower} missing or unsafe; "
            f"upper kept for the next flush", "ERROR")
        return 1

    log(f"FLUSH_IN_PLACE: {p} is in use; syncing it without unmounting")
    errors = 0
    ok = False
    try:
        if not remount_lower(lower, rw=True):
            log(f"LOWER_RW_FAILED: {lower} - skipping flush of {p}", "ERROR")
        else:
            ok = rsync_merged_to_lower(str(p), str(lower),
                                       enforce_uid=entry.uid,
                                       enforce_gid=entry.gid,
                                       enforce_mode=entry.perms)
    except Exception as e:
        errors += 1
        log(f"FLUSH_ERROR: {p} - {e}", "ERROR")
    finally:
        try:
            remount_lower(lower, rw=False)
        except Exception as e2:
            errors += 1
            log(f"LOWER_RO_RESTORE_FAILED: {lower} - {e2}", "ERROR")

    if ok:
        log(f"FLUSHED_IN_PLACE: {p} (upper kept, {fmt_bytes(get_dir_size_bytes(upper))})")
    else:
        errors += 1
        log(f"FLUSH_FAILED: {p} - upper preserved for retry", "ERROR")
    return errors

def unmount(cfg, allow_kill: bool = True):
    """Tear down all overlays and (for RAM-backed workspaces) the workspace itself.

    allow_kill=False is passed on the --reconfigure path (a settings change that
    reshuffles overlays on a live system). There we must never SIGKILL a user's
    process to win an unmount: a busy overlay lazy-detaches instead, and the
    following 'mount' re-establishes it. The real shutdown/reboot teardown keeps
    allow_kill=True, where lazy-detaching risks losing writes seconds before
    power-off and a killed daemon no longer matters.
    """
    log("UNMOUNT_START: Beginning unmount operation"
        + ("" if allow_kill else " (reconfigure: no kill escalation)"))
    ws_root = get_workspace_root(cfg)
    ws_type = get_workspace_type(cfg)
    # Capture the zram backing device up front, before anything is unmounted,
    # so the device can always be reset and hot-removed at the end.
    zdev = device_for_mount(ws_root) if ws_type == "zram" else ""
    # Include overlays of paths since removed from the config: left mounted, they
    # would pin the workspace and survive on a lazily-detached copy of it.
    items = list(parse_paths(cfg.get("paths",""))) + orphan_entries(cfg)
    if any(holds_journal(Path(e.path).resolve()) and is_overlay_mounted(Path(e.path).resolve())
           for e in items):
        journal_relinquish()
    # 1) remove any overlay mounted ON the lower bind
    for entry in items:
        path = entry.path
        name = sanitize_name(str(Path(path).resolve()))
        lower = lower_bind_for(ws_root, name)
        if is_overlay_mounted(lower):
            log(f"LOWER_OVERLAY_UNMOUNT: {lower}")
            force_unmount_with_retry(lower, allow_kill=allow_kill)

    # 2) unmount main overlays deepest-first
    paths = [Path(entry.path).resolve() for entry in items]
    for p in sorted(paths, key=lambda x: len(str(x)), reverse=True):
        name = sanitize_name(str(p))
        # The live overlay's own lower, in case it was mounted under another root.
        live = live_overlay_dirs(p)
        lower = Path(live["lower"]) if live else lower_bind_for(ws_root, name)
        if is_overlay_mounted(p):
            log(f"UNMOUNT: {p}")
            force_unmount_with_retry(p, allow_kill=allow_kill)
        else:
            log(f"NOT_MOUNTED: {p}", "DEBUG")
        if is_mountpoint(lower):
            log(f"LOWER_UNMOUNT: {lower}")
            force_unmount_with_retry(lower, allow_kill=allow_kill)

    # 3) aggressively unmount anything still mounted under the workspace root
    unmount_all_under_workspace(ws_root, allow_kill=allow_kill)

    # 4) if workspace is persistent, ensure 'drop' policies actually drop changes
    # (otherwise upper/ content survives reboot and reappears after next mount)
    if ws_type == "path":
        drop_pending_changes(cfg)

    # 5) if workspace is RAM-backed (tmpfs/zram) that we mounted, tear it down
    if ws_type in ("tmpfs", "zram"):
        if is_mountpoint(ws_root):
            log(f"WORKSPACE_UNMOUNT_ROOT: {ws_root}")
            force_unmount_with_retry(ws_root, allow_kill=allow_kill)
        else:
            log(f"WORKSPACE_ROOT_NOT_MOUNTED: {ws_root}", "DEBUG")
        if ws_type == "zram" and zdev.startswith("/dev/zram"):
            # Reset frees the compressed pages and hot_remove returns the device
            # to the kernel, so devices allocated via hot_add don't accumulate
            # across mount/unmount cycles.
            free_zram_device(zdev)

    log("UNMOUNT_COMPLETE: Finished")

def cmd_mount():
    log("MOUNT_CMD: Starting mount command")
    cfg = load_config()
    if cfg.get("enable", True):
        if root_is_readonly():
            log("ROOT_RO: / is read-only; refusing to mount overlays (protecting system)", "ERROR")
            raise SystemExit(1)
        mount_overlays(cfg)
    else:
        log("DISABLED: Not mounting", "WARN")

def cmd_flush():
    log("FLUSH_CMD: Starting flush command")
    cfg = load_config()
    stopped = stop_services(cfg) if RESTART_SERVICES else set()
    ok = flush(cfg)
    if RESTART_SERVICES:
        restart_services(cfg, stopped)
    sys.exit(0 if ok else 1)

def cmd_rotateflush():
    log("ROTATEFLUSH_CMD: Starting rotate+flush command")
    journalctl_rotate_sync()
    cmd_flush()

def cmd_unmount() -> bool:
    """Flush (if configured) and tear down. Returns False if the teardown was
    refused to protect unflushed data (--reconfigure only)."""
    log("UNMOUNT_CMD: Starting unmount command")
    cfg = load_config()
    ws_type = get_workspace_type(cfg)
    flush_ok = True
    if NO_FLUSH:
        log("PRE_UNMOUNT_FLUSH: skipped (--no-flush)", "WARN")
    elif cfg.get("flush_on_shutdown", True) or RECONFIGURE:
        # flush_on_shutdown only governs shutdown. A --reconfigure teardown of a
        # RAM workspace would otherwise discard every pending 'flush' write on
        # each settings change.
        log("PRE_UNMOUNT_FLUSH: Flushing before unmount")
        flush_ok = flush(cfg, teardown=True)

    if RECONFIGURE and not flush_ok and ws_type in ("tmpfs", "zram") and is_workspace_ready(cfg):
        # A failed path keeps its upper for a retry; tearing down the RAM
        # workspace now would throw it away. At shutdown that is unavoidable,
        # on a live reconfigure it is not: keep everything mounted instead.
        log("UNMOUNT_ABORTED: flush failed; keeping the RAM workspace and overlays "
            "so unflushed writes are not discarded", "ERROR")
        mount_overlays(cfg)  # remount paths already flushed for the teardown
        return False

    # --reconfigure: a settings change reshuffling overlays on a live system.
    # Never SIGKILL holders to force an unmount there; let a busy overlay
    # lazy-detach and rely on the following mount to re-establish it.
    unmount(cfg, allow_kill=not RECONFIGURE)
    return True

def cmd_rotateunmount() -> bool:
    log("ROTATEUNMOUNT_CMD: Starting rotate+unmount command")
    journalctl_rotate_sync()
    return cmd_unmount()

def cmd_status():
    cfg = load_config()

    ws_root = get_workspace_root(cfg)
    ws_type = get_workspace_type(cfg)

    if not is_workspace_ready(cfg):
        if ws_type == "tmpfs":
            print(f"WriteCache: INACTIVE - {ws_root} is not mounted (tmpfs workspace missing).")
        elif ws_type == "zram":
            print(f"WriteCache: INACTIVE - {ws_root} is not mounted (zram workspace missing).")
        else:
            print(f"WriteCache: INACTIVE - workspace path not available/writable: {ws_root}")
        print("Overlays cannot be active without the workspace.")
        return

    # workspace is up; now see if any overlays are mounted
    items = list(parse_paths(cfg.get("paths", "")))
    active = 0
    for entry in items:
        path = entry.path
        p = Path(path).resolve()
        if is_overlay_mounted(p):
            active += 1

    if ws_type == "tmpfs":
        print(f"WriteCache: ACTIVE - {ws_root} is mounted (tmpfs).")
    elif ws_type == "zram":
        print(f"WriteCache: ACTIVE - {ws_root} is mounted (zram).")
    else:
        print(f"WriteCache: ACTIVE - workspace path is available: {ws_root}")
    print(f"Overlays mounted: {active}/{len(items)}")

    # keep the existing detailed listing, but only when workspace exists
    mounts = get_mounts()
    ws_prefix = f"upperdir={str(ws_root).rstrip('/')}/"
    writecache_mounts = [m for m in mounts if (
        (ws_type == "tmpfs" and m["fstype"] == "tmpfs" and m["path"] == str(ws_root)) or
        (ws_type == "zram" and m["device"].startswith("/dev/zram") and m["path"] == str(ws_root)) or
        (m["fstype"] == "overlay" and ws_prefix in m["opts"])
    )]

    if not writecache_mounts:
        print("No writecache mounts found (unexpected if workspace is mounted).")
        return

    print()
    print(f"{'Mount Point':<30} {'Type':<10} {'Options'}")
    print("-" * 60)
    for m in writecache_mounts:
        print(f"{m['path']:<30} {m['fstype']:<10} {m['opts']}")

def cmd_df():
    cfg = load_config()
    items = list(parse_paths(cfg.get("paths", "")))

    ws_root = get_workspace_root(cfg)
    ws_type = get_workspace_type(cfg)
    ready = is_workspace_ready(cfg)

    print(f"{'Target':<42} {'Overlay':<7}  {'Lower':<5}  {'Upper Used':>10}  {'Upper Dir'}")
    print("-" * 110)

    # workspace line
    if ready:
        du = shutil.disk_usage(str(ws_root))
        used = fmt_bytes(du.used)
        total = fmt_bytes(du.total)
        free = fmt_bytes(du.free)
        pct = int((du.used * 100) / du.total) if du.total else 0
        print(f"{str(ws_root):<42} {ws_type:<7}  {'-':<5}  {used:>10}  size={total} avail={free} use%={pct}%")

        # per-path lines
        for entry in items:
            path, mode = entry.path, entry.mode
            p = Path(path).resolve()
            name = sanitize_name(str(p))
            lower = lower_bind_for(ws_root, name)
            upper = ws_root / name / "upper"

            overlay = "YES" if is_overlay_mounted(p) else "NO"
            lower_ok = "YES" if is_mountpoint(lower) else "NO"

            upper_used = "-"
            if upper.exists():
                upper_used = fmt_bytes(get_dir_size_bytes(upper))

            print(f"{str(p):<42} {overlay:<7}  {lower_ok:<5}  {upper_used:>10}  {upper}")
    else:
        if ws_type == "tmpfs":
            print(f"{str(ws_root):<42} {'NO':<7}  {'-':<5}  {'-':>10}  NOT MOUNTED")
        else:
            print(f"{str(ws_root):<42} {'NO':<7}  {'-':<5}  {'-':>10}  NOT AVAILABLE")


def cmd_algos():
    for a in list_zram_algorithms():
        print(a)

def main():
    if len(sys.argv) < 2:
        print(
            "Usage: omv-writecache <command> [--no-flush] [--restart] [--daily] [--reconfigure]\n"
            "Commands:\n"
            "  mount\n"
            "  rotateflush [--restart]\n"
            "  flush [--restart]\n"
            "  unmount [--no-flush] [--reconfigure]\n"
            "  rotateunmount [--reconfigure]\n"
            "  remount [--reconfigure]\n"
            "  status\n"
            "  df\n"
            "  algos\n"
            "  version"
        )
        sys.exit(2)

    global NO_FLUSH
    global RESTART_SERVICES
    global DAILY_FLUSH
    global RECONFIGURE
    global LOG_ENABLED

    args = sys.argv[1:]
    cmd = args[0]

    if "--no-flush" in args:
        NO_FLUSH = True
        args.remove("--no-flush")

    if "--restart" in args:
        RESTART_SERVICES = True
        args.remove("--restart")

    if "--daily" in args:
        DAILY_FLUSH = True
        args.remove("--daily")

    if "--reconfigure" in args:
        RECONFIGURE = True
        args.remove("--reconfigure")

    # Identify commands that should not lock and should not log
    no_lock_no_log_cmds = {"df", "status", "version", "-v", "--version", "algos"}
    mutating_cmds = {"mount", "rotateflush", "flush", "unmount", "rotateunmount", "remount"}
    # algos enumerates kernel zram backends (modprobe/hot_add) and needs root,
    # but takes no lock and emits no dated log output so callers get clean lines.
    root_cmds = mutating_cmds | {"algos"}

    # Disable logging entirely for df/status/version/algos so there is no dated output
    if cmd in no_lock_no_log_cmds:
        LOG_ENABLED = False

    # version output (no root, no lock)
    if cmd in ["-v", "--version", "version"]:
        print(SCRIPT_VERSION)
        sys.exit(0)

    # Commands that touch mounts or kernel modules require root
    if cmd in root_cmds and os.geteuid() != 0:
        print("This tool must be run as root", file=sys.stderr)
        sys.exit(1)

    # Lock only for mutating commands
    if cmd in mutating_cmds:
        check_and_create_lock("omv-writecache")
        ensure_persistent_journal()
        cmdline = f"omv-writecache {cmd}"
        if NO_FLUSH:
            cmdline += " --no-flush"
        if RESTART_SERVICES:
            cmdline += " --restart"
        if DAILY_FLUSH:
            cmdline += " --daily"
        if RECONFIGURE:
            cmdline += " --reconfigure"
        log(f"SCRIPT_START: {cmdline} (v{SCRIPT_VERSION})")
        reclaim_pending_zram()

    ok = True
    if cmd == "mount":
        cmd_mount()
    elif cmd == "rotateflush":
        cmd_rotateflush()
    elif cmd == "flush":
        cmd_flush()
    elif cmd == "unmount":
        ok = cmd_unmount()
    elif cmd == "rotateunmount":
        ok = cmd_rotateunmount()
    elif cmd == "remount":
        cfg = load_config()
        reconfig_paths = {str(Path(e.path).resolve())
                           for e in parse_paths(cfg.get("paths", "") or "")}
        private_tmp_units = (find_active_private_tmp_units()
                              if reconfig_paths & PRIVATE_TMP_PATHS else [])

        # Same stop-before / restart-after wrapping cmd_flush() gives the
        # Services list around a flush, now also around a settings-triggered
        # remount. A unit listed with restart/reload/stopstart gets its mount
        # namespace rebuilt against the new overlay right here, which is what
        # actually prevents the 226/NAMESPACE failure below -- not just a log
        # line the admin has to act on later.
        stopped = stop_services(cfg)
        ok = cmd_unmount()
        cmd_mount()
        restart_services(cfg, stopped)

        if private_tmp_units:
            # Only warn about units the admin hasn't told WriteCache to
            # handle above -- we never restart another service's unit on our
            # own initiative. reloaddaily/restartdaily/stopdailystart entries
            # don't fire here (no --daily on a remount), so they don't count
            # as handling it either.
            handled = {e.name for e in parse_services(cfg.get("services", "") or "")
                       if e.action in ("restart", "reload", "stopstart")}
            unhandled = sorted(u for u in private_tmp_units if u not in handled)
            if unhandled:
                log("PRIVATE_TMP_WARN: " + ", ".join(unhandled) +
                    " have host-backed PrivateTmp dirs under a path just "
                    "reconfigured; their NEXT 'systemctl reload/restart' may "
                    "fail with 226/NAMESPACE unless restarted -- add "
                    "'<unit> = restart' to the Services list to have "
                    "WriteCache handle this automatically", "WARN")
    elif cmd == "status":
        cmd_status()
    elif cmd == "df":
        cmd_df()
    elif cmd == "algos":
        cmd_algos()
    else:
        if cmd in mutating_cmds:
            log(f"UNKNOWN_CMD: {cmd}", "ERROR")
        print("Unknown command")
        sys.exit(2)

    if cmd in mutating_cmds:
        log(f"SCRIPT_END: omv-writecache {cmd}")
        release_lock()
    sys.exit(0 if ok else 1)


# Handle termination signals (Ctrl+C, kill command)
signal.signal(signal.SIGINT, handle_signal)   # Handle Ctrl+C
signal.signal(signal.SIGTERM, handle_signal)  # Handle termination (kill command)

if __name__ == "__main__":
    try:
        main()
    finally:
        release_lock()
