/
/
/
1"""Tests for the Profiler plugin provider."""
2
3from __future__ import annotations
4
5import logging
6import tracemalloc
7from pathlib import Path
8from typing import TYPE_CHECKING
9from unittest import mock
10
11import pytest
12import yappi
13from music_assistant_models.media_items import Artist, ProviderMapping
14
15from music_assistant.providers.profiler import provider as provider_module
16from music_assistant.providers.profiler.helpers import (
17 LogErrorCounter,
18 collect_object_census,
19 render_markdown,
20 sanitize_code_path,
21)
22from music_assistant.providers.profiler.provider import (
23 CONF_TRACEMALLOC_ENABLED,
24 ProfilerProvider,
25)
26
27if TYPE_CHECKING:
28 from music_assistant.mass import MusicAssistant
29
30
31@pytest.fixture
32async def profiler(mass: MusicAssistant) -> ProfilerProvider:
33 """Load the profiler provider into a running Music Assistant instance."""
34 await mass.config._create_provider_instance("profiler", {})
35 provider = mass.get_provider("profiler", provider_type=ProfilerProvider)
36 assert provider is not None
37 await provider.initialized.wait()
38 return provider
39
40
41async def test_library_counts_ignore_requesting_user_provider_filter(
42 profiler: ProfilerProvider,
43 mass: MusicAssistant,
44) -> None:
45 """Test that the report's library counts are true totals, not the admin's filtered view."""
46 await mass.music.artists.add_item_to_library(
47 Artist(
48 item_id="0",
49 provider="library",
50 name="Census Artist",
51 provider_mappings={
52 ProviderMapping(
53 item_id="census_artist",
54 provider_domain="prov_a",
55 provider_instance="prov_a_inst",
56 in_library=True,
57 )
58 },
59 )
60 )
61 with mock.patch(
62 "music_assistant.controllers.music.media.base.get_current_user",
63 return_value=mock.Mock(provider_filter=["no_such_provider"]),
64 ):
65 counts = await profiler._get_library_counts()
66 # the seeded artist has no mapping on the filtered provider, so a user-scoped count
67 # would report 0 for it
68 assert counts == {
69 "artist": 1,
70 "album": 0,
71 "track": 0,
72 "playlist": 0,
73 "radio": 0,
74 "audiobook": 0,
75 "podcast": 0,
76 }
77
78
79async def test_report_shape(profiler: ProfilerProvider) -> None:
80 """Test that the report contains all sections with sane, bounded content."""
81 report = await profiler.get_report()
82 assert isinstance(report, dict)
83 for section in (
84 "server",
85 "config_summary",
86 "memory",
87 "event_loop",
88 "asyncio_tasks",
89 "events",
90 "log_errors",
91 "flight_recorder",
92 ):
93 assert section in report, f"missing section: {section}"
94 assert report["report_format_version"] == 1
95 assert report["server"]["uptime_s"] >= 0
96 assert report["memory"]["rss_mb"] > 0
97 assert report["memory"]["asyncio_tasks"] > 0
98 assert "library_counts" in report["config_summary"]
99 assert report["asyncio_tasks"]["total"] > 0
100 assert len(report["asyncio_tasks"]["top_by_location"]) <= 50
101 assert len(report["events"]["per_type_top"]) <= 30
102 assert report["flight_recorder"]["window_minutes"] == 30
103 # no CPU profile window has completed yet
104 assert report["cpu_profile"] is None
105 # report files are persisted in the profiler storage dir
106 out_files = {path.name for path in Path(profiler._out_dir).iterdir()}
107 assert {"report.json", "report.md"} <= out_files
108
109
110async def test_report_markdown(profiler: ProfilerProvider) -> None:
111 """Test that the markdown rendering of the report is returned as text."""
112 report_md = await profiler.get_report(markdown=True)
113 assert isinstance(report_md, str)
114 assert report_md.startswith("# Music Assistant profiler report")
115 assert "## memory" in report_md
116
117
118async def test_object_census(profiler: ProfilerProvider) -> None:
119 """Test that the object census is only included on request and bounded."""
120 report = await profiler.get_report()
121 assert isinstance(report, dict)
122 assert "object_census_top" not in report["memory"]
123 report = await profiler.get_report(include_object_census=True)
124 assert isinstance(report, dict)
125 census = report["memory"]["object_census_top"]
126 assert 0 < len(census) <= 30
127 assert set(census[0]) == {"type", "count"}
128
129
130async def test_cpu_profile_window(
131 profiler: ProfilerProvider, monkeypatch: pytest.MonkeyPatch
132) -> None:
133 """Test that a CPU profile window captures results and stops yappi."""
134 monkeypatch.setattr(provider_module, "CPU_PROFILE_MIN_DURATION", 1)
135 with mock.patch.object(profiler, "get_config_value", return_value=1):
136 await profiler._run_cpu_profile_window()
137 assert not yappi.is_running()
138 result = profiler._last_cpu_profile
139 assert result is not None
140 assert result["clock_type"] == "cpu"
141 assert result["top_functions"]
142 assert len(result["top_functions"]) <= 40
143 assert {"name", "location", "ncall", "tsub_s", "ttot_s", "tavg_ms"} == set(
144 result["top_functions"][0]
145 )
146 # the pstats file was written for offline analysis
147 out_files = {path.name for path in Path(profiler._out_dir).iterdir()}
148 assert result["pstats_file"] in out_files
149 report = await profiler.get_report()
150 assert isinstance(report, dict)
151 assert report["cpu_profile"] == result
152
153
154async def test_measurement_tasks_running(profiler: ProfilerProvider) -> None:
155 """Test that the continuous measurement tasks are tracked in mass."""
156 mass = profiler.mass
157 assert "profiler_lag_monitor" in mass._tracked_tasks
158 assert "profiler_flight_recorder" in mass._tracked_tasks
159 assert "profiler_cpu_scheduler" in mass._tracked_tasks
160 assert "profiler/report" in mass.command_handlers
161
162
163async def test_unload_cleans_up(profiler: ProfilerProvider) -> None:
164 """Test that unload cancels all tasks and removes all hooks."""
165 mass = profiler.mass
166 log_counter = profiler._log_counter
167 await mass.unload_provider(profiler.instance_id)
168 assert not any(task_id.startswith("profiler_") for task_id in mass._tracked_tasks)
169 assert "profiler/report" not in mass.command_handlers
170 assert log_counter not in logging.getLogger().handlers
171 assert not yappi.is_running()
172
173
174async def test_tracemalloc_lifecycle(mass: MusicAssistant) -> None:
175 """Test that tracemalloc is started/stopped with the provider when enabled."""
176 was_tracing = tracemalloc.is_tracing()
177 await mass.config._create_provider_instance("profiler", {CONF_TRACEMALLOC_ENABLED: True})
178 provider = mass.get_provider("profiler", provider_type=ProfilerProvider)
179 assert provider is not None
180 await provider.initialized.wait()
181 assert tracemalloc.is_tracing()
182 report = await provider.get_report()
183 assert isinstance(report, dict)
184 tm_stats = report["memory"]["tracemalloc"]
185 assert tm_stats["top_allocation_sites"]
186 assert len(tm_stats["top_allocation_sites"]) <= 30
187 await mass.unload_provider(provider.instance_id)
188 assert tracemalloc.is_tracing() == was_tracing
189
190
191def test_sanitize_code_path() -> None:
192 """Test that code paths are stripped of user-specific parts."""
193 assert (
194 sanitize_code_path("/home/user/.venv/lib/python3.14/site-packages/aiohttp/web.py")
195 == "aiohttp/web.py"
196 )
197 assert (
198 sanitize_code_path("/Users/someone/repo/music_assistant/mass.py")
199 == "music_assistant/mass.py"
200 )
201 assert sanitize_code_path("/usr/local/lib/python3.14/asyncio/tasks.py").startswith("python3.14")
202 assert sanitize_code_path("<frozen importlib._bootstrap>") == "<frozen importlib._bootstrap>"
203
204
205def test_log_error_counter() -> None:
206 """Test that the log counter aggregates without storing message content."""
207 counter = LogErrorCounter()
208 logger = logging.getLogger("test.profiler.dummy")
209 record = logger.makeRecord(
210 "test.profiler.dummy",
211 logging.ERROR,
212 "/app/music_assistant/mass.py",
213 1,
214 "secret %s",
215 ("arg",),
216 None,
217 )
218 counter.emit(record)
219 counter.emit(record)
220 # records are keyed by code location: logger names may embed user-set names or device ids
221 record_with_id = logger.makeRecord(
222 "music_assistant.Kitchen Sonos",
223 logging.WARNING,
224 "/app/music_assistant/providers/sonos/player.py",
225 42,
226 "msg",
227 (),
228 None,
229 )
230 counter.emit(record_with_id)
231 summary = counter.summarize()
232 assert summary["total_since_load"] == 3
233 assert summary["top"][0]["source"] == "music_assistant/mass.py:1"
234 assert summary["top"][0]["count"] == 2
235 assert summary["top"][1]["source"] == "music_assistant/providers/sonos/player.py:42"
236 assert "secret" not in str(summary)
237 assert "Kitchen" not in str(summary)
238
239
240def test_render_markdown() -> None:
241 """Test the markdown renderer with nested sections and tables."""
242 report = {
243 "report_format_version": 1,
244 "server": {"version": "x", "nested": {"a": 1}},
245 "memory": {"rss_mb": 1.0, "sites": [{"location": "a.py:1", "size_kb": 2}]},
246 }
247 text = render_markdown(report)
248 assert "# Music Assistant profiler report" in text
249 assert "## server" in text
250 assert "| location | size_kb |" in text
251
252
253def test_object_census_bounds() -> None:
254 """Test the census helper directly for bounds."""
255 census = collect_object_census(top_n=5)
256 assert len(census) == 5
257 assert all(entry["count"] > 0 for entry in census)
258