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
10 changes: 10 additions & 0 deletions astrbot/core/desktop_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import os

DESKTOP_MANAGED_RESTART_MESSAGE = (
"AstrBot Desktop manages this backend process. Please restart or update from "
"the desktop app instead of the core WebUI."
)


def is_desktop_managed_backend() -> bool:
return os.environ.get("ASTRBOT_DESKTOP_MANAGED") == "1"
8 changes: 8 additions & 0 deletions astrbot/core/updator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@

from astrbot.core import logger
from astrbot.core.config.default import VERSION
from astrbot.core.desktop_runtime import (
DESKTOP_MANAGED_RESTART_MESSAGE,
is_desktop_managed_backend,
)
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.io import ensure_dir

Expand Down Expand Up @@ -142,6 +146,10 @@ def _reboot(self, delay: int = 3) -> None:
在指定的延迟后,终止所有子进程并重新启动程序
这里只能使用 os.exec* 来重启程序
"""
if is_desktop_managed_backend():
logger.error(DESKTOP_MANAGED_RESTART_MESSAGE)
raise RuntimeError(DESKTOP_MANAGED_RESTART_MESSAGE)
Comment on lines +149 to +151

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.

medium

When _reboot is called from core_lifecycle.restart(), it is executed inside a background daemon thread. If is_desktop_managed_backend() is true, raising a RuntimeError directly in this background thread will cause it to crash silently (with the traceback only printed to sys.stderr), which might bypass the application's configured logging system (LogBroker/LogManager).

Since the core lifecycle managers have already been terminated by the time _reboot is called, the application will be left in a partially terminated, unresponsive state without any clear indication in the application log files.

To improve observability and make troubleshooting easier, please log the error message using logger.error before raising the exception.

Suggested change
if is_desktop_managed_backend():
raise RuntimeError(DESKTOP_MANAGED_RESTART_MESSAGE)
if is_desktop_managed_backend():
logger.error(DESKTOP_MANAGED_RESTART_MESSAGE)
raise RuntimeError(DESKTOP_MANAGED_RESTART_MESSAGE)


time.sleep(delay)
self.terminate_child_processes()
executable = sys.executable
Expand Down
10 changes: 10 additions & 0 deletions astrbot/dashboard/api/updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from fastapi.responses import JSONResponse

from astrbot.core import logger
from astrbot.core.desktop_runtime import DESKTOP_MANAGED_RESTART_MESSAGE
from astrbot.dashboard.async_utils import run_maybe_async
from astrbot.dashboard.schemas import PipInstallRequest, UpdateRequest
from astrbot.dashboard.services.update_service import (
Expand Down Expand Up @@ -58,6 +59,15 @@ def _service_response(result: UpdateServiceResult) -> JSONResponse:

def _service_error(exc: UpdateServiceError) -> JSONResponse:
logger.error(f"Dashboard update operation failed: {exc}", exc_info=True)
if exc.code == "desktop_managed":
return JSONResponse(
{
"status": "error",
"message": DESKTOP_MANAGED_RESTART_MESSAGE,
"data": None,
},
status_code=200,
)
return JSONResponse(
{"status": "error", "message": "An internal error has occurred.", "data": None},
status_code=200,
Expand Down
7 changes: 7 additions & 0 deletions astrbot/dashboard/services/stat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import ProviderStat
from astrbot.core.desktop_runtime import (
DESKTOP_MANAGED_RESTART_MESSAGE,
is_desktop_managed_backend,
)
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.auth_password import (
is_default_dashboard_password,
Expand Down Expand Up @@ -57,6 +61,9 @@ async def restart_core(self) -> None:
raise StatServiceError(
"You are not permitted to do this operation in demo mode"
)
if is_desktop_managed_backend():
raise StatServiceError(DESKTOP_MANAGED_RESTART_MESSAGE)

await self.core_lifecycle.restart()

@staticmethod
Expand Down
14 changes: 13 additions & 1 deletion astrbot/dashboard/services/update_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
from astrbot.core import pip_installer as _pip_installer
from astrbot.core.config.default import VERSION
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.desktop_runtime import (
DESKTOP_MANAGED_RESTART_MESSAGE,
is_desktop_managed_backend,
)
from astrbot.core.updator import AstrBotUpdator
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
Expand Down Expand Up @@ -67,7 +71,9 @@ class UpdateServiceResult:


class UpdateServiceError(Exception):
pass
def __init__(self, message: str, *, code: str | None = None) -> None:
super().__init__(message)
self.code = code


class UpdateService:
Expand Down Expand Up @@ -143,6 +149,12 @@ async def get_releases(self) -> UpdateServiceResult:
raise UpdateServiceError(exc.__str__()) from exc

async def update_project(self, data: object) -> UpdateServiceResult:
if is_desktop_managed_backend():
raise UpdateServiceError(
DESKTOP_MANAGED_RESTART_MESSAGE,
code="desktop_managed",
)

payload = data if isinstance(data, dict) else {}
version = payload.get("version", "")
reboot = payload.get("reboot", True)
Expand Down
68 changes: 68 additions & 0 deletions tests/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from astrbot.core import LogBroker
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db.sqlite import SQLiteDatabase
from astrbot.core.desktop_runtime import DESKTOP_MANAGED_RESTART_MESSAGE
from astrbot.core.star.star import StarMetadata, star_registry
from astrbot.core.star.star_handler import star_handlers_registry
from astrbot.core.utils.auth_password import (
Expand Down Expand Up @@ -2662,6 +2663,35 @@ async def mock_get_dashboard_version(*args, **kwargs):
assert data["data"]["has_new_version"] is False


@pytest.mark.asyncio
async def test_restart_core_rejects_desktop_managed_backend(
app: FastAPIAppAdapter,
authenticated_header: dict,
core_lifecycle_td: AstrBotCoreLifecycle,
monkeypatch,
):
test_client = app.test_client()
restart_called = False

async def mock_restart():
nonlocal restart_called
restart_called = True

monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1")
monkeypatch.setattr(core_lifecycle_td, "restart", mock_restart)

response = await test_client.post(
"/api/stat/restart-core",
headers=authenticated_header,
)

assert response.status_code == 400
data = await response.get_json()
assert data["status"] == "error"
assert data["message"] == DESKTOP_MANAGED_RESTART_MESSAGE
assert restart_called is False


@pytest.mark.asyncio
async def test_do_update(
app: FastAPIAppAdapter,
Expand Down Expand Up @@ -2826,6 +2856,44 @@ def mock_extract_dashboard(*args, **kwargs):
assert calls == ["download-dashboard", "download-core"]


@pytest.mark.asyncio
async def test_do_update_rejects_desktop_managed_backend(
app: FastAPIAppAdapter,
authenticated_header: dict,
core_lifecycle_td: AstrBotCoreLifecycle,
monkeypatch,
):
test_client = app.test_client()
calls = []

async def mock_download_core(*args, **kwargs):
del args, kwargs
calls.append("download-core")

async def mock_restart():
calls.append("restart")

monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1")
monkeypatch.setattr(
core_lifecycle_td.astrbot_updator,
"download_update_package",
mock_download_core,
)
monkeypatch.setattr(core_lifecycle_td, "restart", mock_restart)

response = await test_client.post(
"/api/update/do",
headers=authenticated_header,
json={"version": "v3.4.0", "progress_id": "desktop-progress"},
)

assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "error"
assert data["message"] == DESKTOP_MANAGED_RESTART_MESSAGE
assert calls == []


@pytest.mark.asyncio
async def test_do_update_does_not_apply_files_when_package_verification_fails(
app: FastAPIAppAdapter,
Expand Down
Loading