From d6a141873958d40b293bf1ad9dfb65d153b8beee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:13:31 +0200 Subject: [PATCH 1/8] feat: Improve reporting on unmount errors Users do not want to see a stack trace in case the device could not be unmounted. Instead, they'd prefer to get a useful error message without scrolling. This new feature will provide that. --- .../butter-backup/src/butter_backup/cli.py | 41 +++++++++++++------ .../tests/cli/test_cli_commands.py | 2 +- .../src/shell_interface/run_cmd.py | 9 ++-- .../src/storage_device_managers/__init__.py | 6 +-- .../tests/test_mount_unmount.py | 15 ++++++- 5 files changed, 53 insertions(+), 20 deletions(-) diff --git a/projects/butter-backup/src/butter_backup/cli.py b/projects/butter-backup/src/butter_backup/cli.py index 9511959..7067297 100644 --- a/projects/butter-backup/src/butter_backup/cli.py +++ b/projects/butter-backup/src/butter_backup/cli.py @@ -221,7 +221,14 @@ def close( ) continue sh.refresh_sudo(parsed_config.SudoPassCmd) - sdm.unmount_device(map_name) + try: + sdm.unmount_device(map_name) + except sdm.UnmountError as e: + typer.echo( + f"Speichermedium {cfg.Name} konnte nicht ausgehängt werden: {e}", + err=True, + ) + continue sdm.close_decrypted_device(map_name) @@ -250,6 +257,7 @@ def backup( """ setup_logging(verbose) parsed_config = _read_configuration(config) + had_unmount_error = False for cfg in parsed_config.DeviceConfigurations: if _skip_device( cfg, @@ -265,17 +273,26 @@ def backup( sh.refresh_sudo(parsed_config.SudoPassCmd) open_dir = parsed_config.OpenDirectory dest = open_dir / cfg.Name if open_dir is not None else None - with ( - sdm.decrypted_device(cfg.device(), cfg.DevicePassCmd) as decrypted, - sdm.mounted_device( - decrypted, dest, compression=cfg.compression() - ) as mount_dir, - ): - backend.do_backup(mount_dir, parsed_config.SudoPassCmd) - # A backup could take so long that the sudo session expires. In this - # case the user would have to enter the password again to unmount and - # close the device. To prevent this, the sudo session is refreshed. - sh.refresh_sudo(parsed_config.SudoPassCmd) + try: + with ( + sdm.decrypted_device(cfg.device(), cfg.DevicePassCmd) as decrypted, + sdm.mounted_device( + decrypted, dest, compression=cfg.compression() + ) as mount_dir, + ): + backend.do_backup(mount_dir, parsed_config.SudoPassCmd) + # A backup could take so long that the sudo session expires. In this + # case the user would have to enter the password again to unmount and + # close the device. To prevent this, the sudo session is refreshed. + sh.refresh_sudo(parsed_config.SudoPassCmd) + except sdm.UnmountError as e: + typer.echo( + f"Speichermedium {cfg.Name} konnte nicht ausgehängt werden: {e}", + err=True, + ) + had_unmount_error = True + if had_unmount_error: + raise typer.Exit(1) @app.command() diff --git a/projects/butter-backup/tests/cli/test_cli_commands.py b/projects/butter-backup/tests/cli/test_cli_commands.py index 43724b2..5aa0345 100644 --- a/projects/butter-backup/tests/cli/test_cli_commands.py +++ b/projects/butter-backup/tests/cli/test_cli_commands.py @@ -477,7 +477,7 @@ def test_unmount_error_does_not_cause_content_deletion( # This test "successfully" provoked the buggy behaviour before the bug was fixed. mocker.patch( "storage_device_managers.unmount_device", - side_effect=sdm.UnmountError("Mocked unmount error"), + side_effect=sdm.UnmountError("Mocked unmount error", b"Mocked stderr"), ) config = complement_configuration(encrypted_device, tmp_path) diff --git a/projects/shell-interface/src/shell_interface/run_cmd.py b/projects/shell-interface/src/shell_interface/run_cmd.py index e2cd956..52adbaa 100644 --- a/projects/shell-interface/src/shell_interface/run_cmd.py +++ b/projects/shell-interface/src/shell_interface/run_cmd.py @@ -1,6 +1,7 @@ import os import subprocess from collections import abc +from dataclasses import dataclass from pathlib import Path StrPathList = abc.Sequence[str | Path] @@ -10,8 +11,10 @@ class PassCmdError(RuntimeError): pass +@dataclass(frozen=True) class ShellInterfaceError(RuntimeError): - pass + errmsg: str + stderr: bytes | None def run_cmd( @@ -58,7 +61,7 @@ def run_cmd( result = subprocess.run(cmd, capture_output=capture_output, check=True, env=env) except subprocess.CalledProcessError as e: errmsg = f"Shell-Befehl `{cmd}` ist fehlgeschlagen." - raise ShellInterfaceError(errmsg) from e + raise ShellInterfaceError(errmsg, e.stderr) from e return result @@ -112,5 +115,5 @@ def pipe_pass_cmd_to_real_cmd( ) except subprocess.CalledProcessError as e: errmsg = f"Shell-Befehl `{command}` ist fehlgeschlagen." - raise ShellInterfaceError(errmsg) from e + raise ShellInterfaceError(errmsg, e.stderr) from e return completed_process diff --git a/projects/storage-device-managers/src/storage_device_managers/__init__.py b/projects/storage-device-managers/src/storage_device_managers/__init__.py index 3ddf300..6f930dc 100644 --- a/projects/storage-device-managers/src/storage_device_managers/__init__.py +++ b/projects/storage-device-managers/src/storage_device_managers/__init__.py @@ -49,7 +49,7 @@ class InvalidDecryptedDevice(ValueError): pass -class UnmountError(RuntimeError): +class UnmountError(sh.ShellInterfaceError): pass @@ -402,9 +402,9 @@ def unmount_device(device: Path) -> None: sync_device(device) cmd: sh.StrPathList = ["sudo", "umount", device] try: - sh.run_cmd(cmd=cmd) + sh.run_cmd(cmd=cmd, capture_output=True) except sh.ShellInterfaceError as e: - raise UnmountError from e + raise UnmountError(e.errmsg, e.stderr) from e def open_encrypted_device(device: Path, pass_cmd: str) -> Path: diff --git a/projects/storage-device-managers/tests/test_mount_unmount.py b/projects/storage-device-managers/tests/test_mount_unmount.py index 3c39955..884b151 100644 --- a/projects/storage-device-managers/tests/test_mount_unmount.py +++ b/projects/storage-device-managers/tests/test_mount_unmount.py @@ -143,13 +143,26 @@ def test_unmount_device_raises_unmounterror(tmp_path: Path) -> None: sdm.unmount_device(mountpoint) +def test_unmount_device_preserves_shell_error_details(tmp_path: Path) -> None: + mountpoint = tmp_path + with pytest.raises(sdm.UnmountError) as exc_info: + sdm.unmount_device(mountpoint) + + exc = exc_info.value + assert isinstance(exc, sh.ShellInterfaceError) + assert exc.errmsg.startswith("Shell-Befehl `['sudo', 'umount',") + assert str(mountpoint) in exc.errmsg + assert exc.stderr is not None + assert exc.stderr.strip() + + def test_mounted_device_does_not_delete_content_on_umount_error( device_with_fs, compression_kwargs: CompressionKwargsT, mocker ) -> None: device, _ = device_with_fs mocker.patch( "storage_device_managers.unmount_device", - side_effect=sdm.UnmountError("Mocked unmount error"), + side_effect=sdm.UnmountError("Mocked unmount error", b"Mocked stderr"), ) user = sh.get_user() sentinel_text = "This file should not be deleted." From 2cf7107808ea3a26fd6b72561d6b2318486d9533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:13:31 +0200 Subject: [PATCH 2/8] Add comment explaining why close_decrypted_device is skipped on unmount error Co-authored-by: MaxG87 <5477952+MaxG87@users.noreply.github.com> --- projects/butter-backup/src/butter_backup/cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projects/butter-backup/src/butter_backup/cli.py b/projects/butter-backup/src/butter_backup/cli.py index 7067297..3091baa 100644 --- a/projects/butter-backup/src/butter_backup/cli.py +++ b/projects/butter-backup/src/butter_backup/cli.py @@ -228,6 +228,8 @@ def close( f"Speichermedium {cfg.Name} konnte nicht ausgehängt werden: {e}", err=True, ) + # The device is still mounted, so closing the decrypted device + # would fail. Leave it for the user to handle manually. continue sdm.close_decrypted_device(map_name) From 029caf98e579d0b9d051b2b0e460618b71109016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:53:26 +0200 Subject: [PATCH 3/8] refactor: Put failing command in exception directly The receiver of the exception can decide on their own what to do about this. There is no need to formulate the error message at the error site. --- .../shell-interface/src/shell_interface/run_cmd.py | 14 ++++++-------- .../src/storage_device_managers/__init__.py | 4 ++-- .../tests/test_mount_unmount.py | 4 ++-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/projects/shell-interface/src/shell_interface/run_cmd.py b/projects/shell-interface/src/shell_interface/run_cmd.py index 52adbaa..ff9624f 100644 --- a/projects/shell-interface/src/shell_interface/run_cmd.py +++ b/projects/shell-interface/src/shell_interface/run_cmd.py @@ -13,7 +13,7 @@ class PassCmdError(RuntimeError): @dataclass(frozen=True) class ShellInterfaceError(RuntimeError): - errmsg: str + command: StrPathList stderr: bytes | None @@ -60,13 +60,12 @@ def run_cmd( try: result = subprocess.run(cmd, capture_output=capture_output, check=True, env=env) except subprocess.CalledProcessError as e: - errmsg = f"Shell-Befehl `{cmd}` ist fehlgeschlagen." - raise ShellInterfaceError(errmsg, e.stderr) from e + raise ShellInterfaceError(cmd, e.stderr) from e return result def pipe_pass_cmd_to_real_cmd( - pass_cmd: str, command: StrPathList, *, capture_output: bool = False + pass_cmd: str, cmd: StrPathList, *, capture_output: bool = False ) -> subprocess.CompletedProcess[bytes]: """ Pipe result of first command to second command @@ -83,7 +82,7 @@ def pipe_pass_cmd_to_real_cmd( ----------- pass_cmd command to run in shell and whose output is piped to the second command - command + cmd command to run in shell and whose input is piped from the first command capture_output whether to capture the output of the real command; if `True`, the output is @@ -111,9 +110,8 @@ def pipe_pass_cmd_to_real_cmd( raise PassCmdError(errmsg) from e try: completed_process = subprocess.run( - command, input=pwd_proc.stdout, check=True, capture_output=capture_output + cmd, input=pwd_proc.stdout, check=True, capture_output=capture_output ) except subprocess.CalledProcessError as e: - errmsg = f"Shell-Befehl `{command}` ist fehlgeschlagen." - raise ShellInterfaceError(errmsg, e.stderr) from e + raise ShellInterfaceError(cmd, e.stderr) from e return completed_process diff --git a/projects/storage-device-managers/src/storage_device_managers/__init__.py b/projects/storage-device-managers/src/storage_device_managers/__init__.py index 6f930dc..eb56401 100644 --- a/projects/storage-device-managers/src/storage_device_managers/__init__.py +++ b/projects/storage-device-managers/src/storage_device_managers/__init__.py @@ -404,7 +404,7 @@ def unmount_device(device: Path) -> None: try: sh.run_cmd(cmd=cmd, capture_output=True) except sh.ShellInterfaceError as e: - raise UnmountError(e.errmsg, e.stderr) from e + raise UnmountError(e.command, e.stderr) from e def open_encrypted_device(device: Path, pass_cmd: str) -> Path: @@ -510,7 +510,7 @@ def encrypt_device(device: Path, password_cmd: str) -> UUID: str(new_uuid), device, ] - sh.pipe_pass_cmd_to_real_cmd(pass_cmd=password_cmd, command=format_cmd) + sh.pipe_pass_cmd_to_real_cmd(pass_cmd=password_cmd, cmd=format_cmd) return new_uuid diff --git a/projects/storage-device-managers/tests/test_mount_unmount.py b/projects/storage-device-managers/tests/test_mount_unmount.py index 884b151..eb4c689 100644 --- a/projects/storage-device-managers/tests/test_mount_unmount.py +++ b/projects/storage-device-managers/tests/test_mount_unmount.py @@ -150,8 +150,8 @@ def test_unmount_device_preserves_shell_error_details(tmp_path: Path) -> None: exc = exc_info.value assert isinstance(exc, sh.ShellInterfaceError) - assert exc.errmsg.startswith("Shell-Befehl `['sudo', 'umount',") - assert str(mountpoint) in exc.errmsg + assert exc.command[:2] == ["sudo", "umount"] + assert mountpoint in exc.command assert exc.stderr is not None assert exc.stderr.strip() From de9b75b9e6ff02763336812216afe3c44775d459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:02:06 +0200 Subject: [PATCH 4/8] feat: Improve style of error reporting Instead of putting the complete exception to the string, only the STDERR is given now. This reduces the error message to the necessary information. --- projects/butter-backup/src/butter_backup/cli.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/projects/butter-backup/src/butter_backup/cli.py b/projects/butter-backup/src/butter_backup/cli.py index 3091baa..64da253 100644 --- a/projects/butter-backup/src/butter_backup/cli.py +++ b/projects/butter-backup/src/butter_backup/cli.py @@ -153,6 +153,13 @@ def _open_device( typer.echo(f"Speichermedium {cfg.Name} wurde in {mount_dir} geöffnet.") +def _unmount_errmsg(cfg: cp.DeviceConfiguration, e: sdm.UnmountError) -> str: + if e.stderr is None: + return f"Speichermedium {cfg.Name} konnte nicht ausgehängt werden. Es ist keine Fehlermeldung verfügbar." + stderr = e.stderr.decode("utf-8", errors="replace") + return f"Aushängen des Speichermediums {cfg.Name} ist fehlgeschlagen. Die Fehlermeldung ist: {stderr}" + + @app.command("open") def cli_open( config: t.Annotated[Path | None, CONFIG_OPTION] = None, @@ -224,10 +231,7 @@ def close( try: sdm.unmount_device(map_name) except sdm.UnmountError as e: - typer.echo( - f"Speichermedium {cfg.Name} konnte nicht ausgehängt werden: {e}", - err=True, - ) + typer.echo(_unmount_errmsg(cfg, e), err=True) # The device is still mounted, so closing the decrypted device # would fail. Leave it for the user to handle manually. continue @@ -288,10 +292,7 @@ def backup( # close the device. To prevent this, the sudo session is refreshed. sh.refresh_sudo(parsed_config.SudoPassCmd) except sdm.UnmountError as e: - typer.echo( - f"Speichermedium {cfg.Name} konnte nicht ausgehängt werden: {e}", - err=True, - ) + typer.echo(_unmount_errmsg(cfg, e), err=True) had_unmount_error = True if had_unmount_error: raise typer.Exit(1) From 357cbb6e9b888a7cf74875f1866a59fdf41e7d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:31:30 +0200 Subject: [PATCH 5/8] fix: Unfreeze ShellInterfaceError exception dataclass If frozen, the traceback cannot be set, which causes the error handling to crash itself. --- projects/shell-interface/src/shell_interface/run_cmd.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/projects/shell-interface/src/shell_interface/run_cmd.py b/projects/shell-interface/src/shell_interface/run_cmd.py index ff9624f..fd0964c 100644 --- a/projects/shell-interface/src/shell_interface/run_cmd.py +++ b/projects/shell-interface/src/shell_interface/run_cmd.py @@ -11,8 +11,11 @@ class PassCmdError(RuntimeError): pass -@dataclass(frozen=True) +@dataclass(frozen=False) class ShellInterfaceError(RuntimeError): + # Setting frozen=True would prevent updating `__traceback__` in the except blocks, + # causing error handling to crash itself. Therefore frozen=False is used, contrary + # to the usual practice of making dataclasses frozen. command: StrPathList stderr: bytes | None From 04b1fed35a691198edc78a16b9ce0a6916ea4c5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:35:51 +0000 Subject: [PATCH 6/8] Add traceback regression test for ShellInterfaceError Co-authored-by: MaxG87 <5477952+MaxG87@users.noreply.github.com> --- projects/shell-interface/tests/test_run_cmd.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/projects/shell-interface/tests/test_run_cmd.py b/projects/shell-interface/tests/test_run_cmd.py index 9aea0f9..b71b203 100644 --- a/projects/shell-interface/tests/test_run_cmd.py +++ b/projects/shell-interface/tests/test_run_cmd.py @@ -22,6 +22,19 @@ def test_run_cmd_fails() -> None: sh.run_cmd(cmd=["false"]) +def test_shellinterfaceerror_can_be_raised_and_reraised_with_traceback() -> None: + error = sh.ShellInterfaceError(["false"], b"stderr") + + with pytest.raises(sh.ShellInterfaceError) as exc_info: + raise error + assert exc_info.value is error + assert exc_info.value.__traceback__ is not None + + with pytest.raises(sh.ShellInterfaceError) as exc_info: + raise error + assert exc_info.value.__traceback__ is not None + + @given(environment=st.dictionaries(environment_variable_names, echoable_text)) def test_run_cmd_forwards_env(environment: dict[str, str]) -> None: proc = sh.run_cmd(cmd=["env"], capture_output=True, env=environment) From dc137ece0528057d80357798bd39ba228982a3b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:32:12 +0000 Subject: [PATCH 7/8] Fix exception masking in decrypted cleanup and add regressions Co-authored-by: MaxG87 <5477952+MaxG87@users.noreply.github.com> --- .../tests/cli/test_cli_commands.py | 83 +++++++++++++++++++ .../src/storage_device_managers/__init__.py | 10 ++- .../tests/test_device_manager.py | 36 ++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/projects/butter-backup/tests/cli/test_cli_commands.py b/projects/butter-backup/tests/cli/test_cli_commands.py index 5aa0345..43ceb92 100644 --- a/projects/butter-backup/tests/cli/test_cli_commands.py +++ b/projects/butter-backup/tests/cli/test_cli_commands.py @@ -1,6 +1,8 @@ import datetime as dt +import types import re import time +from contextlib import contextmanager from pathlib import Path from tempfile import NamedTemporaryFile from uuid import UUID @@ -503,3 +505,84 @@ def test_unmount_error_does_not_cause_content_deletion( assert result.exit_code == 0 assert mount_of_device.exists() # Target directory should be kept after closing. assert sdm.is_mounted(mount_of_device) is False + + +def test_close_prints_unmount_error_and_keeps_device_open( + runner: CliRunner, mocker +) -> None: + map_name = Path("/dev/mapper/mock-device") + cfg = types.SimpleNamespace( + Name="mock-device", + device=lambda: Path("/tmp/mock-device"), + map_name=lambda: map_name, + ) + parsed = types.SimpleNamespace( + DeviceConfigurations=[cfg], SudoPassCmd=None, OpenDirectory=None + ) + mocker.patch.object(cli, "_read_configuration", return_value=parsed) + mocker.patch.object(Path, "exists", return_value=True) + mocker.patch.object( + cli.sdm, + "get_mounted_devices", + return_value={str(map_name): {Path("/mnt/mock"): frozenset()}}, + ) + mocker.patch.object(cli.sh, "refresh_sudo") + mocker.patch.object( + cli.sdm, + "unmount_device", + side_effect=sdm.UnmountError(["sudo", "umount", map_name], b"Mocked stderr\n"), + ) + close_mock = mocker.patch.object(cli.sdm, "close_decrypted_device") + + result = runner.invoke(app, ["close"]) + + assert result.exit_code == 0 + assert ( + "Aushängen des Speichermediums mock-device ist fehlgeschlagen." in result.stderr + ) + assert "Mocked stderr" in result.stderr + close_mock.assert_not_called() + + +def test_backup_keeps_unmount_error_as_primary_failure( + runner: CliRunner, mocker +) -> None: + @contextmanager + def _failing_mounted_device(*_args, **_kwargs): + yield Path("/mnt/mock") + raise sdm.UnmountError( + ["sudo", "umount", Path("/dev/mapper/mock-device")], b"Mocked stderr\n" + ) + + cfg = types.SimpleNamespace( + Name="mock-device", + DevicePassCmd="echo pw", + device=lambda: Path("/dev/disk/by-uuid/mock-device"), + compression=lambda: None, + ) + parsed = types.SimpleNamespace( + DeviceConfigurations=[cfg], SudoPassCmd=None, OpenDirectory=None + ) + mocker.patch.object(cli, "_read_configuration", return_value=parsed) + mocker.patch.object(cli, "_skip_device", return_value=False) + backend = mocker.Mock() + mocker.patch.object(cli.bb.BackupBackend, "from_config", return_value=backend) + mocker.patch.object(cli.sh, "refresh_sudo") + mocker.patch.object( + cli.sdm, "open_encrypted_device", return_value=Path("/dev/mapper/mock-device") + ) + close_cmd = ["sudo", "cryptsetup", "close", "mock-device"] + mocker.patch.object( + cli.sdm, + "close_decrypted_device", + side_effect=sh.ShellInterfaceError(close_cmd, None), + ) + mocker.patch.object(cli.sdm, "mounted_device", _failing_mounted_device) + + result = runner.invoke(app, ["backup"]) + + assert result.exit_code == 1 + assert ( + "Aushängen des Speichermediums mock-device ist fehlgeschlagen." in result.stderr + ) + assert "Mocked stderr" in result.stderr diff --git a/projects/storage-device-managers/src/storage_device_managers/__init__.py b/projects/storage-device-managers/src/storage_device_managers/__init__.py index eb56401..944f6c7 100644 --- a/projects/storage-device-managers/src/storage_device_managers/__init__.py +++ b/projects/storage-device-managers/src/storage_device_managers/__init__.py @@ -146,7 +146,15 @@ def decrypted_device(device: Path, pass_cmd: str) -> Iterator[Path]: decrypted = open_encrypted_device(device, pass_cmd) try: yield decrypted - finally: + except Exception: + try: + close_decrypted_device(decrypted) + except sh.ShellInterfaceError: + # If cleanup follows another failure (e.g. an unmount error), keep the + # original exception as root cause. + pass + raise + else: close_decrypted_device(decrypted) diff --git a/projects/storage-device-managers/tests/test_device_manager.py b/projects/storage-device-managers/tests/test_device_manager.py index 12cb51a..eae7099 100644 --- a/projects/storage-device-managers/tests/test_device_manager.py +++ b/projects/storage-device-managers/tests/test_device_manager.py @@ -76,6 +76,42 @@ def test_decrypted_device_closes_in_case_of_exception(encrypted_device) -> None: assert not dd.exists() +def test_decrypted_device_preserves_original_exception_when_close_fails(mocker) -> None: + encrypted = Path("/dev/sdz1") + decrypted = Path("/dev/mapper/mock-decrypted") + close_cmd = ["sudo", "cryptsetup", "close", decrypted.name] + mocker.patch( + "storage_device_managers.open_encrypted_device", return_value=decrypted + ) + mocker.patch( + "storage_device_managers.close_decrypted_device", + side_effect=sh.ShellInterfaceError(close_cmd, None), + ) + with ( + pytest.raises(MyCustomTestException), + sdm.decrypted_device(encrypted, "echo pw"), + ): + raise MyCustomTestException + + +def test_decrypted_device_raises_close_error_without_prior_exception(mocker) -> None: + encrypted = Path("/dev/sdz1") + decrypted = Path("/dev/mapper/mock-decrypted") + close_cmd = ["sudo", "cryptsetup", "close", decrypted.name] + mocker.patch( + "storage_device_managers.open_encrypted_device", return_value=decrypted + ) + mocker.patch( + "storage_device_managers.close_decrypted_device", + side_effect=sh.ShellInterfaceError(close_cmd, None), + ) + with ( + pytest.raises(sh.ShellInterfaceError), + sdm.decrypted_device(encrypted, "echo pw"), + ): + pass + + def test_decrypted_device_can_use_home_for_passcmd(encrypted_device) -> None: # Regression Test # Test if `decrypted_device` can use a program that is located in PATH. For From d74317ecc1cdc6d5b897f8c7a010bf5d9c5e2d8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:34:40 +0000 Subject: [PATCH 8/8] Add regressions for unmount cleanup error masking Co-authored-by: MaxG87 <5477952+MaxG87@users.noreply.github.com> --- projects/butter-backup/tests/cli/test_cli_commands.py | 10 ++++++---- .../src/storage_device_managers/__init__.py | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/projects/butter-backup/tests/cli/test_cli_commands.py b/projects/butter-backup/tests/cli/test_cli_commands.py index 43ceb92..5692bb5 100644 --- a/projects/butter-backup/tests/cli/test_cli_commands.py +++ b/projects/butter-backup/tests/cli/test_cli_commands.py @@ -549,10 +549,12 @@ def test_backup_keeps_unmount_error_as_primary_failure( ) -> None: @contextmanager def _failing_mounted_device(*_args, **_kwargs): - yield Path("/mnt/mock") - raise sdm.UnmountError( - ["sudo", "umount", Path("/dev/mapper/mock-device")], b"Mocked stderr\n" - ) + try: + yield Path("/mnt/mock") + finally: + raise sdm.UnmountError( + ["sudo", "umount", Path("/dev/mapper/mock-device")], b"Mocked stderr\n" + ) cfg = types.SimpleNamespace( Name="mock-device", diff --git a/projects/storage-device-managers/src/storage_device_managers/__init__.py b/projects/storage-device-managers/src/storage_device_managers/__init__.py index 944f6c7..aeb072d 100644 --- a/projects/storage-device-managers/src/storage_device_managers/__init__.py +++ b/projects/storage-device-managers/src/storage_device_managers/__init__.py @@ -149,7 +149,7 @@ def decrypted_device(device: Path, pass_cmd: str) -> Iterator[Path]: except Exception: try: close_decrypted_device(decrypted) - except sh.ShellInterfaceError: + except Exception: # If cleanup follows another failure (e.g. an unmount error), keep the # original exception as root cause. pass