From d5d710afa7dfd74d916742392f42e50b148a7b71 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Fri, 8 May 2026 11:24:04 +0200 Subject: [PATCH] fix(l4d2-host): make stop_instance idempotent on the unmount step systemctl stop is already a no-op on a stopped unit, but stop_instance was unconditionally running fusermount3 -u and bubbling up the EINVAL when the overlay wasn't currently mounted (e.g. server already stopped). Mirror the established delete_instance pattern: always attempt the unmount, swallow CalledProcessError, and label the step "(if mounted)". Co-Authored-By: Claude Opus 4.7 (1M context) --- l4d2host/instances.py | 19 +++++++++++-------- l4d2host/tests/test_lifecycle.py | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/l4d2host/instances.py b/l4d2host/instances.py index 59a293e..8128114 100644 --- a/l4d2host/instances.py +++ b/l4d2host/instances.py @@ -135,14 +135,17 @@ def stop_instance( passthrough=passthrough, should_cancel=should_cancel, ) - emit_step("unmounting runtime overlay...", on_stdout, passthrough) - run_command( - ["fusermount3", "-u", str(root / "runtime" / name / "merged")], - on_stdout=on_stdout, - on_stderr=on_stderr, - passthrough=passthrough, - should_cancel=should_cancel, - ) + emit_step("unmounting runtime overlay (if mounted)...", on_stdout, passthrough) + try: + run_command( + ["fusermount3", "-u", str(root / "runtime" / name / "merged")], + on_stdout=on_stdout, + on_stderr=on_stderr, + passthrough=passthrough, + should_cancel=should_cancel, + ) + except subprocess.CalledProcessError: + pass emit_step("stop complete.", on_stdout, passthrough) diff --git a/l4d2host/tests/test_lifecycle.py b/l4d2host/tests/test_lifecycle.py index 84d6bd4..5c369d1 100644 --- a/l4d2host/tests/test_lifecycle.py +++ b/l4d2host/tests/test_lifecycle.py @@ -96,6 +96,27 @@ def test_delete_stopped_instance_removes_dirs(tmp_path: Path, monkeypatch: pytes assert ["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "stop", "alpha"] in calls +def test_stop_succeeds_when_unmount_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + fusermount_calls: list[list[str]] = [] + + def fake_run_command(cmd, **kwargs): + del kwargs + if cmd and cmd[0] == "fusermount3": + fusermount_calls.append(list(cmd)) + raise subprocess.CalledProcessError( + returncode=1, + cmd=list(cmd), + stderr="fusermount3: failed to unmount /var/lib/left4me/runtime/alpha/merged: Invalid argument", + ) + + monkeypatch.setattr("l4d2host.instances.run_command", fake_run_command) + monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command) + + stop_instance("alpha", root=tmp_path) + + assert fusermount_calls, "stop must always attempt fusermount3 -u (no preflight)" + + def test_delete_succeeds_when_unmount_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: fusermount_calls: list[list[str]] = []