From 95d0a19f1f860c016b14981fc148411b1b9f1228 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 12 Oct 2025 21:21:36 -0700 Subject: [PATCH 1/2] Handle broken pipe when destination is full --- rsync_time_machine.py | 17 ++++++++-- tests/test_app.py | 77 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/rsync_time_machine.py b/rsync_time_machine.py index f1ff1f7..ef50a4f 100755 --- a/rsync_time_machine.py +++ b/rsync_time_machine.py @@ -694,7 +694,7 @@ def deal_with_no_space_left( auto_expire: bool, ) -> bool: """Deal with no space left on device.""" - with open(log_file) as f: + with open(log_file, encoding="utf-8", errors="replace") as f: log_data = f.read() no_space_left = re.search( @@ -702,6 +702,19 @@ def deal_with_no_space_left( log_data, ) + if not no_space_left and re.search(r"rsync: \[sender\] write error: Broken pipe \(32\)", log_data): + df_result = run_cmd(f"df -Pk '{dest_folder}'", dest_is_ssh(ssh)) + if df_result.returncode == 0: + lines = df_result.stdout.splitlines() + if len(lines) > 1: + try: + available_blocks = int(lines[1].split()[3]) + except (IndexError, ValueError): + pass + else: + if available_blocks <= 0: + no_space_left = True + if no_space_left: if not auto_expire: log_error( @@ -727,7 +740,7 @@ def check_rsync_errors( auto_delete_log: bool, # noqa: FBT001 ) -> None: """Check rsync errors.""" - with open(log_file) as f: + with open(log_file, encoding="utf-8", errors="replace") as f: log_data = f.read() if "rsync error:" in log_data: log_error( diff --git a/tests/test_app.py b/tests/test_app.py index e9133c8..d3e5e57 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -18,6 +18,7 @@ backup, backup_marker_path, check_dest_is_backup_folder, + deal_with_no_space_left, expire_backups, find, find_backup_marker, @@ -199,6 +200,82 @@ def test_find_backup_marker(tmp_path: Path) -> None: assert find_backup_marker(str(tmp_path), None) == marker_path +def test_deal_with_no_space_left_handles_broken_pipe_when_dest_full( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Broken pipe with a full destination should trigger expiration.""" + log_file = tmp_path / "2025-10-12-212400.log" + log_file.write_text("rsync: [sender] write error: Broken pipe (32)\n", encoding="utf-8") + + backups = [ + "/dest/2025-10-11-120000", + "/dest/2025-10-12-120000", + ] + + monkeypatch.setattr(rsync_time_machine, "find_backups", Mock(return_value=backups)) + + expired: list[str] = [] + monkeypatch.setattr( + rsync_time_machine, + "expire_backup", + lambda path, ssh: expired.append(path), + ) + + def fake_run_cmd(cmd: str, ssh: rsync_time_machine.SSH | None = None) -> rsync_time_machine.CmdResult: + if cmd.startswith("df -Pk"): + df_output = ( + "Filesystem 1024-blocks Used Available Capacity Mounted on\n" + "/dev/sda1 100 100 0 100% /dest\n" + ) + return rsync_time_machine.CmdResult(df_output, "", 0) + return rsync_time_machine.CmdResult("", "", 0) + + monkeypatch.setattr(rsync_time_machine, "run_cmd", fake_run_cmd) + + should_retry = deal_with_no_space_left( + str(log_file), + "/dest", + ssh=None, + auto_expire=True, + ) + + assert should_retry is True + assert expired == [sorted(backups)[-1]] + + +def test_deal_with_no_space_left_ignores_broken_pipe_when_space_available( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Broken pipe without the destination filling up should not expire backups.""" + log_file = tmp_path / "2025-10-12-212400.log" + log_file.write_text("rsync: [sender] write error: Broken pipe (32)\n", encoding="utf-8") + + monkeypatch.setattr(rsync_time_machine, "find_backups", Mock(return_value=["/dest/2025-10-12-120000", "/dest/2025-10-11-120000"])) + monkeypatch.setattr(rsync_time_machine, "expire_backup", Mock()) + + def fake_run_cmd(cmd: str, ssh: rsync_time_machine.SSH | None = None) -> rsync_time_machine.CmdResult: + if cmd.startswith("df -Pk"): + df_output = ( + "Filesystem 1024-blocks Used Available Capacity Mounted on\n" + "/dev/sda1 100 58 42 58% /dest\n" + ) + return rsync_time_machine.CmdResult(df_output, "", 0) + return rsync_time_machine.CmdResult("", "", 0) + + monkeypatch.setattr(rsync_time_machine, "run_cmd", fake_run_cmd) + + should_retry = deal_with_no_space_left( + str(log_file), + "/dest", + ssh=None, + auto_expire=True, + ) + + assert should_retry is False + + def test_run_cmd() -> None: """Test the run_cmd function.""" result = run_cmd("echo 'Hello, World!'") From eb6a0b850b75c4d2de069ca0f2e31efacee6746c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 15:22:55 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- rsync_time_machine.py | 4 +++- tests/test_app.py | 22 +++++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/rsync_time_machine.py b/rsync_time_machine.py index ef50a4f..ef8113f 100755 --- a/rsync_time_machine.py +++ b/rsync_time_machine.py @@ -702,7 +702,9 @@ def deal_with_no_space_left( log_data, ) - if not no_space_left and re.search(r"rsync: \[sender\] write error: Broken pipe \(32\)", log_data): + if not no_space_left and re.search( + r"rsync: \[sender\] write error: Broken pipe \(32\)", log_data, + ): df_result = run_cmd(f"df -Pk '{dest_folder}'", dest_is_ssh(ssh)) if df_result.returncode == 0: lines = df_result.stdout.splitlines() diff --git a/tests/test_app.py b/tests/test_app.py index d3e5e57..5232249 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -206,7 +206,9 @@ def test_deal_with_no_space_left_handles_broken_pipe_when_dest_full( ) -> None: """Broken pipe with a full destination should trigger expiration.""" log_file = tmp_path / "2025-10-12-212400.log" - log_file.write_text("rsync: [sender] write error: Broken pipe (32)\n", encoding="utf-8") + log_file.write_text( + "rsync: [sender] write error: Broken pipe (32)\n", encoding="utf-8", + ) backups = [ "/dest/2025-10-11-120000", @@ -222,7 +224,9 @@ def test_deal_with_no_space_left_handles_broken_pipe_when_dest_full( lambda path, ssh: expired.append(path), ) - def fake_run_cmd(cmd: str, ssh: rsync_time_machine.SSH | None = None) -> rsync_time_machine.CmdResult: + def fake_run_cmd( + cmd: str, ssh: rsync_time_machine.SSH | None = None, + ) -> rsync_time_machine.CmdResult: if cmd.startswith("df -Pk"): df_output = ( "Filesystem 1024-blocks Used Available Capacity Mounted on\n" @@ -250,12 +254,20 @@ def test_deal_with_no_space_left_ignores_broken_pipe_when_space_available( ) -> None: """Broken pipe without the destination filling up should not expire backups.""" log_file = tmp_path / "2025-10-12-212400.log" - log_file.write_text("rsync: [sender] write error: Broken pipe (32)\n", encoding="utf-8") + log_file.write_text( + "rsync: [sender] write error: Broken pipe (32)\n", encoding="utf-8", + ) - monkeypatch.setattr(rsync_time_machine, "find_backups", Mock(return_value=["/dest/2025-10-12-120000", "/dest/2025-10-11-120000"])) + monkeypatch.setattr( + rsync_time_machine, + "find_backups", + Mock(return_value=["/dest/2025-10-12-120000", "/dest/2025-10-11-120000"]), + ) monkeypatch.setattr(rsync_time_machine, "expire_backup", Mock()) - def fake_run_cmd(cmd: str, ssh: rsync_time_machine.SSH | None = None) -> rsync_time_machine.CmdResult: + def fake_run_cmd( + cmd: str, ssh: rsync_time_machine.SSH | None = None, + ) -> rsync_time_machine.CmdResult: if cmd.startswith("df -Pk"): df_output = ( "Filesystem 1024-blocks Used Available Capacity Mounted on\n"