Files
MoviePilot/tests/test_runtime_allocator_release.py
2026-09-28 18:20:24 +08:00

192 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""分配器空闲页归还:按进程实际加载的分配器选择 jemalloc purge 或 glibc malloc_trim,以及线程缓存刷新。"""
from __future__ import annotations
import ctypes
import pytest
from app.runtime import gc as runtime_gc
from app.scheduler.maintenance import SchedulerMaintenanceOwner
class _FakeSymbol:
"""模拟 ctypes 导出函数,记录调用参数与签名设置。"""
def __init__(self, result: int = 0):
self.result = result
self.calls: list[tuple] = []
self.argtypes = None
self.restype = None
def __call__(self, *args):
self.calls.append(args)
return self.result
class _FakeLib:
"""只暴露给定符号的伪 libc,缺失符号与真实 ctypes 一样返回 AttributeError。"""
def __init__(self, **symbols):
self._symbols = symbols
def __getattr__(self, name):
try:
return self._symbols[name]
except KeyError:
raise AttributeError(name) from None
@pytest.fixture(autouse=True)
def _fresh_symbol_resolution():
"""符号解析按进程缓存;每个用例换用不同的伪 libc,前后都清空缓存。"""
runtime_gc._process_libc.cache_clear()
runtime_gc._jemalloc_mallctl.cache_clear()
yield
runtime_gc._process_libc.cache_clear()
runtime_gc._jemalloc_mallctl.cache_clear()
@pytest.fixture
def linux(monkeypatch):
monkeypatch.setattr(runtime_gc.sys, "platform", "linux")
def _use_lib(monkeypatch, lib) -> None:
monkeypatch.setattr(runtime_gc.ctypes, "CDLL", lambda _name: lib)
def test_jemalloc_purges_all_arenas(linux, monkeypatch):
mallctl = _FakeSymbol()
trim = _FakeSymbol()
_use_lib(monkeypatch, _FakeLib(mallctl=mallctl, malloc_trim=trim))
assert runtime_gc.release_allocator_memory() == "jemalloc"
assert mallctl.calls == [(b"arena.4096.purge", None, None, None, 0)]
assert mallctl.restype is ctypes.c_int
# jemalloc 生效时不再调用 glibc 接口
assert trim.calls == []
def test_jemalloc_failure_does_not_fall_back_to_glibc(linux, monkeypatch):
mallctl = _FakeSymbol(result=22)
trim = _FakeSymbol()
_use_lib(monkeypatch, _FakeLib(mallctl=mallctl, malloc_trim=trim))
assert runtime_gc.release_allocator_memory() is None
assert trim.calls == []
def test_glibc_trims_when_jemalloc_absent(linux, monkeypatch):
trim = _FakeSymbol(result=1)
_use_lib(monkeypatch, _FakeLib(malloc_trim=trim))
assert runtime_gc.release_allocator_memory() == "glibc"
assert trim.calls == [(0,)]
def test_unknown_allocator_is_a_no_op(linux, monkeypatch):
_use_lib(monkeypatch, _FakeLib())
assert runtime_gc.release_allocator_memory() is None
def test_non_linux_platform_skips_native_calls(monkeypatch):
monkeypatch.setattr(runtime_gc.sys, "platform", "darwin")
def unexpected(_name):
raise AssertionError("非 Linux 平台不应加载 C 库")
monkeypatch.setattr(runtime_gc.ctypes, "CDLL", unexpected)
assert runtime_gc.release_allocator_memory() is None
def test_full_gc_releases_allocator_after_collect(monkeypatch):
order: list[str] = []
monkeypatch.setattr("app.scheduler.maintenance.gc.collect", lambda: order.append("collect") or 3)
monkeypatch.setattr(
"app.scheduler.maintenance.release_allocator_memory", lambda: order.append("release") or "jemalloc"
)
monkeypatch.setattr("app.scheduler.maintenance.get_memory_usage", lambda: 100.0)
SchedulerMaintenanceOwner.full_gc()
assert order == ["collect", "release"]
def test_thread_cache_flush_uses_jemalloc(linux, monkeypatch):
mallctl = _FakeSymbol()
_use_lib(monkeypatch, _FakeLib(mallctl=mallctl))
assert runtime_gc.flush_thread_allocator_cache() is True
assert mallctl.calls == [(b"thread.tcache.flush", None, None, None, 0)]
def test_thread_cache_flush_resolves_symbols_once(linux, monkeypatch):
mallctl = _FakeSymbol()
loads: list[str] = []
def load(_name):
loads.append("load")
return _FakeLib(mallctl=mallctl)
monkeypatch.setattr(runtime_gc.ctypes, "CDLL", load)
for _ in range(3):
runtime_gc.flush_thread_allocator_cache()
# 线程池每个任务结束都可能调用,C 库只加载一次
assert loads == ["load"]
assert len(mallctl.calls) == 3
def test_thread_cache_flush_is_a_no_op_without_jemalloc(linux, monkeypatch):
trim = _FakeSymbol()
_use_lib(monkeypatch, _FakeLib(malloc_trim=trim))
assert runtime_gc.flush_thread_allocator_cache() is False
assert trim.calls == []
def test_background_thread_enabled_at_runtime(linux, monkeypatch):
"""未配置后台线程时在主进程内开启,先设上限再打开开关,子进程环境保持不变。"""
monkeypatch.setenv("MALLOC_CONF", "narenas:8,dirty_decay_ms:5000")
mallctl = _FakeSymbol()
_use_lib(monkeypatch, _FakeLib(mallctl=mallctl))
assert runtime_gc.configure_allocator_background_thread() is True
assert [call[0] for call in mallctl.calls] == [b"max_background_threads", b"background_thread"]
assert all(call[1] is None and call[2] is None for call in mallctl.calls)
assert runtime_gc.os.environ["MALLOC_CONF"] == "narenas:8,dirty_decay_ms:5000"
def test_inherited_background_thread_conf_is_stripped(linux, monkeypatch):
"""v3.0.10 镜像把后台线程写进了 MALLOC_CONF;主进程已按其生效,只需从子进程将继承的环境中去掉。"""
monkeypatch.setenv(
"MALLOC_CONF",
"background_thread:true,max_background_threads:1,narenas:8,dirty_decay_ms:5000,muzzy_decay_ms:0",
)
mallctl = _FakeSymbol()
_use_lib(monkeypatch, _FakeLib(mallctl=mallctl))
assert runtime_gc.configure_allocator_background_thread() is False
assert mallctl.calls == []
assert runtime_gc.os.environ["MALLOC_CONF"] == "narenas:8,dirty_decay_ms:5000,muzzy_decay_ms:0"
def test_conf_with_only_background_thread_is_removed(linux, monkeypatch):
monkeypatch.setenv("MALLOC_CONF", "background_thread:false")
_use_lib(monkeypatch, _FakeLib(mallctl=_FakeSymbol()))
assert runtime_gc.configure_allocator_background_thread() is False
assert "MALLOC_CONF" not in runtime_gc.os.environ
def test_background_thread_is_a_no_op_without_jemalloc(linux, monkeypatch):
monkeypatch.delenv("MALLOC_CONF", raising=False)
_use_lib(monkeypatch, _FakeLib(malloc_trim=_FakeSymbol()))
assert runtime_gc.configure_allocator_background_thread() is False