From 35d3008936b53f4c16fa48c6fad6b13b30ed9858 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Mon, 6 Jul 2026 08:49:54 +0200 Subject: [PATCH 1/8] testsuite: fix logger state leak in RemoteLoggerTestCase (cherry picked from commit 133b630a84c3c4cd3638e44e77cb7d445aad1864) --- src/borg/testsuite/repository.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/borg/testsuite/repository.py b/src/borg/testsuite/repository.py index b537ccc811..ca0f9d9c07 100644 --- a/src/borg/testsuite/repository.py +++ b/src/borg/testsuite/repository.py @@ -1062,6 +1062,13 @@ class RemoteLoggerTestCase(BaseTestCase): def setUp(self): self.stream = io.StringIO() self.handler = logging.StreamHandler(self.stream) + + # Save old logging state + self.old_root_handlers = logging.getLogger().handlers[:] + self.old_root_level = logging.getLogger().level + self.old_repo_handlers = logging.getLogger('borg.repository').handlers[:] + self.old_repo_level = logging.getLogger('borg.repository').level + logging.getLogger().handlers[:] = [self.handler] logging.getLogger('borg.repository').handlers[:] = [] logging.getLogger('borg.repository.foo').handlers[:] = [] @@ -1073,6 +1080,12 @@ def setUp(self): def tearDown(self): sys.stderr = self.old_stderr + # Restore old logging state + logging.getLogger().handlers[:] = self.old_root_handlers + logging.getLogger().setLevel(self.old_root_level) + logging.getLogger('borg.repository').handlers[:] = self.old_repo_handlers + logging.getLogger('borg.repository').setLevel(self.old_repo_level) + def test_stderr_messages(self): handle_remote_line("unstructured stderr message\n") self.assert_equal(self.stream.getvalue(), '') From 8321dd41a19e503797e328a1d3160c6d23881022 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Sun, 19 Jul 2026 19:35:25 +0200 Subject: [PATCH 2/8] CI: make pip cache key architecture-specific The pip cache key used only ${{ runner.os }} ("Linux") and had an over-broad "${{ runner.os }}-" restore-key fallback. GitHub Actions lets a branch restore caches from the default branch (master), whose cache key is "${{ runner.os }}-${{ runner.arch }}-pip-..." and which runs on ARM64 runners (ubuntu-24.04-arm). The bare "Linux-" fallback prefix-matches master's "Linux-ARM64-pip-*" caches, so our x86_64 jobs were restoring an ARM64 pip cache. Include ${{ runner.arch }} in the key and drop the bare "${{ runner.os }}-" restore-key so caches never cross architectures (matches how master keys it). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013bFWgq2KV6oYxmfH6Zqvp3 (cherry picked from commit 93d9571f5ce3093e644f67db150f65542526fd61) --- .github/workflows/ci.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 775ee06a82..66ce99d529 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,10 +91,9 @@ jobs: uses: actions/cache@v4 with: path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('requirements.d/development.txt') }} + key: ${{ runner.os }}-${{ runner.arch }}-pip-${{ hashFiles('requirements.d/development.txt') }} restore-keys: | - ${{ runner.os }}-pip- - ${{ runner.os }}- + ${{ runner.os }}-${{ runner.arch }}-pip- - name: Install Linux packages if: ${{ runner.os == 'Linux' }} From 063c3cb163729dc08c28d5802cb96fb3179e1c3c Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Sun, 19 Jul 2026 19:47:39 +0200 Subject: [PATCH 3/8] CI: update GitHub Actions to Node 24 runtimes The Node 20 action runtimes are deprecated. Bump all actions to the current major versions (matching what the master branch uses), which run on Node 24: - actions/checkout v4 -> v7 - actions/setup-python v5 -> v6 - actions/cache v4 -> v6 - actions/upload-artifact v4 -> v7 - codecov/codecov-action v4 -> v7 - github/codeql-action/{init,analyze} v2 -> v4 (v2 is also fully deprecated) msys2/setup-msys2 stays at v2 (current). Inputs used (token, env_vars, name, path, languages) are unchanged across these bumps. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013bFWgq2KV6oYxmfH6Zqvp3 (cherry picked from commit 774983f6967ba13d05d09cc92ed30065e6d9de56) --- .github/workflows/ci.yml | 12 ++++++------ .github/workflows/codeql-analysis.yml | 10 +++++----- .github/workflows/windows.yml | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66ce99d529..4434c936fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,9 +37,9 @@ jobs: timeout-minutes: 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.11 - name: Lint with flake8 @@ -79,16 +79,16 @@ jobs: timeout-minutes: 360 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: # just fetching 1 commit is not enough for setuptools-scm, so we fetch all fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Cache pip - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.cache/pip key: ${{ runner.os }}-${{ runner.arch }}-pip-${{ hashFiles('requirements.d/development.txt') }} @@ -135,7 +135,7 @@ jobs: #sudo -E bash -c "tox -e py" tox --skip-missing-interpreters - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 env: OS: ${{ runner.os }} python: ${{ matrix.python-version }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index c96dc6b0cf..c31cb6fd9b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -29,16 +29,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: # just fetching 1 commit is not enough for setuptools-scm, so we fetch all fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.11 - name: Cache pip - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('requirements.d/development.txt') }} @@ -52,7 +52,7 @@ jobs: sudo apt-get install -y libssl-dev libacl1-dev libxxhash-dev liblz4-dev libzstd-dev # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -66,4 +66,4 @@ jobs: pip3 install -r requirements.d/development.txt pip3 install -ve . - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 4d901fa292..dd2b667a78 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -8,7 +8,7 @@ jobs: run: shell: msys2 {0} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: msys2/setup-msys2@v2 @@ -21,7 +21,7 @@ jobs: run: ./scripts/msys2-build - name: Test run: ./dist/borg.exe -V - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: borg-windows path: dist/borg.exe From cc9655fd3ad83b75acaf803906cc311591146ac6 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Sun, 19 Jul 2026 15:57:41 +0200 Subject: [PATCH 4/8] testsuite: fix test_create_read_special_symlink hanging when borg create fails The fifo_feeder thread blocks on os.open(fifo, O_WRONLY) until a reader opens the FIFO. If 'borg create --read-special' exits without opening the FIFO (e.g. it raises), the feeder stays blocked forever and t.join() hangs the whole test worker. This turned a would-be test failure into an infinite CI hang (observed on Linux py311-fuse2 and other jobs, but not py39-fuse2). Drain the FIFO in the finally block so the feeder always unblocks, turning a hang into a normal (diagnosable) test result. The OSError guard covers FreeBSD 13 raising BlockingIOError on the non-blocking read. Adapted from master commit 8ce3d223585ad68171e51839403c1fa3cb625d87 ("Fix test hanging reading FIFO when borg create failed", Peter Gerber) plus the later FreeBSD BlockingIOError guard; that fix never landed on 1.4-maint or 1.2-maint. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013bFWgq2KV6oYxmfH6Zqvp3 (cherry picked from commit d03a62076ce24fe293be734bb5e41b1191ae9980) --- src/borg/testsuite/archiver.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py index 55e1c3dcfa..44c2e65862 100644 --- a/src/borg/testsuite/archiver.py +++ b/src/borg/testsuite/archiver.py @@ -2166,6 +2166,15 @@ def fifo_feeder(fifo_fn, data): try: self.cmd('create', '--read-special', archive, 'input/link_fifo') finally: + # In case `borg create` failed to open FIFO, read all data to avoid join() hanging. + fd = os.open(fifo_fn, os.O_RDONLY | os.O_NONBLOCK) + try: + os.read(fd, len(data)) + except OSError: + # fails on FreeBSD 13 with BlockingIOError + pass + finally: + os.close(fd) t.join() with changedir('output'): self.cmd('extract', archive) From 8302f2e7a2bca880c748faec028ab488fdb78058 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Sun, 19 Jul 2026 14:45:07 +0200 Subject: [PATCH 5/8] tests: reset borg.output.progress logger between tests to fix flakiness ProgressIndicatorBase installs a handler on the process-global 'borg.output.progress' logger and only removes it in __del__. In-process (fork=False) command execution leaves this logger at level INFO / propagate=False with lingering handlers, and that state leaks across tests. When a later progress test runs, the "if not self.logger.handlers" guard skips the stderr handler setup, so progress output goes to the logging system instead of the stderr captured by capfd. Under xdist this surfaces as order/distribution-dependent failures of the progress tests (test_progress_percentage_*, test_extract_progress, test_progress_on, test_check_usage, ...). Add an autouse fixture that removes handlers and resets level/propagate on the progress logger after each test. This also makes the intra-test workaround in test_check_usage unnecessary (the progress logger level is set per command run by _setup_implied_logging), so remove it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013bFWgq2KV6oYxmfH6Zqvp3 (cherry picked from commit 196f9c89e6155e03ddfbafe479178d2a4f7a9776) (cherry picked from commit 11a0a9c42cc99627913bb0b0897928678ca3c4f1) --- conftest.py | 18 ++++++++++++++++++ src/borg/testsuite/archiver.py | 2 -- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/conftest.py b/conftest.py index bc40f33b7e..12070964ca 100644 --- a/conftest.py +++ b/conftest.py @@ -1,3 +1,4 @@ +import logging import os import pytest @@ -26,6 +27,23 @@ from borg.testsuite.platform import fakeroot_detected # noqa: E402 +@pytest.fixture(autouse=True) +def reset_progress_logger(): + # ProgressIndicatorBase installs a handler on the process-global + # 'borg.output.progress' logger and only removes it in __del__, and in-process + # (fork=False) command execution leaves this logger at level INFO / propagate=False. + # This state leaks across tests and makes the progress tests flaky (the progress + # output ends up in the logging system instead of on the stderr captured by capfd). + # Reset it after each test to keep tests isolated. + yield + logger = logging.getLogger('borg.output.progress') + for handler in logger.handlers[:]: + logger.removeHandler(handler) + handler.close() + logger.setLevel(logging.NOTSET) + logger.propagate = True + + @pytest.fixture(autouse=True) def clean_env(tmpdir_factory, monkeypatch): # avoid that we access / modify the user's normal .config / .cache directory: diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py index 44c2e65862..8173be171a 100644 --- a/src/borg/testsuite/archiver.py +++ b/src/borg/testsuite/archiver.py @@ -3887,8 +3887,6 @@ def test_check_usage(self): self.assert_in('Starting repository check', output) self.assert_in('Starting archive consistency check', output) self.assert_in('Checking segments', output) - # reset logging to new process default to avoid need for fork=True on next check - logging.getLogger('borg.output.progress').setLevel(logging.NOTSET) output = self.cmd('check', '-v', '--repository-only', self.repository_location, exit_code=0) self.assert_in('Starting repository check', output) self.assert_not_in('Starting archive consistency check', output) From 561ed101cbdc7ddc055e346c782e99394f0def4a Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Sun, 19 Jul 2026 14:49:22 +0200 Subject: [PATCH 6/8] no_selinux -> filter_xattrs Originally, we only wanted to get rid of selinux xattrs, but macOS also uses some xattr keys that disturb our test results, so we filter them also. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013bFWgq2KV6oYxmfH6Zqvp3 (cherry picked from commit ac51d3e868a1115c584d0e547640b6b991e7bb87) (cherry picked from commit 677eaafb03afa2402032732f4fc8f170d7b61d91) --- src/borg/testsuite/__init__.py | 15 ++++++++------- src/borg/testsuite/archiver.py | 6 +++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/borg/testsuite/__init__.py b/src/borg/testsuite/__init__.py index 7a3aaef37d..1e9660062c 100644 --- a/src/borg/testsuite/__init__.py +++ b/src/borg/testsuite/__init__.py @@ -156,13 +156,14 @@ def is_birthtime_fully_supported(): return False -def no_selinux(x): - # selinux fails our FUSE tests, thus ignore selinux xattrs - SELINUX_KEY = b'security.selinux' +def filter_xattrs(x): + # selinux and com.apple.provenance fail our FUSE tests, thus ignore them + UNWANTED_KEYS = {b"security.selinux", b"com.apple.provenance"} if isinstance(x, dict): - return {k: v for k, v in x.items() if k != SELINUX_KEY} + return {k: v for k, v in x.items() if k not in UNWANTED_KEYS} if isinstance(x, list): - return [k for k in x if k != SELINUX_KEY] + return [k for k in x if k not in UNWANTED_KEYS] + raise ValueError("Unsupported type: %s" % type(x)) class BaseTestCase(unittest.TestCase): @@ -233,8 +234,8 @@ def _assert_dirs_equal_cmp(self, diff, ignore_flags=False, ignore_xattrs=False, d1.append(round(s1.st_mtime_ns, st_mtime_ns_round)) d2.append(round(s2.st_mtime_ns, st_mtime_ns_round)) if not ignore_xattrs: - d1.append(no_selinux(get_all(path1, follow_symlinks=False))) - d2.append(no_selinux(get_all(path2, follow_symlinks=False))) + d1.append(filter_xattrs(get_all(path1, follow_symlinks=False))) + d2.append(filter_xattrs(get_all(path2, follow_symlinks=False))) self.assert_equal(d1, d2) for sub_diff in diff.subdirs.values(): self._assert_dirs_equal_cmp(sub_diff, ignore_flags=ignore_flags, ignore_xattrs=ignore_xattrs, ignore_ns=ignore_ns) diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py index 8173be171a..b19e850e9d 100644 --- a/src/borg/testsuite/archiver.py +++ b/src/borg/testsuite/archiver.py @@ -55,7 +55,7 @@ from ..remote import RemoteRepository, PathNotAllowed from ..repository import Repository from . import has_lchflags, llfuse -from . import BaseTestCase, changedir, environment_variable, no_selinux, same_ts_ns +from . import BaseTestCase, changedir, environment_variable, filter_xattrs, same_ts_ns from . import are_symlinks_supported, are_hardlinks_supported, are_fifos_supported, is_utime_fully_supported, is_birthtime_fully_supported from .platform import fakeroot_detected, is_darwin, is_freebsd, is_win32 from .upgrader import make_attic_repo @@ -2725,11 +2725,11 @@ def has_noatime(some_file): in_fn = 'input/fusexattr' out_fn = os.fsencode(os.path.join(mountpoint, 'input', 'fusexattr')) if not xattr.XATTR_FAKEROOT and xattr.is_enabled(self.input_path): - assert sorted(no_selinux(xattr.listxattr(out_fn))) == [b'user.empty', b'user.foo', ] + assert sorted(filter_xattrs(xattr.listxattr(out_fn))) == [b'user.empty', b'user.foo', ] assert xattr.getxattr(out_fn, b'user.foo') == b'bar' assert xattr.getxattr(out_fn, b'user.empty') == b'' else: - assert no_selinux(xattr.listxattr(out_fn)) == [] + assert filter_xattrs(xattr.listxattr(out_fn)) == [] try: xattr.getxattr(out_fn, b'user.foo') except OSError as e: From 61f490a66be31565bcea781a4a75f9a32a1443c3 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Mon, 20 Jul 2026 14:58:59 +0200 Subject: [PATCH 7/8] testsuite: move conftest.py into the borg.testsuite package The autouse test-isolation fixtures (clean_env, reset_progress_logger, default_patches) lived only in the repo-root conftest.py. That file is not installed with the package, and pytest does not apply a rootdir conftest's fixtures to tests collected from a different directory tree. On CI the tests run against the sdist-installed package via `pytest --pyargs borg.testsuite` (tox), i.e. from site-packages/borg/testsuite/ - so the fixtures never ran there, and tests leaked state into each other: - BORG_NEW_PASSPHRASE leaked from test_change_passphrase, making later repokey `init` encrypt with the wrong passphrase -> ~330 spurious PassphraseWrong failures in archiver create tests. - XDG_CONFIG_HOME was not isolated, so nonce/key tests read+wrote the real security dir and poisoned each other (nonce/IV assertions). - the borg.output.progress logger handler leaked (progress test failures). These only showed up on py>=3.10 because pytest is unpinned: py3.9 resolves an older pytest that still applied the rootdir fixtures to --pyargs tests. Move conftest.py into src/borg/testsuite/ (matching 1.4-maint/master) so it is installed alongside the tests and its fixtures always apply. Adjust the flake8 / pre-commit targets accordingly (it is now covered by `flake8 src`). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013bFWgq2KV6oYxmfH6Zqvp3 --- .github/workflows/ci.yml | 2 +- .pre-commit-config.yaml | 2 +- conftest.py => src/borg/testsuite/conftest.py | 0 tox.ini | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename conftest.py => src/borg/testsuite/conftest.py (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4434c936fc..888aa97a0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: - name: Lint with flake8 run: | pip install flake8 - flake8 src scripts conftest.py + flake8 src scripts pytest: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c1a27a78b..f6df9b4856 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,4 +3,4 @@ repos: rev: 6.1.0 hooks: - id: flake8 - files: '(src|scripts|conftest.py)' + files: '(src|scripts)' diff --git a/conftest.py b/src/borg/testsuite/conftest.py similarity index 100% rename from conftest.py rename to src/borg/testsuite/conftest.py diff --git a/tox.ini b/tox.ini index 425aba6ab2..3ae0d53e6e 100644 --- a/tox.ini +++ b/tox.ini @@ -32,4 +32,4 @@ skip_install=true changedir = deps = flake8 -commands = flake8 src scripts conftest.py +commands = flake8 src scripts From 19927f489a29fd2fe6678c3cb746c864dc43a5e1 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Mon, 20 Jul 2026 14:59:56 +0200 Subject: [PATCH 8/8] testsuite: don't leak BORG_NEW_PASSPHRASE from test_change_passphrase Set BORG_NEW_PASSPHRASE / BORG_PASSPHRASE via the environment_variable context manager so they are restored after the test instead of being left in os.environ. While the clean_env fixture (now effective, see previous commit) already scrubs BORG_* vars between tests, not leaking them in the first place is the correct behaviour and defends against the leak reaching tests that bypass the fixture. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013bFWgq2KV6oYxmfH6Zqvp3 --- src/borg/testsuite/archiver.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py index b19e850e9d..6af41a967f 100644 --- a/src/borg/testsuite/archiver.py +++ b/src/borg/testsuite/archiver.py @@ -2621,11 +2621,14 @@ def test_compression_auto_uncompressible(self): def test_change_passphrase(self): self.cmd('init', '--encryption=repokey', self.repository_location) - os.environ['BORG_NEW_PASSPHRASE'] = 'newpassphrase' - # here we have both BORG_PASSPHRASE and BORG_NEW_PASSPHRASE set: - self.cmd('key', 'change-passphrase', self.repository_location) - os.environ['BORG_PASSPHRASE'] = 'newpassphrase' - self.cmd('list', self.repository_location) + # use the context manager so BORG_NEW_PASSPHRASE / BORG_PASSPHRASE are restored + # afterwards and cannot leak into later tests (a leaked BORG_NEW_PASSPHRASE would + # make a later repokey `init` encrypt with the wrong passphrase). + with environment_variable(BORG_NEW_PASSPHRASE='newpassphrase'): + # here we have both BORG_PASSPHRASE and BORG_NEW_PASSPHRASE set: + self.cmd('key', 'change-passphrase', self.repository_location) + with environment_variable(BORG_PASSPHRASE='newpassphrase'): + self.cmd('list', self.repository_location) def test_break_lock(self): self.cmd('init', '--encryption=repokey', self.repository_location)