Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions src/vorta/application.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Tuple
Expand Down Expand Up @@ -253,7 +254,7 @@ def react_to_log(self, mgs, context):
cancelButton = msg.addButton(self.tr("Cancel"), QMessageBox.ButtonRole.RejectRole)
msg.addButton(self.tr("Break the lock"), QMessageBox.ButtonRole.AcceptRole)
msg.setDefaultButton(cancelButton)
msg.setText(self.tr(f"The repository at {repo_url} might be in use elsewhere."))
msg.setText(self.tr("The repository at {} might be in use elsewhere.").format(repo_url))
msg.setInformativeText(
self.tr(
"Only break the lock if you are certain no other Borg process "
Expand All @@ -265,13 +266,44 @@ def react_to_log(self, mgs, context):
msg.show()
elif msgid == 'LockFailed':
repo_url = context.get('repo_url')
borg_message = context.get('message', '')

# Borg reports any failure to create the lock file as LockFailed, so the
# errno in its message tells us what actually went wrong. Match the number
# and not the text after it, because that text comes from strerror and is
# localized. ENOSPC is 28 everywhere, EDQUOT is 122 on Linux and 69 on
# macOS/BSD. For a remote repository the errno originates on the server, so
# the local platform's errno constants are not a valid comparison here and we
# accept all three. On Linux 69 is ESRMNT instead, but that never surfaces in
# practice, so the ambiguity is not worth handling.
errno_match = re.search(r'\[Errno (\d+)\]', borg_message)
err = int(errno_match.group(1)) if errno_match else None

msg = QMessageBox()
msg.setText(
self.tr(
f"You do not have permission to access the repository at {repo_url}. Gain access and try again."
msg.setIcon(QMessageBox.Icon.Critical)
if err in (28, 122, 69):
msg.setWindowTitle(self.tr("Repository Storage Full"))
msg.setText(self.tr("The storage backing the repository at {} is full.").format(repo_url))
msg.setInformativeText(
self.tr(
"Borg could not create its lock file, so no operation can run — including pruning, "
"which would free space. Free up space outside the repository, then try again."
)
)
elif err in (1, 13):
msg.setWindowTitle(self.tr("No Repository Permissions"))
msg.setText(
self.tr(
"You do not have permission to access the repository at {}. Gain access and try again."
).format(repo_url)
)
) # noqa: E501
msg.setWindowTitle(self.tr("No Repository Permissions"))
else:
msg.setWindowTitle(self.tr("Repository Lock Failed"))
msg.setText(self.tr("Borg could not lock the repository at {}.").format(repo_url))

if borg_message:
msg.setDetailedText(borg_message)

self._msg = msg
msg.show()

Expand Down
1 change: 1 addition & 0 deletions src/vorta/borg/borg_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ def read_async(fd):
if parsed['type'] == 'log_message':
context = {
'msgid': parsed.get('msgid'),
'message': parsed.get('message', ''),
'repo_url': self.params['repo_url'],
'profile_name': self.params.get('profile_name'),
'cmd': self.params['cmd'][1],
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/borg_json_output/create_lockfail_stderr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{"type": "log_message", "time": 1786000100.0, "message": "Failed to create/acquire the lock /mnt/repo/lock.exclusive ([Errno 30] Read-only file system: '/mnt/repo/lock.exclusive.xn0egqgt.tmp').", "levelname": "ERROR", "name": "borg.archiver", "msgid": "LockFailed"}
{"type": "log_message", "time": 1786000100.5, "message": "Traceback (most recent call last):\n File \"/usr/lib/python3/dist-packages/borg/archiver.py\", line 4591, in main\n exit_code = archiver.run(args)\n File \"/usr/lib/python3/dist-packages/borg/archiver.py\", line 4523, in run\n return set_ec(func(args))\n File \"/usr/lib/python3/dist-packages/borg/archiver.py\", line 161, in wrapper\n with repository:\n File \"/usr/lib/python3/dist-packages/borg/repository.py\", line 190, in __enter__\n self.open(self.path, bool(self.exclusive), lock_wait=self.lock_wait, lock=self.do_lock)\n File \"/usr/lib/python3/dist-packages/borg/repository.py\", line 421, in open\n self.lock = Lock(os.path.join(path, 'lock'), exclusive, timeout=lock_wait, kill_stale_locks=hostname_is_unique()).acquire()\n File \"/usr/lib/python3/dist-packages/borg/locking.py\", line 350, in acquire\n self._wait_for_readers_finishing(remove, sleep)\n File \"/usr/lib/python3/dist-packages/borg/locking.py\", line 363, in _wait_for_readers_finishing\n self._lock.acquire()\n File \"/usr/lib/python3/dist-packages/borg/locking.py\", line 138, in acquire\n raise LockFailed(self.path, str(err)) from None\nborg.locking.LockFailed: Failed to create/acquire the lock /mnt/repo/lock.exclusive ([Errno 30] Read-only file system: '/mnt/repo/lock.exclusive.xn0egqgt.tmp').\n\nPlatform: Linux github 6.9.0-30-generic #30-Ubuntu SMP Thu Jul 10 12:37:59 UTC 2025 x86_64\nLinux: Unknown Linux \nBorg: 1.4.5 Python: CPython 3.14.6 msgpack: 0.5.6\nPID: 64701 CWD: /home/user/Projects/vorta/tests/borg_json_output\nsys.argv: ['/usr/bin/borg', 'create', '--list', '--progress', '--info', '--log-json', '--json', '--filter=AM', '-C', 'lz4', '/mnt/repo::github-asdf-2026-08-14T00:05:49', '/home/user/bashrc']\nSSH_ORIGINAL_COMMAND: None\n", "levelname": "ERROR", "name": "borg.archiver"}
Empty file.
2 changes: 2 additions & 0 deletions tests/unit/borg_json_output/create_quota_stderr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{"type": "log_message", "time": 1786000000.0, "message": "Failed to create/acquire the lock /home/repo/lock.exclusive ([Errno 122] Disk quota exceeded: '/home/repo/lock.exclusive.xn0egqgt.tmp').", "levelname": "ERROR", "name": "borg.archiver", "msgid": "LockFailed"}
{"type": "log_message", "time": 1786000000.5, "message": "Traceback (most recent call last):\n File \"/usr/lib/python3/dist-packages/borg/archiver.py\", line 4591, in main\n exit_code = archiver.run(args)\n File \"/usr/lib/python3/dist-packages/borg/archiver.py\", line 4523, in run\n return set_ec(func(args))\n File \"/usr/lib/python3/dist-packages/borg/archiver.py\", line 161, in wrapper\n with repository:\n File \"/usr/lib/python3/dist-packages/borg/repository.py\", line 190, in __enter__\n self.open(self.path, bool(self.exclusive), lock_wait=self.lock_wait, lock=self.do_lock)\n File \"/usr/lib/python3/dist-packages/borg/repository.py\", line 421, in open\n self.lock = Lock(os.path.join(path, 'lock'), exclusive, timeout=lock_wait, kill_stale_locks=hostname_is_unique()).acquire()\n File \"/usr/lib/python3/dist-packages/borg/locking.py\", line 350, in acquire\n self._wait_for_readers_finishing(remove, sleep)\n File \"/usr/lib/python3/dist-packages/borg/locking.py\", line 363, in _wait_for_readers_finishing\n self._lock.acquire()\n File \"/usr/lib/python3/dist-packages/borg/locking.py\", line 138, in acquire\n raise LockFailed(self.path, str(err)) from None\nborg.locking.LockFailed: Failed to create/acquire the lock /home/repo/lock.exclusive ([Errno 122] Disk quota exceeded: '/home/repo/lock.exclusive.xn0egqgt.tmp').\n\nPlatform: Linux github 6.9.0-30-generic #30-Ubuntu SMP Thu Jul 10 12:37:59 UTC 2025 x86_64\nLinux: Unknown Linux \nBorg: 1.4.5 Python: CPython 3.14.6 msgpack: 0.5.6\nPID: 64701 CWD: /home/user/Projects/vorta/tests/borg_json_output\nsys.argv: ['/usr/bin/borg', 'create', '--list', '--progress', '--info', '--log-json', '--json', '--filter=AM', '-C', 'lz4', '/home/repo::github-asdf-2026-08-14T00:05:49', '/home/user/bashrc']\nSSH_ORIGINAL_COMMAND: None\n", "levelname": "ERROR", "name": "borg.archiver"}
Empty file.
36 changes: 36 additions & 0 deletions tests/unit/test_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,42 @@ def test_create_perm_error(qapp, borg_json_output, mocker, qtbot):
del qapp._msg


def test_create_quota_error(qapp, borg_json_output, mocker, qtbot):
main = qapp.main_window
mocker.patch.object(vorta.application.QMessageBox, 'show')

stdout, stderr = borg_json_output('create_quota')
popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0)
mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result)

# Prevent thread collision with the job still finishing from the previous test
qtbot.waitUntil(lambda: main.createStartBtn.isEnabled(), **pytest._wait_defaults)
qtbot.mouseClick(main.createStartBtn, QtCore.Qt.MouseButton.LeftButton)

qtbot.waitUntil(lambda: hasattr(qapp, '_msg'), **pytest._wait_defaults)
assert "is full" in qapp._msg.text()
assert "Disk quota exceeded" in qapp._msg.detailedText()
del qapp._msg


def test_create_lock_failed_generic(qapp, borg_json_output, mocker, qtbot):
main = qapp.main_window
mocker.patch.object(vorta.application.QMessageBox, 'show')

stdout, stderr = borg_json_output('create_lockfail')
popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0)
mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result)

# Prevent thread collision with the job still finishing from the previous test
qtbot.waitUntil(lambda: main.createStartBtn.isEnabled(), **pytest._wait_defaults)
qtbot.mouseClick(main.createStartBtn, QtCore.Qt.MouseButton.LeftButton)

qtbot.waitUntil(lambda: hasattr(qapp, '_msg'), **pytest._wait_defaults)
assert "Borg could not lock the repository" in qapp._msg.text()
assert "Read-only file system" in qapp._msg.detailedText()
del qapp._msg


def test_create_lock(qapp, borg_json_output, mocker, qtbot):
main = qapp.main_window
mocker.patch.object(vorta.application.QMessageBox, 'show')
Expand Down
Loading