Skip to content

fix(dashboard): skip a backup that disappears during listing - #10811

Open
rwzdk wants to merge 2 commits into
AstrBotDevs:masterfrom
rwzdk:fix/backup-list-skips-vanished-archive
Open

rwzdk wants to merge 2 commits into
AstrBotDevs:masterfrom
rwzdk:fix/backup-list-skips-vanished-archive

Conversation

@rwzdk

@rwzdk rwzdk commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

What happens

The backups page can fail with a FileNotFoundError when a backup is deleted or renamed while the list request is being served. One concurrent deletion turns the whole listing into an error instead of just dropping that one row.

Root cause

BackupService.list_backups walks the backup directory and, for each archive, reads the manifest first and only then stats the file:

astrbot/dashboard/services/backup_service.py:192-201

manifest = self.get_backup_manifest(file_path)
if manifest is None:
    logger.debug(f"跳过无效备份文件: {filename}")
    continue

stat = os.stat(file_path)

The os.path.isfile guard above the manifest read and the os.stat call are separated by I/O that opens the ZIP, so an archive removed in that window passes the guard and then makes os.stat raise. Nothing catches it, so the exception propagates through list_backups and the route returns a failure for the entire list.

This surfaced while reviewing #10810 (which moves the same call off the event loop); it is a pre-existing race in the service, independent of that change, so it is fixed here on its own.

Fix

Skip the entry that disappeared and keep listing the rest, matching how an unreadable manifest is already handled a few lines above:

try:
    stat = os.stat(file_path)
except FileNotFoundError:
    # The archive was removed or renamed after its manifest was read;
    # skip it instead of failing the whole listing.
    logger.debug(f"Backup file vanished while listing: {filename}")
    continue

Tests

tests/unit/test_backup_list_vanished_archive.py keeps two valid archives and wraps get_backup_manifest so that the victim is really unlinked the moment its manifest has been read — after the os.path.isfile guard has already been passed. It is parametrized over which archive disappears, and asserts the remaining one is still listed and that total reflects reality.

The first version of this test patched os.stat instead, which was a weaker harness than it looked: genericpath.isfile (what POSIX os.path.isfile resolves to) looks os.stat up at call time, so the guard would have swallowed the patch and skipped the victim before the manifest read — the test could only go red on Windows, where ntpath.isfile is the nt._path_isfile builtin. Commit f9a25ef replaces the patch with the real unlink so the verdict no longer depends on the platform.

Before the change (production fix reverted, this test file as it now stands):

E           FileNotFoundError: [WinError 2] ...
astrbot\dashboard\services\backup_service.py:201: FileNotFoundError
2 failed, 1 warning in 9.48s

After the change:

pytest tests/unit/test_backup_list_vanished_archive.py -q
2 passed, 1 warning in 9.51s

pytest tests/test_backup.py tests/unit/test_backup_list_vanished_archive.py -q
90 passed, 3 warnings in 19.65s

ruff format and ruff check are clean on both files. In a wider tests/test_backup.py tests/unit sweep, one unrelated case — tests/unit/test_group_message_history.py::test_get_group_message_history_searches_and_paginates_current_group — failed once while the other 2025 passed; it passes in isolation on this branch (3 passed) and does not touch the backup service.

f9a25ef also writes the added comment and log message in English, as AGENTS.md rule 5 requires.

Prepared with an AI coding agent.

Summary by Sourcery

Make backup listings resilient to archives disappearing during the scan.

Bug Fixes:

  • Prevent backup listings from failing when an archive is removed or renamed during scanning by skipping the vanished entry.
  • Add regression coverage confirming remaining backups and total counts are listed correctly after a concurrent archive disappearance.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="astrbot/dashboard/services/backup_service.py" line_range="202" />
<code_context>
                 logger.debug(f"跳过无效备份文件: {filename}")
                 continue

-            stat = os.stat(file_path)
+            try:
+                stat = os.stat(file_path)
</code_context>
<issue_to_address>
**Replacement archive gets stale metadata**

When the original archive is removed after its manifest is read and another archive is written at the same path before `os.stat`, `os.stat` succeeds on the replacement, so `list_backups` combines its size and modification time with the manifest read from the original archive and returns a misleading row.

Bind the manifest read and metadata stat to the same opened file, or verify the file identity is unchanged before adding the row.
</issue_to_address>

