Skip to content

fix: prevent SharedPreferences deadlocks - #9649

Merged
Soulter merged 4 commits into
masterfrom
codex/fix-shared-preferences-deadlock
Aug 12, 2026
Merged

fix: prevent SharedPreferences deadlocks#9649
Soulter merged 4 commits into
masterfrom
codex/fix-shared-preferences-deadlock

Conversation

@Soulter

@Soulter Soulter commented Aug 12, 2026

Copy link
Copy Markdown
Member

Summary

  • replace the blocking SharedPreferences sync bridge with a startup-preloaded cache and an ordered persistence queue on the main event loop
  • keep legacy synchronous APIs non-blocking and mark the public sync provider/tool APIs as deprecated
  • add async provider selection and LLM tool toggle APIs, then migrate core, built-in, pipeline, and dashboard call sites
  • flush pending preference writes during shutdown and keep the cache consistent when plugin KV data is cleared
  • update the plugin development documentation and add deadlock, persistence, preload, and FIFO ordering regression tests

Root cause

The deprecated synchronous SharedPreferences methods submitted database coroutines to a secondary event loop and blocked on .result(). The secondary loop used the same SQLAlchemy async connection pool as the main loop. When the pool was exhausted, the main loop could block waiting for the synchronous result while also being the only loop able to release the required connection, creating a circular wait.

The new implementation serves synchronous reads from memory and makes synchronous writes update the cache immediately before scheduling ordered persistence. Async writes await persistence completion and surface database failures.

Fixes #9633.

Validation

  • uv run ruff format .
  • uv run ruff check .
  • focused regression suite: 252 passed
  • full tests/ suite: 2128 passed, 2 deselected
    • the two deselected tests are pre-existing dashboard caplog capture failures where the expected warning is emitted to stdout

Summary by Sourcery

Replace the blocking SharedPreferences sync bridge with an event-loop-bound cached store and ordered write queue, and introduce async provider selection and tool toggle APIs used across core, pipeline, plugins, and dashboard.

Bug Fixes:

  • Eliminate SharedPreferences deadlocks by serving sync reads from an in-memory cache and decoupling sync writes from direct database blocking on shared async pools.

Enhancements:

  • Add initialization, flush, and close lifecycle hooks for SharedPreferences to preload preferences, ensure FIFO persistence, and flush writes on shutdown and restart.
  • Introduce async provider selection APIs for chat, TTS, and STT and migrate call sites to use them while keeping deprecated synchronous variants for backward compatibility.
  • Add async activation/deactivation APIs for LLM tools that persist their state via SharedPreferences, and wire these into StarTools and dashboard tool toggling.
  • Relax preference DB queries to support optional scope filtering and use SharedPreferences to clear plugin KV data rather than direct DB access.

Documentation:

  • Update plugin development documentation to recommend the new async provider APIs and clarify deprecation status of synchronous provider methods.

Tests:

  • Add a dedicated SharedPreferences test suite covering cache preload, non-blocking sync access, FIFO write ordering, and connection pool exhaustion behavior.
  • Extend tests for provider selection, tool manager behavior, dashboard endpoints, plugin cleanup, and group chat wiring to cover the new async APIs and preference-backed behaviors.

@Soulter
Soulter marked this pull request as ready for review August 12, 2026 16:32
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. area:core The bug / feature is about astrbot's core, backend area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. labels Aug 12, 2026

@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/core/utils/shared_preferences.py" line_range="125-134" />
<code_context>
+    async def _drain_write_queue(self) -> None:
</code_context>
<issue_to_address>
**issue (bug_risk):** Queued writes can get stuck if they are enqueued while the writer task is still running but the queue has just become empty.

Because `_drain_write_queue` uses `queue.get_nowait()` in a loop and `_schedule_write` only starts a writer when `self._writer_task is None or self._writer_task.done()`, there’s a race where the queue becomes empty, the task is still marked running, and a new item is enqueued before `_writer_task` completes. In that case no new writer task is scheduled and the new item is never processed. To avoid stuck writes, consider making `_drain_write_queue` a long‑lived consumer using `await queue.get()` in a loop, exiting only on an explicit shutdown/sentinel, or alternately removing `_writer_task` and spawning a fresh task per flush.
</issue_to_address>

