From a62911e7ff9c77a74a02c2f2b585aaba7bc8766d Mon Sep 17 00:00:00 2001 From: Mrityunjay Raj Date: Wed, 29 Jul 2026 18:08:30 +0530 Subject: [PATCH] check: handle soft interrupt (Ctrl-C) at safe boundaries, always run finish() (#7893) --- src/borg/archive.py | 30 +++++++++-- src/borg/archiver/check_cmd.py | 6 ++- src/borg/repository.py | 4 ++ src/borg/testsuite/archiver/check_cmd_test.py | 54 +++++++++++++++++++ 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src/borg/archive.py b/src/borg/archive.py index 4af2d47f18..cf5e3c8888 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -32,7 +32,7 @@ from .helpers import BackupOSError, BackupPermissionError, BackupFileNotFoundError, BackupIOError from .helpers import HardLinkManager from .helpers import ChunkIteratorFileWrapper, open_item -from .helpers import Error, IntegrityError, set_ec +from .helpers import Error, IntegrityError, set_ec, sig_int from .platform import uid2user, user2uid, gid2group, group2gid, get_birthtime_ns from .helpers import parse_timestamp, archive_ts_now, CompressionSpec from .helpers import OutputTimestamp, format_timedelta, format_file_size, file_status, FileSize @@ -1879,12 +1879,25 @@ def check( rebuild_manifest = True if rebuild_manifest: self.manifest = self.rebuild_manifest() - if find_lost_archives: + # Skip the remaining scans on Ctrl-C, but still run finish() below. + if find_lost_archives and not sig_int: self.rebuild_archives_directory() - self.rebuild_archives( - match=match, first=first, last=last, sort_by=sort_by, older=older, oldest=oldest, newer=newer, newest=newest - ) + if not sig_int: + self.rebuild_archives( + match=match, + first=first, + last=last, + sort_by=sort_by, + older=older, + oldest=oldest, + newer=newer, + newest=newest, + ) + # finish() drops the chunk index and writes the manifest; run it even on Ctrl-C so --repair + # leaves a valid index (#9850). self.finish() + if sig_int: + raise Error("Got Ctrl-C / SIGINT.") if self.error_found: logger.error("Archive consistency check complete, problems found.") else: @@ -1937,6 +1950,8 @@ def verify_data(self): total=chunks_count, msg="Verifying data %6.2f%%", step=0.01, msgid="check.verify_data" ) for chunk_id, _ in self.chunks.iteritems(): + if sig_int: # stop at a chunk boundary + break pi.show() try: encrypted_data = self.repository.get(chunk_id) @@ -2023,6 +2038,8 @@ def valid_archive(obj): msgid="check.rebuild_archives_directory", ) for chunk_id, _ in self.chunks.iteritems(): + if sig_int: # stop at a chunk boundary + break pi.show() cdata = self.repository.get(chunk_id, read_data=False) # only get metadata try: @@ -2216,6 +2233,9 @@ def valid_item(obj): total=num_archives, msg="Checking archives %3.1f%%", step=0.1, msgid="check.rebuild_archives" ) for i, info in enumerate(archive_infos): + if sig_int: + # Break only between archives: --repair rewrites each archive as a whole below. + break pi.show(i) archive_id, archive_id_hex = info.id, bin_to_hex(info.id) try: diff --git a/src/borg/archiver/check_cmd.py b/src/borg/archiver/check_cmd.py index f5433a898d..1a812a2dc1 100644 --- a/src/borg/archiver/check_cmd.py +++ b/src/borg/archiver/check_cmd.py @@ -3,8 +3,8 @@ from ._common import with_repository, Highlander from ..archive import ArchiveChecker from ..constants import * # NOQA -from ..helpers import set_ec, EXIT_WARNING, CancelledByUser, CommandError, IntegrityError -from ..helpers import yes, ArchiveFormatter, use_iec_units +from ..helpers import set_ec, EXIT_WARNING, CancelledByUser, CommandError, Error, IntegrityError +from ..helpers import yes, ArchiveFormatter, use_iec_units, sig_int from ..helpers.argparsing import ArgumentParser from ..logger import create_logger @@ -66,6 +66,8 @@ def do_check(self, args, repository): if not args.archives_only: if not repository.check(repair=args.repair, max_duration=args.max_duration): set_ec(EXIT_WARNING) + if sig_int: # repository check interrupted; skip the archive check + raise Error("Got Ctrl-C / SIGINT.") if not args.repo_only and not archive_checker.check( repository, verify_data=args.verify_data, diff --git a/src/borg/repository.py b/src/borg/repository.py index 22af077221..8dc8bf2a1d 100644 --- a/src/borg/repository.py +++ b/src/borg/repository.py @@ -813,6 +813,10 @@ def store_list(namespace): pack_infos = store_list("packs") pack_pi = ProgressIndicatorPercent(total=len(pack_infos), msg="Checking packs %3.0f%%", msgid="check.packs") for info in pack_infos: + if sig_int: # save progress so a later check resumes, then stop + logger.info(f"Interrupted repository check, {len(tracker)} packs checked so far.") + tracker.save() + break self._lock_refresh() pack_pi.show(increase=1) # advance for skipped packs too, so the bar tracks packs/, not work done pack_id = hex_to_bin(info.name) diff --git a/src/borg/testsuite/archiver/check_cmd_test.py b/src/borg/testsuite/archiver/check_cmd_test.py index 1e91ea84ba..e77207cf7a 100644 --- a/src/borg/testsuite/archiver/check_cmd_test.py +++ b/src/borg/testsuite/archiver/check_cmd_test.py @@ -65,6 +65,60 @@ def test_check_usage(archivers, request): assert "archive2" in output +def test_check_soft_interrupt(archivers, request): + """A Ctrl-C during a read-only check stops at a safe boundary and raises 'Got Ctrl-C' (#7893). + It changes nothing, so a normal check still passes afterwards.""" + from ...archive import ArchiveChecker + from ...helpers import sig_int, Error + + archiver = request.getfixturevalue(archivers) + check_cmd_setup(archiver) + + try: + with Repository(archiver.repository_path, exclusive=True) as repository: + # repository check: stops at a pack boundary, still reports no errors. + sig_int._sig_int_triggered = True + assert repository.check() is True + with Repository(archiver.repository_path, exclusive=True) as repository: + # archive check: runs finish(), then raises. + sig_int._sig_int_triggered = True + with pytest.raises(Error, match="Got Ctrl-C"): + ArchiveChecker().check(repository, verify_data=True, sort_by="ts", format="{archive} {time} {id}") + finally: + sig_int._sig_int_triggered = False # reset the global flag for the following tests + + cmd(archiver, "check", exit_code=0) + + +def test_check_repair_soft_interrupt(archivers, request, monkeypatch): + """A Ctrl-C after the first archive of a --repair archive check stops at the archive boundary, runs + finish() (dropping the chunk index, writing the manifest), then raises. A later check confirms the + repository is still consistent.""" + from ...archive import ArchiveChecker + from ...manifest import Archives + from ...helpers import sig_int, Error + + archiver = request.getfixturevalue(archivers) + check_cmd_setup(archiver) # two archives + + orig_create = Archives.create + + def create_then_interrupt(self, *args, **kwargs): + orig_create(self, *args, **kwargs) + sig_int._sig_int_triggered = True # one Ctrl-C after the first archive was rebuilt + + monkeypatch.setattr(Archives, "create", create_then_interrupt) + try: + with Repository(archiver.repository_path, exclusive=True) as repository: + with pytest.raises(Error, match="Got Ctrl-C"): + ArchiveChecker().check(repository, repair=True, sort_by="ts", format="{archive} {time} {id}") + finally: + sig_int._sig_int_triggered = False # reset the global flag for the following tests + + # a normal check does not rebuild archives, so the patched Archives.create never fires here + cmd(archiver, "check", exit_code=0) + + def test_date_matching(archivers, request): archiver = request.getfixturevalue(archivers) check_cmd_setup(archiver)