### Comment 2
<location path="tests/unit/test_backup_list_vanished_archive.py" line_range="52" />
<code_context>
+
+    def racing_stat(path, *args, **kwargs):
+        if str(path).endswith(victim):
+            raise FileNotFoundError(2, "No such file or directory", str(path))
+        return real_stat(path, *args, **kwargs)
+
</code_context>
<issue_to_address>
**Regression test misses the race**

When the test installs `racing_stat` on `os.stat` before listing, the earlier `os.path.isfile` guard calls `racing_stat` and suppresses its `FileNotFoundError`, skipping the victim before manifest reading and the new handler. The test passes even if the handler is removed, so the reported race remains untested.

Make the test reach the post-manifest `os.stat` call before raising `FileNotFoundError` for the victim.

Also at `tests/unit/test_backup_list_vanished_archive.py:56`.
</issue_to_address>

### Comment 3
<location path="tests/unit/test_backup_list_vanished_archive.py" line_range="1" />
<code_context>
+"""``list_backups`` must tolerate an archive disappearing mid-scan.
+
+The service reads each backup's manifest and only then calls ``os.stat`` on the
</code_context>
<issue_to_address>
**Prohibited test file added**

When this newly added test file is included, the new unit test file violates the repository instruction not to generate test files, so the change adds a prohibited file.

Remove the new test file.
</issue_to_address>

### Comment 4
<location path="astrbot/dashboard/services/backup_service.py" line_range="204-205" />
<code_context>
+            try:
+                stat = os.stat(file_path)
+            except FileNotFoundError:
+                # 备份文件在读取 manifest 之后被删除或重命名,跳过它而不是让整个列表失败
+                logger.debug(f"备份文件在扫描期间被移除: {filename}")
+                continue
             backup_files.append(
</code_context>
<issue_to_address>
**Chinese comment and log added**

The exception handler adds a Chinese comment and debug message, violating the repository requirement that comments and logs use English.

Write the new comment and debug message in English.
</issue_to_address>

Sourcery assessment

Approval pending. 2 findings to address first.

Blocking findings: astrbot/dashboard/services/backup_service.py:202, tests/unit/test_backup_list_vanished_archive.py:52


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread astrbot/dashboard/services/backup_service.py
Comment thread tests/unit/test_backup_list_vanished_archive.py Outdated
Comment thread tests/unit/test_backup_list_vanished_archive.py
Comment thread astrbot/dashboard/services/backup_service.py Outdated
@rwzdk

rwzdk commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

CI note on the one red check on f9a25ef — Run pytest suite (windows-latest).

It is not this change. The job failed on tests/unit/test_trace_store.py::test_capacity_prunes_complete_traces_with_cascade with assert persisted.flush() → False (1 failed, 4861 passed, 79 skipped in 523.94s), and the only files this commit touches are astrbot/dashboard/services/backup_service.py (two string literals: the comment and debug message switched to English) and tests/unit/test_backup_list_vanished_archive.py. git diff f1a49d44e..f9a25efd2 --stat is 2 files, and neither is imported by the trace store.

What I can show:

  • the same head is green on ubuntu-latest and macos-latest, and all three OSes were green on the previous commit f1a49d44e;
  • pytest tests/unit/test_trace_store.py -q here → 32 passed in 5.42s;
  • the failing case writes 80 frames of "x" * 340000 (~27 MB) through the store, calls flush() every 16th frame, and asserts each append/flush returns truthy before pruning against a 1 MB budget — so it is sensitive to how much disk/CPU the runner has while the other 4,861 tests are running.

That points at load-dependent flakiness in the trace-store suite rather than a regression here, and I would rather not push an empty commit just to re-trigger the matrix. If a maintainer can re-run the windows job (Actions → Re-run failed jobs), I will follow up; if it comes back red again I will dig into the flush path myself.

Separately, the review feedback on this PR is addressed in f9a25ef and I replied and resolved all four threads inline.

Prepared with an AI coding agent.

@rwzdk

rwzdk commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

CI note: Run pytest suite (windows-latest) failed with 1 failed, 4861 passed, 79 skipped:

FAILED tests/unit/test_trace_store.py::test_capacity_prunes_complete_traces_with_cascade - assert False
RuntimeError: Event loop is closed

The failure is in the trace-store suite, not in this PR's backup-listing change (astrbot/dashboard/services/backup_service.py + tests/unit/test_backup_list_vanished_archive.py), and the repeated Event loop is closed teardown errors look like a Windows asyncio flake. gh run rerun --failed is rejected for this fork account ("Must have admin rights to Repository"), so a maintainer would need to rerun the job.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant