From eaef6ac879a58b5db251d10c5ff62478bb386634 Mon Sep 17 00:00:00 2001 From: Mrityunjay Raj Date: Wed, 29 Jul 2026 18:08:30 +0530 Subject: [PATCH 1/4] 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 271118ac86..c4516f0644 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 @@ -1929,12 +1929,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: @@ -1987,6 +2000,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) @@ -2083,6 +2098,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: @@ -2326,6 +2343,9 @@ def valid_item(obj): # badly damaged repo does not throw away everything it already found. try: 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 682f3a9279..b2037abff2 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 +from ..helpers import set_ec, EXIT_WARNING, CancelledByUser, CommandError, Error, IntegrityError +from ..helpers import yes, ArchiveFormatter, 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 3bb3e3ed4a..900eede627 100644 --- a/src/borg/repository.py +++ b/src/borg/repository.py @@ -1019,6 +1019,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 64c3fc990f..767b1c4ebb 100644 --- a/src/borg/testsuite/archiver/check_cmd_test.py +++ b/src/borg/testsuite/archiver/check_cmd_test.py @@ -75,6 +75,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) From 566f643aad379bc74bf13bed6c368182398d3d8c Mon Sep 17 00:00:00 2001 From: Mrityunjay Raj Date: Wed, 5 Aug 2026 18:08:08 +0530 Subject: [PATCH 2/4] check: report honest status on Ctrl-C interrupt verify_data() now logs how many chunks it actually verified and an interrupted variant of its summary; Repository.check() and rebuild_archives_directory() likewise report interruption instead of success/completion when stopped by SIGINT. Document the SIGINT contract in the check epilog and rework the soft-interrupt tests to interrupt mid-run and assert the resulting state (persisted pack-check progress, both archives surviving a --repair interrupt, a second --repair finishing the job). --- src/borg/archive.py | 41 +++++--- src/borg/archiver/check_cmd.py | 8 ++ src/borg/repository.py | 7 +- src/borg/testsuite/archiver/check_cmd_test.py | 94 +++++++++++++------ 4 files changed, 105 insertions(+), 45 deletions(-) diff --git a/src/borg/archive.py b/src/borg/archive.py index c4516f0644..7e297274b4 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -1929,10 +1929,10 @@ def check( rebuild_manifest = True if rebuild_manifest: self.manifest = self.rebuild_manifest() - # Skip the remaining scans on Ctrl-C, but still run finish() below. - if find_lost_archives and not sig_int: - self.rebuild_archives_directory() + # On Ctrl-C, skip the remaining scans. if not sig_int: + if find_lost_archives: + self.rebuild_archives_directory() self.rebuild_archives( match=match, first=first, @@ -1943,8 +1943,7 @@ def check( 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). + # finish() writes the manifest and a consistent chunk index; run it on Ctrl-C too (#9850). self.finish() if sig_int: raise Error("Got Ctrl-C / SIGINT.") @@ -1995,14 +1994,16 @@ def verify_data(self): logger.info("Starting cryptographic data integrity verification...") chunks_count = len(self.chunks) errors = 0 + verified = 0 # chunks actually verified defect_chunks = [] pi = ProgressIndicatorPercent( 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 + if sig_int: break pi.show() + verified += 1 try: encrypted_data = self.repository.get(chunk_id) except (Repository.ObjectNotFound, IntegrityErrorBase) as err: @@ -2058,11 +2059,20 @@ def verify_data(self): for defect_chunk in defect_chunks: logger.debug("chunk %s is defect.", bin_to_hex(defect_chunk)) log = logger.error if errors else logger.info - log( - "Finished cryptographic data integrity verification, verified %d chunks with %d integrity errors.", - chunks_count, - errors, - ) + if sig_int: + log( + "Interrupted cryptographic data integrity verification, " + "verified %d of %d chunks with %d integrity errors.", + verified, + chunks_count, + errors, + ) + else: + log( + "Finished cryptographic data integrity verification, verified %d chunks with %d integrity errors.", + verified, + errors, + ) def rebuild_manifest(self): """Rebuild the manifest object.""" @@ -2098,7 +2108,7 @@ def valid_archive(obj): msgid="check.rebuild_archives_directory", ) for chunk_id, _ in self.chunks.iteritems(): - if sig_int: # stop at a chunk boundary + if sig_int: break pi.show() cdata = self.repository.get(chunk_id, read_data=False) # only get metadata @@ -2145,7 +2155,10 @@ def valid_archive(obj): logger.warning(f"Would create archives directory entry for {name} {archive_id_hex}.") pi.finish() - logger.info("Rebuilding missing archives directory entries completed.") + if sig_int: + logger.info("Rebuilding missing archives directory entries interrupted.") + else: + logger.info("Rebuilding missing archives directory entries completed.") def rebuild_archives( self, first=0, last=0, sort_by="", match=None, older=None, newer=None, oldest=None, newest=None @@ -2344,7 +2357,7 @@ def valid_item(obj): try: for i, info in enumerate(archive_infos): if sig_int: - # Break only between archives: --repair rewrites each archive as a whole below. + # Break only between archives, as --repair rewrites each archive as a whole. break pi.show(i) archive_id, archive_id_hex = info.id, bin_to_hex(info.id) diff --git a/src/borg/archiver/check_cmd.py b/src/borg/archiver/check_cmd.py index b2037abff2..5800b3175e 100644 --- a/src/borg/archiver/check_cmd.py +++ b/src/borg/archiver/check_cmd.py @@ -162,6 +162,14 @@ def build_parser_check(self, subparsers, common_parser, mid_common_parser): formatted by giving a custom format using ``--format`` (see the ``borg repo-list`` description for more details about the format string). + If the ``borg check`` process receives a SIGINT signal (Ctrl-C), it stops at the + next safe boundary (a pack boundary during the repository check, an archive boundary + during the archive check), leaving the repository and its chunk index in a consistent + state. A partial repository check (``--max-duration``) saves its progress so a later + partial check resumes where it stopped; a full check restarts from the beginning. + With ``--repair``, an interrupted archive check may leave some archives already + repaired and others not yet processed, so run ``borg check --repair`` again to finish. + About repair mode +++++++++++++++++ diff --git a/src/borg/repository.py b/src/borg/repository.py index 900eede627..02667de446 100644 --- a/src/borg/repository.py +++ b/src/borg/repository.py @@ -1019,7 +1019,7 @@ 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 + if sig_int: # on Ctrl-C, persist checked packs, then stop logger.info(f"Interrupted repository check, {len(tracker)} packs checked so far.") tracker.save() break @@ -1059,7 +1059,10 @@ def store_list(namespace): f"Checked {index_files} index files ({index_errors} errors) and {pack_files} packs ({pack_errors} errors)." ) if objs_errors == 0: - logger.info(f"Finished {mode} repository check, no problems found.") + if sig_int: + logger.info(f"Interrupted {mode} repository check, no problems found so far.") + else: + logger.info(f"Finished {mode} repository check, no problems found.") elif repair: logger.error(f"Finished {mode} repository check, errors found (repository repair not implemented).") else: diff --git a/src/borg/testsuite/archiver/check_cmd_test.py b/src/borg/testsuite/archiver/check_cmd_test.py index 767b1c4ebb..41967cea04 100644 --- a/src/borg/testsuite/archiver/check_cmd_test.py +++ b/src/borg/testsuite/archiver/check_cmd_test.py @@ -6,11 +6,11 @@ import pytest -from ...archive import ChunkBuffer, ArchiveChecker +from ...archive import ArchiveChecker, ChunkBuffer from ...constants import * # NOQA -from ...helpers import bin_to_hex, msgpack, CommandError, IntegrityError -from ...manifest import Manifest -from ...repository import Repository +from ...helpers import bin_to_hex, msgpack, CommandError, Error, IntegrityError, sig_int +from ...manifest import Archives, Manifest +from ...repository import PackTracker, Repository from ..repository_test import fchunk, corrupt_chunk_on_disk from . import ( cmd, @@ -75,39 +75,67 @@ 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 - +def test_check_soft_interrupt(archivers, request, monkeypatch): + """A mid-run Ctrl-C stops both check phases at a safe boundary (#7893): the repository check persists + its progress for a later partial check to resume, the archive check runs finish() and then raises. + The check is read-only, so a normal check still passes afterwards.""" 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 + check_cmd_setup(archiver) # produces many packs + + # repository check: interrupt after a few packs. + with Repository(archiver.repository_path, exclusive=True) as repository: + orig_hash = repository.store.hash + hash_calls = 0 + + def hash_then_interrupt(key): + nonlocal hash_calls + hash_calls += 1 + result = orig_hash(key) + if hash_calls == 5: # trip mid-run, after several packs + sig_int._sig_int_triggered = True + return result + + monkeypatch.setattr(repository.store, "hash", hash_then_interrupt) + try: + assert repository.check() is True # interrupted, no errors found + finally: + sig_int._sig_int_triggered = False + assert len(PackTracker.load(repository.store)) > 0 # the verified packs were persisted + + # a partial check resumes the saved cycle. + output = cmd(archiver, "check", "-v", "--repository-only", "--max-duration=600", exit_code=0) + assert "Continuing check cycle" in output + + # archive check: interrupt verify_data after 3 chunks. + with Repository(archiver.repository_path, exclusive=True) as repository: + orig_get = repository.get + get_calls = 0 + interrupted_after = None + + def get_then_interrupt(*args, **kwargs): + nonlocal get_calls, interrupted_after + get_calls += 1 + if get_calls == 3: # trip mid-loop, after 3 chunks + sig_int._sig_int_triggered = True + interrupted_after = get_calls + return orig_get(*args, **kwargs) + + monkeypatch.setattr(repository, "get", get_then_interrupt) + try: 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 + finally: + sig_int._sig_int_triggered = False + assert interrupted_after == 3 # the loop stopped mid-run, after verifying 3 chunks + # nothing changed, so a normal check passes. 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 - + finish() (dropping the chunk index, writing the manifest), then raises. No archive is lost, and a + second --repair finishes the job so a following check reports the repository consistent.""" archiver = request.getfixturevalue(archivers) check_cmd_setup(archiver) # two archives @@ -124,8 +152,16 @@ def create_then_interrupt(self, *args, **kwargs): 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 + # restore the real method; monkeypatch.undo() would also drop the autouse env (BORG_TESTONLY_WEAKEN_KDF). + monkeypatch.setattr(Archives, "create", orig_create) + + # both archives survive the interrupt between archives. + output = cmd(archiver, "repo-list", exit_code=0) + assert "archive1" in output + assert "archive2" in output - # a normal check does not rebuild archives, so the patched Archives.create never fires here + # a second --repair finishes the job; a plain check then finds no problems. + cmd(archiver, "check", "--repair", exit_code=0) cmd(archiver, "check", exit_code=0) From e848f1f98ea80b8cdae9e6e54e501b7aa3e96178 Mon Sep 17 00:00:00 2001 From: Mrityunjay Raj Date: Thu, 6 Aug 2026 04:43:57 +0530 Subject: [PATCH 3/4] check: honest interrupt summary, precise SIGINT docs, align tests - archive check logs an interrupted summary (reflecting whether problems were found) before raising, instead of dropping the summary line. - epilog: scope the not-yet-interruptible note to the archive check's chunk-index rebuild and key recovery, not the whole --repair run. - repository.check(): note what the return value means on interrupt. - soft-interrupt tests trip after the first pack (packs namespace only) and assert persisted state, matching test_compact_soft_interrupt. --- src/borg/archive.py | 4 ++++ src/borg/archiver/check_cmd.py | 5 +++++ src/borg/repository.py | 1 + src/borg/testsuite/archiver/check_cmd_test.py | 22 +++++++++---------- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/borg/archive.py b/src/borg/archive.py index 7e297274b4..337b297394 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -1946,6 +1946,10 @@ def check( # finish() writes the manifest and a consistent chunk index; run it on Ctrl-C too (#9850). self.finish() if sig_int: + if self.error_found: + logger.error("Archive consistency check interrupted, problems found so far.") + else: + logger.info("Archive consistency check interrupted, no problems found so far.") raise Error("Got Ctrl-C / SIGINT.") if self.error_found: logger.error("Archive consistency check complete, problems found.") diff --git a/src/borg/archiver/check_cmd.py b/src/borg/archiver/check_cmd.py index 5800b3175e..6a3092e3f2 100644 --- a/src/borg/archiver/check_cmd.py +++ b/src/borg/archiver/check_cmd.py @@ -170,6 +170,11 @@ def build_parser_check(self, subparsers, common_parser, mid_common_parser): With ``--repair``, an interrupted archive check may leave some archives already repaired and others not yet processed, so run ``borg check --repair`` again to finish. + During a ``--repair`` run, the archive check first rebuilds the chunk index from the + packs, and, if the key must be recovered, scans chunks for it. These phases do not yet + respond to SIGINT, so on a large repository a Ctrl-C during them may appear to have no + effect until they finish. + About repair mode +++++++++++++++++ diff --git a/src/borg/repository.py b/src/borg/repository.py index 02667de446..ec88a65f3e 100644 --- a/src/borg/repository.py +++ b/src/borg/repository.py @@ -1067,6 +1067,7 @@ def store_list(namespace): logger.error(f"Finished {mode} repository check, errors found (repository repair not implemented).") else: logger.error(f"Finished {mode} repository check, errors found.") + # True means the checked objects were clean; on Ctrl-C that covers only the packs seen so far. return objs_errors == 0 or repair def list(self, limit=None, marker=None): diff --git a/src/borg/testsuite/archiver/check_cmd_test.py b/src/borg/testsuite/archiver/check_cmd_test.py index 41967cea04..5d5c21cd4a 100644 --- a/src/borg/testsuite/archiver/check_cmd_test.py +++ b/src/borg/testsuite/archiver/check_cmd_test.py @@ -77,30 +77,30 @@ def test_check_usage(archivers, request): def test_check_soft_interrupt(archivers, request, monkeypatch): """A mid-run Ctrl-C stops both check phases at a safe boundary (#7893): the repository check persists - its progress for a later partial check to resume, the archive check runs finish() and then raises. - The check is read-only, so a normal check still passes afterwards.""" + its checked packs for a later partial check to resume, and the archive check runs finish() and then + raises. The check is read-only, so a normal check still passes afterwards.""" archiver = request.getfixturevalue(archivers) check_cmd_setup(archiver) # produces many packs - # repository check: interrupt after a few packs. + # repository check: interrupt after the first pack. with Repository(archiver.repository_path, exclusive=True) as repository: orig_hash = repository.store.hash - hash_calls = 0 + pack_checks = [] def hash_then_interrupt(key): - nonlocal hash_calls - hash_calls += 1 result = orig_hash(key) - if hash_calls == 5: # trip mid-run, after several packs - sig_int._sig_int_triggered = True + if key.startswith("packs/"): # count pack checks, not the index files hashed first + pack_checks.append(key) + if len(pack_checks) == 1: # one Ctrl-C after the first pack is checked + sig_int._sig_int_triggered = True return result monkeypatch.setattr(repository.store, "hash", hash_then_interrupt) try: - assert repository.check() is True # interrupted, no errors found + repository.check() finally: sig_int._sig_int_triggered = False - assert len(PackTracker.load(repository.store)) > 0 # the verified packs were persisted + assert len(PackTracker.load(repository.store)) == 1 # the pack checked before the break persisted # a partial check resumes the saved cycle. output = cmd(archiver, "check", "-v", "--repository-only", "--max-duration=600", exit_code=0) @@ -126,7 +126,7 @@ def get_then_interrupt(*args, **kwargs): ArchiveChecker().check(repository, verify_data=True, sort_by="ts", format="{archive} {time} {id}") finally: sig_int._sig_int_triggered = False - assert interrupted_after == 3 # the loop stopped mid-run, after verifying 3 chunks + assert interrupted_after == 3 # the loop stopped after verifying 3 chunks # nothing changed, so a normal check passes. cmd(archiver, "check", exit_code=0) From 5257426e533186e66f114aaece137381eecaed50 Mon Sep 17 00:00:00 2001 From: Mrityunjay Raj Date: Thu, 6 Aug 2026 19:43:24 +0530 Subject: [PATCH 4/4] check: honest interrupt summary in all branches, precise boundaries, cover skip Repository.check() only reported "Interrupted" when no problems were found; the error branches still logged "Finished" on Ctrl-C. Thread the interrupted tense and "so far" through all three branches so an interrupted check that also found errors is reported honestly. Split the archive-check sig_int guard so a Ctrl-C during rebuild_archives_directory skips rebuild_archives instead of entering it and listing the archives before breaking at the first one. Reword the epilog to name the actual stop boundaries: after the current pack for the repository check, after the current chunk for --verify-data and --find-lost-archives, and between whole archives for a --repair archive check. Assert get_calls == 3 instead of the tautological interrupted_after == 3, and add a test that drives borg check through do_check so an interrupted repository check skips the archive check. --- src/borg/archive.py | 6 +-- src/borg/archiver/check_cmd.py | 14 +++--- src/borg/repository.py | 13 +++-- src/borg/testsuite/archiver/check_cmd_test.py | 50 +++++++++++++++++-- 4 files changed, 63 insertions(+), 20 deletions(-) diff --git a/src/borg/archive.py b/src/borg/archive.py index 337b297394..55dcaed62b 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -1929,10 +1929,10 @@ def check( rebuild_manifest = True if rebuild_manifest: self.manifest = self.rebuild_manifest() - # On Ctrl-C, skip the remaining scans. + # On Ctrl-C, skip any scan not yet started; a scan already running stops at its own boundary. + if find_lost_archives and not sig_int: + self.rebuild_archives_directory() if not sig_int: - if find_lost_archives: - self.rebuild_archives_directory() self.rebuild_archives( match=match, first=first, diff --git a/src/borg/archiver/check_cmd.py b/src/borg/archiver/check_cmd.py index 6a3092e3f2..28e64f0e16 100644 --- a/src/borg/archiver/check_cmd.py +++ b/src/borg/archiver/check_cmd.py @@ -163,12 +163,14 @@ def build_parser_check(self, subparsers, common_parser, mid_common_parser): description for more details about the format string). If the ``borg check`` process receives a SIGINT signal (Ctrl-C), it stops at the - next safe boundary (a pack boundary during the repository check, an archive boundary - during the archive check), leaving the repository and its chunk index in a consistent - state. A partial repository check (``--max-duration``) saves its progress so a later - partial check resumes where it stopped; a full check restarts from the beginning. - With ``--repair``, an interrupted archive check may leave some archives already - repaired and others not yet processed, so run ``borg check --repair`` again to finish. + next safe boundary, leaving the repository and its chunk index in a consistent state. + The repository check stops after the current pack; ``--verify-data`` and + ``--find-lost-archives`` stop after the current chunk; a ``--repair`` archive check + stops between whole archives. A partial repository check (``--max-duration``) saves its + progress so a later partial check resumes where it stopped; a full check restarts from + the beginning. With ``--repair``, an interrupted archive check may leave some archives + already repaired and others not yet processed, so run ``borg check --repair`` again to + finish. During a ``--repair`` run, the archive check first rebuilds the chunk index from the packs, and, if the key must be recovered, scans chunks for it. These phases do not yet diff --git a/src/borg/repository.py b/src/borg/repository.py index ec88a65f3e..a4279240e0 100644 --- a/src/borg/repository.py +++ b/src/borg/repository.py @@ -1058,16 +1058,15 @@ def store_list(namespace): logger.info( f"Checked {index_files} index files ({index_errors} errors) and {pack_files} packs ({pack_errors} errors)." ) + # On Ctrl-C the check stopped early, so the summary only covers the packs seen so far. + done, so_far = ("Interrupted", " so far") if sig_int else ("Finished", "") if objs_errors == 0: - if sig_int: - logger.info(f"Interrupted {mode} repository check, no problems found so far.") - else: - logger.info(f"Finished {mode} repository check, no problems found.") + logger.info(f"{done} {mode} repository check, no problems found{so_far}.") elif repair: - logger.error(f"Finished {mode} repository check, errors found (repository repair not implemented).") + logger.error(f"{done} {mode} repository check, errors found{so_far} (repository repair not implemented).") else: - logger.error(f"Finished {mode} repository check, errors found.") - # True means the checked objects were clean; on Ctrl-C that covers only the packs seen so far. + logger.error(f"{done} {mode} repository check, errors found{so_far}.") + # True means the checked objects were clean; --repair returns True so the caller proceeds to fix them. return objs_errors == 0 or repair def list(self, limit=None, marker=None): diff --git a/src/borg/testsuite/archiver/check_cmd_test.py b/src/borg/testsuite/archiver/check_cmd_test.py index 5d5c21cd4a..9e847ce79f 100644 --- a/src/borg/testsuite/archiver/check_cmd_test.py +++ b/src/borg/testsuite/archiver/check_cmd_test.py @@ -110,14 +110,12 @@ def hash_then_interrupt(key): with Repository(archiver.repository_path, exclusive=True) as repository: orig_get = repository.get get_calls = 0 - interrupted_after = None def get_then_interrupt(*args, **kwargs): - nonlocal get_calls, interrupted_after + nonlocal get_calls get_calls += 1 if get_calls == 3: # trip mid-loop, after 3 chunks sig_int._sig_int_triggered = True - interrupted_after = get_calls return orig_get(*args, **kwargs) monkeypatch.setattr(repository, "get", get_then_interrupt) @@ -126,7 +124,8 @@ def get_then_interrupt(*args, **kwargs): ArchiveChecker().check(repository, verify_data=True, sort_by="ts", format="{archive} {time} {id}") finally: sig_int._sig_int_triggered = False - assert interrupted_after == 3 # the loop stopped after verifying 3 chunks + # verify_data breaks at the chunk it interrupted on, and the skipped scans issue no more get()s. + assert get_calls == 3 # nothing changed, so a normal check passes. cmd(archiver, "check", exit_code=0) @@ -165,6 +164,49 @@ def create_then_interrupt(self, *args, **kwargs): cmd(archiver, "check", exit_code=0) +def test_check_interrupt_skips_archive_check(archivers, request, monkeypatch): + """A Ctrl-C during the repository check makes a full `borg check` skip the archive check. do_check + raises at the sig_int guard, which sits before the archive_checker.check() call, so the raise itself + is the skip. Exercises the do_check path (the other soft-interrupt tests call check() directly).""" + archiver = request.getfixturevalue(archivers) + if archiver.EXE: # a class-level monkeypatch cannot reach the borg.exe subprocess + pytest.skip("in-process store patch does not apply to the binary") + check_cmd_setup(archiver) # produces many packs + + from borgstore.store import Store + + orig_hash = Store.hash + pack_checks = [] + + def hash_then_interrupt(self, key): + result = orig_hash(self, key) + if key.startswith("packs/"): # count pack checks, not the index files hashed first + pack_checks.append(key) + if len(pack_checks) == 1: # one Ctrl-C after the first pack is checked + sig_int._sig_int_triggered = True + return result + + # spy on the archive check: "Got Ctrl-C" is also raised inside ArchiveChecker.check(), so matching the + # message alone would not prove the skip. Recording that check() never runs is the load-bearing assertion. + orig_check = ArchiveChecker.check + archive_check_ran = False + + def spy_check(self, *args, **kwargs): + nonlocal archive_check_ran + archive_check_ran = True + return orig_check(self, *args, **kwargs) + + monkeypatch.setattr(Store, "hash", hash_then_interrupt) + monkeypatch.setattr(ArchiveChecker, "check", spy_check) + try: + # exec_cmd calls Archiver.run() directly; only main() maps Error to an exit code, so it propagates. + with pytest.raises(Error, match="Got Ctrl-C"): + cmd(archiver, "check", "-v") + finally: + sig_int._sig_int_triggered = False + assert archive_check_ran is False # do_check raised at the sig_int guard, before archive_checker.check() + + def test_date_matching(archivers, request): archiver = request.getfixturevalue(archivers) check_cmd_setup(archiver)