### Comment 2
<location path="astrbot/core/provider/manager.py" line_range="218-227" />
<code_context>

-    def get_using_provider(
-        self, provider_type: ProviderType, umo=None
+    def _resolve_using_provider(
+        self,
+        provider_type: ProviderType,
</code_context>
<issue_to_address>
**nitpick:** The helper method’s docstring advertises a ValueError that is never raised, which may mislead callers.

The `_resolve_using_provider` docstring claims it may raise `ValueError` for unsupported `provider_type`, but the implementation only returns `None` for disabled/unavailable providers. This contract mismatch can cause callers to handle exceptions that never occur instead of the `None` case.

Please either add explicit validation that raises on unsupported `ProviderType` values, or update the docstring to reflect the current behavior (no exceptions, `None` as the failure mode), especially since both sync and async `get_using_provider` variants now rely on this helper.
</issue_to_address>

### Comment 3
<location path="tests/unit/test_shared_preferences.py" line_range="28-31" />
<code_context>
+async def test_sync_put_updates_cache_and_persists_without_blocking(preferences):
+    store, database = preferences
+
+    started = time.monotonic()
+    store.put("theme", "dark", scope="global", scope_id="global")
+
+    assert time.monotonic() - started < 0.1
+    assert store.get("theme", scope="global", scope_id="global") == "dark"
+
</code_context>
<issue_to_address>
**suggestion (testing):** The strict timing assertion in `test_sync_put_updates_cache_and_persists_without_blocking` may be flaky across environments.

This test encodes a 100ms latency guarantee for `store.put`, which is likely to be flaky under CI load or on slower systems. Since the goal is to verify non-blocking behavior rather than exact timing, consider either mocking the persistence path so the test only asserts that `put` returns without awaiting, or relaxing the timing constraint (e.g., relative to a known blocking operation) to avoid spurious failures unrelated to the deadlock fix.

Suggested implementation:

```python
import time

import pytest
import pytest_asyncio

```

```python
@pytest.mark.asyncio
async def test_sync_put_updates_cache_and_persists_without_blocking(preferences):
    store, database = preferences

    # This test verifies that `put` returns promptly and updates the in-memory cache
    # without asserting a very tight latency bound that could be flaky under CI load.
    started = time.monotonic()
    store.put("theme", "dark", scope="global", scope_id="global")
    elapsed = time.monotonic() - started

    # Allow a more relaxed upper bound to avoid spurious failures while still
    # catching pathological blocking behavior.
    assert elapsed < 0.5
    assert store.get("theme", scope="global", scope_id="global") == "dark"

```

Depending on how `SharedPreferences` and the underlying persistence layer are implemented, you may want to further align this test with the non-blocking guarantee by:
1. Introducing a mock or spy for the persistence method (e.g., the database write call) and asserting that it is invoked asynchronously after `put` returns, rather than relying on wall-clock timing.
2. If you adopt that mocking approach, the `elapsed` assertion could be removed entirely, and the test would instead assert that `put` does not await the persistence coroutine (for example, by making the mocked persistence artificially slow and confirming that `put` returns before it completes).
These adjustments will require access to the actual `SharedPreferences` implementation and the persistence API to determine the best hook point for mocking.
</issue_to_address>

### Comment 4
<location path="astrbot/core/utils/shared_preferences.py" line_range="46" />
<code_context>
+        self._cache_initialized = False
+        self._initializing = False
+        self._initialize_lock = asyncio.Lock()
+        self._loop: asyncio.AbstractEventLoop | None = None
+        self._write_queue: asyncio.Queue[_WriteOperation] | None = None
+        self._writer_task: asyncio.Task[None] | None = None
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying SharedPreferences to a single event loop with one writer task and queue, removing multi-loop rebinding logic, pending write handling, and extra state flags.

The main complexity comes from supporting rebinding across multiple event loops and coordinating `_pending_writes` vs the live `_write_queue`. If you don’t actually need multi-loop ownership, you can simplify to a single-loop, single-queue model and drop most of the state flags without losing any behaviour.

### 1. Bind to a single event loop and remove rebinding / `_pending_writes`

You can bind once in `initialize()` and enforce a stricter contract:

```python
class SharedPreferences:
    def __init__(self, db_helper: BaseDatabase, json_storage_path=None) -> None:
        ...
        self._cache: dict[tuple[str, str, str], Any] = {}
        self._cache_lock = threading.RLock()
        self._cache_initialized = False

        self._loop: asyncio.AbstractEventLoop | None = None
        self._write_queue: asyncio.Queue[_WriteOperation] | None = None
        self._writer_task: asyncio.Task[None] | None = None
```

```python
    async def initialize(self) -> None:
        loop = asyncio.get_running_loop()
        async with self._initialize_lock:
            if self._loop is not None and self._loop is not loop:
                raise RuntimeError("SharedPreferences must be used on a single event loop")

            if self._cache_initialized:
                return

            self._loop = loop
            self._write_queue = asyncio.Queue()
            self._writer_task = loop.create_task(
                self._writer(), name="shared_preferences_writer"
            )

            preferences = await self.db_helper.get_preferences()
            with self._cache_lock:
                self._cache = {
                    (item.scope, item.scope_id, item.key): deepcopy(item.value["val"])
                    for item in preferences
                }
                self._cache_initialized = True
```

Key changes:

- No `_initializing`, `_pending_writes`, or queue migration from an old loop.
- If someone calls from a different loop, you fail fast with a clear error instead of trying to rebind.

### 2. Single writer task that owns both cache and DB mutations

Instead of `_apply_cache_operation` + `_schedule_write` + `_drain_write_queue`, you can make the writer the single source of truth:

```python
    def _submit_write(self, operation: _WriteOperation) -> None:
        # Caller must have ensured initialize() was called
        if self._write_queue is None:
            raise RuntimeError("SharedPreferences not initialized")

        self._write_queue.put_nowait(operation)
```

```python
    async def _writer(self) -> None:
        assert self._write_queue is not None
        while True:
            action, scope, scope_id, key, value, completion = await self._write_queue.get()
            try:
                # Update cache first
                with self._cache_lock:
                    if action == "put" and key is not None:
                        self._cache[(scope, scope_id, key)] = deepcopy(value)
                    elif action == "remove" and key is not None:
                        self._cache.pop((scope, scope_id, key), None)
                    elif action == "clear":
                        keys = [
                            k for k in self._cache
                            if k[0] == scope and k[1] == scope_id
                        ]
                        for k in keys:
                            self._cache.pop(k, None)

                # Then persist
                if action == "put" and key is not None:
                    await self.db_helper.insert_preference_or_update(
                        scope, scope_id, key, {"val": value}
                    )
                elif action == "remove" and key is not None:
                    await self.db_helper.remove_preference(scope, scope_id, key)
                elif action == "clear":
                    await self.db_helper.clear_preferences(scope, scope_id)
                else:
                    raise ValueError(f"Unknown preference write operation: {action}")

                if completion is not None and not completion.done():
                    completion.set_result(None)
            except Exception as exc:
                logger.error(
                    "Failed to persist shared preference operation %s for %s/%s: %s",
                    action, scope, scope_id, exc, exc_info=True,
                )
                if completion is not None and not completion.done():
                    completion.set_exception(exc)
            finally:
                self._write_queue.task_done()
```

Effects:

- All mutations (cache + DB) are linearized through one queue and one task.
- `_apply_cache_operation` and `_schedule_write` can be removed entirely.
- You no longer need `_initializing` or to replay `_pending_writes` into both cache and queue.

### 3. Simplify `flush()` around the single writer/queue

With the writer always running after initialization, `flush()` becomes simpler:

```python
    async def flush(self) -> None:
        if self._write_queue is None or self._loop is None:
            return

        loop = asyncio.get_running_loop()
        if loop is not self._loop:
            raise RuntimeError("flush() must be called on the owning event loop")

        await self._write_queue.join()
```

No need to:

- Check `loop.is_running()` vs a stopped loop.
- Call `initialize()` from here or handle rebinding.
- Await `_writer_task` each time; the writer runs for the lifetime of the store.

### 4. Deprecated sync APIs: keep behaviour simple and consistent

If the deprecated sync APIs still need to hit the DB (and be consistent with async ones), you can reuse the async pipeline via the main loop instead of direct cache writes:

```python
    @deprecated(...)
    def put(self, key, value, scope: str | None = None, scope_id: str | None = None) -> None:
        if self._loop is None:
            raise RuntimeError("SharedPreferences must be initialized before sync calls")
        op_scope = scope or "unknown"
        op_scope_id = scope_id or "unknown"
        fut = asyncio.run_coroutine_threadsafe(
            self.put_async(op_scope, op_scope_id, key, value),
            self._loop,
        )
        fut.result()
```

Same pattern for `get`, `remove`, `clear`. That avoids:

- Divergent semantics between sync and async paths.
- Additional cache-only state that depends on whether `initialize()`/`flush()` ran.

---

All of the above keeps your new features (async access, in-memory cache, batching writes) but removes the multi-loop and pending-write state machine, which is where most of the incidental complexity comes from.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +125 to +134
async def _drain_write_queue(self) -> None:
"""Persist queued preference mutations in FIFO order."""
queue = self._write_queue
if queue is None:
return
while True:
try:
operation = queue.get_nowait()
except asyncio.QueueEmpty:
return

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.

issue (bug_risk): Queued writes can get stuck if they are enqueued while the writer task is still running but the queue has just become empty.

Because _drain_write_queue uses queue.get_nowait() in a loop and _schedule_write only starts a writer when self._writer_task is None or self._writer_task.done(), there’s a race where the queue becomes empty, the task is still marked running, and a new item is enqueued before _writer_task completes. In that case no new writer task is scheduled and the new item is never processed. To avoid stuck writes, consider making _drain_write_queue a long‑lived consumer using await queue.get() in a loop, exiting only on an explicit shutdown/sentinel, or alternately removing _writer_task and spawning a fresh task per flush.

Comment on lines +218 to +227
def _resolve_using_provider(
self,
provider_type: ProviderType,
umo: str | None,
provider_id: str | None,
) -> Providers | None:
"""获取正在使用的提供商实例。
"""Resolve a provider preference with configuration fallbacks.

Args:
provider_type (ProviderType): 提供商类型。
umo (str, optional): 用户会话 ID,用于提供商会话隔离。
provider_type: Provider type to resolve.

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.

nitpick: The helper method’s docstring advertises a ValueError that is never raised, which may mislead callers.

The _resolve_using_provider docstring claims it may raise ValueError for unsupported provider_type, but the implementation only returns None for disabled/unavailable providers. This contract mismatch can cause callers to handle exceptions that never occur instead of the None case.

Please either add explicit validation that raises on unsupported ProviderType values, or update the docstring to reflect the current behavior (no exceptions, None as the failure mode), especially since both sync and async get_using_provider variants now rely on this helper.

Comment on lines +28 to +31
started = time.monotonic()
store.put("theme", "dark", scope="global", scope_id="global")

assert time.monotonic() - started < 0.1

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.

suggestion (testing): The strict timing assertion in test_sync_put_updates_cache_and_persists_without_blocking may be flaky across environments.

This test encodes a 100ms latency guarantee for store.put, which is likely to be flaky under CI load or on slower systems. Since the goal is to verify non-blocking behavior rather than exact timing, consider either mocking the persistence path so the test only asserts that put returns without awaiting, or relaxing the timing constraint (e.g., relative to a known blocking operation) to avoid spurious failures unrelated to the deadlock fix.

Suggested implementation:

import time

import pytest
import pytest_asyncio
@pytest.mark.asyncio
async def test_sync_put_updates_cache_and_persists_without_blocking(preferences):
    store, database = preferences

    # This test verifies that `put` returns promptly and updates the in-memory cache
    # without asserting a very tight latency bound that could be flaky under CI load.
    started = time.monotonic()
    store.put("theme", "dark", scope="global", scope_id="global")
    elapsed = time.monotonic() - started

    # Allow a more relaxed upper bound to avoid spurious failures while still
    # catching pathological blocking behavior.
    assert elapsed < 0.5
    assert store.get("theme", scope="global", scope_id="global") == "dark"

Depending on how SharedPreferences and the underlying persistence layer are implemented, you may want to further align this test with the non-blocking guarantee by:

  1. Introducing a mock or spy for the persistence method (e.g., the database write call) and asserting that it is invoked asynchronously after put returns, rather than relying on wall-clock timing.
  2. If you adopt that mocking approach, the elapsed assertion could be removed entirely, and the test would instead assert that put does not await the persistence coroutine (for example, by making the mocked persistence artificially slow and confirming that put returns before it completes).
    These adjustments will require access to the actual SharedPreferences implementation and the persistence API to determine the best hook point for mocking.

self._cache_initialized = False
self._initializing = False
self._initialize_lock = asyncio.Lock()
self._loop: asyncio.AbstractEventLoop | None = None

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.

issue (complexity): Consider simplifying SharedPreferences to a single event loop with one writer task and queue, removing multi-loop rebinding logic, pending write handling, and extra state flags.

The main complexity comes from supporting rebinding across multiple event loops and coordinating _pending_writes vs the live _write_queue. If you don’t actually need multi-loop ownership, you can simplify to a single-loop, single-queue model and drop most of the state flags without losing any behaviour.

1. Bind to a single event loop and remove rebinding / _pending_writes

You can bind once in initialize() and enforce a stricter contract:

class SharedPreferences:
    def __init__(self, db_helper: BaseDatabase, json_storage_path=None) -> None:
        ...
        self._cache: dict[tuple[str, str, str], Any] = {}
        self._cache_lock = threading.RLock()
        self._cache_initialized = False

        self._loop: asyncio.AbstractEventLoop | None = None
        self._write_queue: asyncio.Queue[_WriteOperation] | None = None
        self._writer_task: asyncio.Task[None] | None = None
    async def initialize(self) -> None:
        loop = asyncio.get_running_loop()
        async with self._initialize_lock:
            if self._loop is not None and self._loop is not loop:
                raise RuntimeError("SharedPreferences must be used on a single event loop")

            if self._cache_initialized:
                return

            self._loop = loop
            self._write_queue = asyncio.Queue()
            self._writer_task = loop.create_task(
                self._writer(), name="shared_preferences_writer"
            )

            preferences = await self.db_helper.get_preferences()
            with self._cache_lock:
                self._cache = {
                    (item.scope, item.scope_id, item.key): deepcopy(item.value["val"])
                    for item in preferences
                }
                self._cache_initialized = True

Key changes:

  • No _initializing, _pending_writes, or queue migration from an old loop.
  • If someone calls from a different loop, you fail fast with a clear error instead of trying to rebind.

2. Single writer task that owns both cache and DB mutations

Instead of _apply_cache_operation + _schedule_write + _drain_write_queue, you can make the writer the single source of truth:

    def _submit_write(self, operation: _WriteOperation) -> None:
        # Caller must have ensured initialize() was called
        if self._write_queue is None:
            raise RuntimeError("SharedPreferences not initialized")

        self._write_queue.put_nowait(operation)
    async def _writer(self) -> None:
        assert self._write_queue is not None
        while True:
            action, scope, scope_id, key, value, completion = await self._write_queue.get()
            try:
                # Update cache first
                with self._cache_lock:
                    if action == "put" and key is not None:
                        self._cache[(scope, scope_id, key)] = deepcopy(value)
                    elif action == "remove" and key is not None:
                        self._cache.pop((scope, scope_id, key), None)
                    elif action == "clear":
                        keys = [
                            k for k in self._cache
                            if k[0] == scope and k[1] == scope_id
                        ]
                        for k in keys:
                            self._cache.pop(k, None)

                # Then persist
                if action == "put" and key is not None:
                    await self.db_helper.insert_preference_or_update(
                        scope, scope_id, key, {"val": value}
                    )
                elif action == "remove" and key is not None:
                    await self.db_helper.remove_preference(scope, scope_id, key)
                elif action == "clear":
                    await self.db_helper.clear_preferences(scope, scope_id)
                else:
                    raise ValueError(f"Unknown preference write operation: {action}")

                if completion is not None and not completion.done():
                    completion.set_result(None)
            except Exception as exc:
                logger.error(
                    "Failed to persist shared preference operation %s for %s/%s: %s",
                    action, scope, scope_id, exc, exc_info=True,
                )
                if completion is not None and not completion.done():
                    completion.set_exception(exc)
            finally:
                self._write_queue.task_done()

Effects:

  • All mutations (cache + DB) are linearized through one queue and one task.
  • _apply_cache_operation and _schedule_write can be removed entirely.
  • You no longer need _initializing or to replay _pending_writes into both cache and queue.

3. Simplify flush() around the single writer/queue

With the writer always running after initialization, flush() becomes simpler:

    async def flush(self) -> None:
        if self._write_queue is None or self._loop is None:
            return

        loop = asyncio.get_running_loop()
        if loop is not self._loop:
            raise RuntimeError("flush() must be called on the owning event loop")

        await self._write_queue.join()

No need to:

  • Check loop.is_running() vs a stopped loop.
  • Call initialize() from here or handle rebinding.
  • Await _writer_task each time; the writer runs for the lifetime of the store.

4. Deprecated sync APIs: keep behaviour simple and consistent

If the deprecated sync APIs still need to hit the DB (and be consistent with async ones), you can reuse the async pipeline via the main loop instead of direct cache writes:

    @deprecated(...)
    def put(self, key, value, scope: str | None = None, scope_id: str | None = None) -> None:
        if self._loop is None:
            raise RuntimeError("SharedPreferences must be initialized before sync calls")
        op_scope = scope or "unknown"
        op_scope_id = scope_id or "unknown"
        fut = asyncio.run_coroutine_threadsafe(
            self.put_async(op_scope, op_scope_id, key, value),
            self._loop,
        )
        fut.result()

Same pattern for get, remove, clear. That avoids:

  • Divergent semantics between sync and async paths.
  • Additional cache-only state that depends on whether initialize()/flush() ran.

All of the above keeps your new features (async access, in-memory cache, batching writes) but removes the multi-loop and pending-write state machine, which is where most of the incidental complexity comes from.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 12, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
astrbot-docs 0222df3 Commit Preview URL

Branch Preview URL
Aug 12 2026, 04:47 PM

@Soulter
Soulter merged commit e9ae69f into master Aug 12, 2026
21 of 22 checks passed
@Soulter
Soulter deleted the codex/fix-shared-preferences-deadlock branch August 12, 2026 16:44
LIghtJUNction added a commit that referenced this pull request Aug 14, 2026
* chore: update readme

* feat: support PowerShell 7 for Windows local shell (#9622)

* feat(config): add Windows PowerShell version option

* feat(computer): honor Windows PowerShell version in local runtime

* fix(computer): correct stale shell comment for configurable PowerShell

* refactor(computer): inline windows shell resolution per AGENTS.md

* test(computer): assert windows_shell in exec_managed call

* test: make shell tests cross-platform

Co-authored-by: Donoym <prober13c14@gmail.com>

* refactor(computer): auto-detect Windows shell instead of config

* fix(computer): drop stale cmd.exe references after shell auto-detect

* style: apply ruff format and import order

* Update astrbot/core/astr_main_agent.py

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Donoym <prober13c14@gmail.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

* feat: add download-count sorting to plugin marketplace (#9570)

* feat: 插件市场支持按下载量排序

为插件市场现有的前端本地排序补充下载量选项,复用已规范化的 download_count,不改变默认推荐顺序、后端接口和 OpenAPI。

新增稳定且不修改输入的下载量排序工具:支持升降序、未知值双向置底、非法值兜底及相同值原始顺序保留;接入市场排序控件并补齐中、英、俄三语文案。

新增 9 项专项单元测试,覆盖正常排序、零值、缺失与非法值、负数、数字字符串、小数、稳定性和输入不可变。验证通过 45 项 Dashboard Node 测试、额外性质检查、三语 JSON 校验、Vue 类型检查和生产构建。

* Delete dashboard/tests/marketPluginSort.test.mjs

* refactor: 内联插件市场下载量排序

删除独立的市场下载量排序工具,将下载量比较逻辑直接放在现有市场排序分支。

缺失下载量使用默认值 0,保持前端本地排序与现有排序行为一致。

验证:vue-tsc --noEmit;pnpm run build;node --test dashboard/tests/*.test.mjs;git diff --check。

---------

Co-authored-by: C₂₂H₂₅NO₆ <Sisyphbaous-DT-Project@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* fix: improve dark theme text contrast (#9643)

* fix: propagate QQ media upload failures (#9644)

* feat(qqofficial): add chunked file uploads (#9646)

Add a dedicated chunked uploader for large local QQ Official media. Keep C2C and group endpoints explicit, support server-provided part index bases, and retry transient upload operations.

Co-authored-by: Fuyan Yuan <33221728+TheRainstorm@users.noreply.github.com>

* fix(kb): support UUID fallback in get_kb_by_name and warn on missing KBs (#9636)

Fixes #9529.

When kb_names contains UUIDs instead of names, get_kb_by_name() would
return None and check_all_kb() would silently skip retrieval with only
a DEBUG-level log.

Modifications:
- get_kb_by_name() now falls back to UUID lookup when name match fails
- check_all_kb() distinguishes None (not found) from empty KBs and logs
  a WARNING with the count of missing KBs
- Add regression tests covering UUID lookup, name preference, and logging

* fix: prevent SharedPreferences deadlocks (#9649)

* fix: avoid blocking shared preference access

* docs: clarify shared preference cache roles

* docs: explain shared preference cache purpose

* docs: link cache rationale to pull request

* chore: bump version to 4.27.3 (#9650)

* chore: bump version to 4.27.3

* Update changelog for version 4.27.3

Updated changelog for version 4.27.3 with new features, maintenance updates, and bug fixes.

* Refactor: stat service deprecated stats (#9658)

* refactor(dashboard): migrate get_stat off deprecated stats methods

Rewrite StatService.get_stat to query PlatformStat directly via
db_helper.get_db(), following the existing get_provider_token_stats
pattern in the same file, instead of the deprecated
get_base_stats/get_grouped_base_stats/get_total_message_count.

- Windowed rows are fetched once with an explicit ORDER BY timestamp
  (the old get_base_stats relied on insertion order)
- Per-platform sums and hourly time-series buckets are aggregated in
  Python; total message count uses func.coalesce(func.sum(...), 0)
- Response shape is unchanged: platform entries keep the
  {name, count, timestamp} keys, now built as plain dicts so no
  deprecated po.Platform/Stats classes are instantiated
- Verified semantically identical against the old methods with an A/B
  comparison over a seeded database (time series, per-platform sums,
  total count, and empty-window case all match)

* test(dashboard): cover StatService.get_stat aggregation semantics

The existing test_get_stat route test only asserts the HTTP status and
the presence of the platform key, so an aggregation regression would
pass unnoticed. Add focused unit tests that seed PlatformStat rows and
assert the windowed per-platform sums, the global message total, the
hourly time-series bucket shape, the response key set, and the
empty-window behavior.

* feat(provider): add SSYCloud chat completion provider (#9659)

* fix: include JSON cards in group context (#9655)

* fix: include JSON cards in group context

* fix: allow JSON cards to trigger active replies

* docs: add ShengSuanYun provider guide

* fix: detect audio format from file content (#9612)

* feat: log effective Windows local runtime shell (#9648)

* feat: log effective Windows local runtime shell

* chore: remove Windows shell logging tests

---------

Co-authored-by: Soulter <905617992@qq.com>

* refactor(misskey): migrate off deprecated visibility resolver alias (#9674)

---------

Co-authored-by: Soulter <905617992@qq.com>
Co-authored-by: Wei Chengqian <wcqqq1214@gmail.com>
Co-authored-by: Donoym <prober13c14@gmail.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Co-authored-by: C₂₂H₂₅NO₆ <96930391+Sisyphbaous-DT-Project@users.noreply.github.com>
Co-authored-by: C₂₂H₂₅NO₆ <Sisyphbaous-DT-Project@users.noreply.github.com>
Co-authored-by: Fuyan Yuan <33221728+TheRainstorm@users.noreply.github.com>
Co-authored-by: lxfight <1686540385@qq.com>
Co-authored-by: Ruochen Pan <badbatch0x01@gmail.com>
Co-authored-by: xxshu26 <15197757832@163.com>
Co-authored-by: PeiPei233 <49641967+PeiPei233@users.noreply.github.com>
Co-authored-by: xiaoxuan010 <2592053474@qq.com>
Co-authored-by: C10H14N2O5 <100066858+C10H14N2O5@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core The bug / feature is about astrbot's core, backend area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] 后端 event loop 偶发长时间阻塞(最长 5.4 小时),启用大量插件时出现

1 participant