/
/
/
1"""
2Tests for the debounced save of the persistent settings storage.
3
4Saves are debounced by a timer, so on server stop the controller has to decide
5whether anything still needs writing: skip the write when the data on disk is
6already up to date, but never skip one that is genuinely still pending.
7"""
8
9from __future__ import annotations
10
11import asyncio
12import json
13import threading
14from collections.abc import AsyncIterator, Callable, Coroutine
15from contextlib import asynccontextmanager
16from pathlib import Path
17from types import SimpleNamespace
18from typing import Any
19from unittest.mock import AsyncMock, patch
20
21from music_assistant.controllers.config.controller import ConfigController
22
23
24class _FakeMass:
25 """Minimal MusicAssistant stub that tracks every task the controller creates."""
26
27 def __init__(self, storage_path: Path) -> None:
28 self.storage_path = str(storage_path)
29 self.tasks: list[asyncio.Task[Any]] = []
30 self._loop = asyncio.get_running_loop()
31 self.loop = SimpleNamespace(call_later=self._loop.call_later, create_task=self._track)
32
33 def create_task(
34 self, target: Callable[..., Coroutine[Any, Any, Any]], *args: Any, **kwargs: Any
35 ) -> asyncio.Task[Any]:
36 """Create a task from a coroutine function, as the real server does."""
37 return self._track(target(*args, **kwargs))
38
39 def _track(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
40 # eager start, like the real server: the save already runs up to its first
41 # suspension before the caller gets the task back
42 task = asyncio.Task(coro, loop=self._loop, eager_start=True)
43 self.tasks.append(task)
44 return task
45
46
47def _make_controller(tmp_path: Path) -> tuple[ConfigController, _FakeMass]:
48 mass = _FakeMass(tmp_path)
49 controller = ConfigController(mass) # type: ignore[arg-type]
50 controller.initialized = True
51 return controller, mass
52
53
54def _set_without_delay(controller: ConfigController, key: str, value: Any) -> None:
55 """Change a setting with the debounce delay reduced to zero."""
56 with patch("music_assistant.controllers.config.controller.DEFAULT_SAVE_DELAY", 0):
57 controller.set(key, value)
58
59
60async def _wait_for_save_task(mass: _FakeMass) -> None:
61 """Wait until the save timer has fired and started its task."""
62 async with asyncio.timeout(5):
63 while not mass.tasks:
64 await asyncio.sleep(0)
65
66
67async def _await_save_task(mass: _FakeMass) -> None:
68 """Wait for the scheduled save to run to completion."""
69 await _wait_for_save_task(mass)
70 await asyncio.gather(*mass.tasks)
71 mass.tasks.clear()
72
73
74async def _stalled_json_dumps(*_args: Any, **_kwargs: Any) -> str:
75 """Stand in for the serialization step of a save that is still busy when the server stops."""
76 await asyncio.Event().wait()
77 return ""
78
79
80@asynccontextmanager
81async def _change_made_while_saving(
82 controller: ConfigController, mass: _FakeMass
83) -> AsyncIterator[None]:
84 """
85 Change a setting while a save is writing, and leave that save finished.
86
87 The change lands after the running save took its snapshot, so it is not part of
88 that write, and the save clears the pending marker on its way out.
89 """
90 save_to_disk = controller._save_to_disk
91 writing = threading.Event()
92 release = threading.Event()
93
94 def blocking_save_to_disk(json_data: str) -> None:
95 writing.set()
96 assert release.wait(5), "the test never released the save"
97 save_to_disk(json_data)
98
99 with patch.object(controller, "_save_to_disk", new=blocking_save_to_disk):
100 controller.set("first", 1, immediate=True)
101 assert await asyncio.to_thread(writing.wait, 5), "the save never reached the write"
102 controller.set("second", 2)
103 release.set()
104 await asyncio.gather(*mass.tasks)
105 mass.tasks.clear()
106 yield
107
108
109async def _cancel_save_tasks(mass: _FakeMass) -> None:
110 """Cancel the running save tasks, as the server does on stop."""
111 for task in mass.tasks:
112 task.cancel()
113 await asyncio.gather(*mass.tasks, return_exceptions=True)
114 mass.tasks.clear()
115
116
117async def test_close_skips_save_when_nothing_changed(tmp_path: Path) -> None:
118 """A stop without config changes may not rewrite the settings file."""
119 controller, mass = _make_controller(tmp_path)
120 _set_without_delay(controller, "generation", 1)
121 await _await_save_task(mass)
122
123 with patch.object(controller, "_save_to_disk") as save_to_disk:
124 await controller.close()
125
126 save_to_disk.assert_not_called()
127 assert json.loads(Path(controller.filename).read_text()) == {"generation": 1}
128
129
130async def test_save_timer_handle_is_cleared_when_it_fires(tmp_path: Path) -> None:
131 """The timer handle may not outlive the timer it belongs to."""
132 controller, mass = _make_controller(tmp_path)
133 _set_without_delay(controller, "generation", 1)
134 await _await_save_task(mass)
135
136 assert controller._timer_handle is None
137
138
139async def test_close_skips_save_after_immediate_save(tmp_path: Path) -> None:
140 """An immediate save leaves nothing behind for the stop to write."""
141 controller, mass = _make_controller(tmp_path)
142 controller.set("generation", 1, immediate=True)
143 await _await_save_task(mass)
144
145 with patch.object(controller, "_save_to_disk") as save_to_disk:
146 await controller.close()
147
148 save_to_disk.assert_not_called()
149 assert json.loads(Path(controller.filename).read_text()) == {"generation": 1}
150
151
152async def test_close_skips_save_after_startup_migration(tmp_path: Path) -> None:
153 """A migration during load writes the settings file, so the stop must not repeat it."""
154 controller, _ = _make_controller(tmp_path)
155 Path(controller.filename).write_text(json.dumps({"generation": 1}))
156 with patch(
157 "music_assistant.controllers.config.controller.migrate", new=AsyncMock(return_value=True)
158 ):
159 await controller._load()
160
161 with patch.object(controller, "_save_to_disk") as save_to_disk:
162 await controller.close()
163
164 save_to_disk.assert_not_called()
165
166
167async def test_close_saves_change_that_is_still_debounced(tmp_path: Path) -> None:
168 """A change made within the debounce delay must survive a stop."""
169 controller, mass = _make_controller(tmp_path)
170 controller.set("generation", 1)
171
172 await controller.close()
173
174 assert not mass.tasks
175 assert json.loads(Path(controller.filename).read_text()) == {"generation": 1}
176
177
178async def test_cancelled_save_does_not_race_the_next_writer(tmp_path: Path) -> None:
179 """
180 A save cancelled mid-write may not leave a second writer racing it.
181
182 Cancelling a save does not stop the worker thread it handed the write to, so the
183 save that follows writes the same file at the same time as one that is on its way
184 out - and between them they used to leave no settings file at all.
185 """
186 controller, mass = _make_controller(tmp_path)
187 controller.set("generation", 1, immediate=True)
188 await _cancel_save_tasks(mass)
189
190 await controller.close()
191
192 assert json.loads(Path(controller.filename).read_text()) == {"generation": 1}
193 assert not Path(f"{controller.filename}.tmp").exists()
194
195
196async def test_close_saves_change_whose_save_task_was_cancelled(tmp_path: Path) -> None:
197 """
198 A change must survive a stop that cancels the save task before closing.
199
200 The server cancels all tracked tasks before it closes the controllers, so a
201 save that was already scheduled is cancelled and can only complete here.
202 """
203 controller, mass = _make_controller(tmp_path)
204 with patch(
205 "music_assistant.controllers.config.controller.async_json_dumps", new=_stalled_json_dumps
206 ):
207 _set_without_delay(controller, "generation", 1)
208 await _wait_for_save_task(mass)
209 await _cancel_save_tasks(mass)
210 assert not Path(controller.filename).exists()
211
212 await controller.close()
213
214 assert json.loads(Path(controller.filename).read_text()) == {"generation": 1}
215
216
217async def test_close_saves_immediate_save_that_was_cancelled(tmp_path: Path) -> None:
218 """An immediate save that did not finish before the stop must still be written."""
219 controller, mass = _make_controller(tmp_path)
220 with patch(
221 "music_assistant.controllers.config.controller.async_json_dumps", new=_stalled_json_dumps
222 ):
223 controller.set("generation", 1, immediate=True)
224 await _cancel_save_tasks(mass)
225 assert not Path(controller.filename).exists()
226
227 await controller.close()
228
229 assert json.loads(Path(controller.filename).read_text()) == {"generation": 1}
230
231
232async def test_close_saves_change_whose_save_failed(tmp_path: Path) -> None:
233 """A save that could not be written must be retried on stop."""
234 controller, mass = _make_controller(tmp_path)
235 with patch.object(controller, "_save_to_disk", side_effect=OSError("no space left on device")):
236 _set_without_delay(controller, "generation", 1)
237 await _wait_for_save_task(mass)
238 await asyncio.gather(*mass.tasks, return_exceptions=True)
239 mass.tasks.clear()
240 assert not Path(controller.filename).exists()
241
242 await controller.close()
243
244 assert json.loads(Path(controller.filename).read_text()) == {"generation": 1}
245
246
247async def test_close_cancels_the_pending_save_timer(tmp_path: Path) -> None:
248 """The stop may not leave a timer behind that still fires a save afterwards."""
249 controller, mass = _make_controller(tmp_path)
250 _set_without_delay(controller, "generation", 1)
251
252 await controller.close()
253 await asyncio.sleep(0)
254
255 assert controller._timer_handle is None
256 assert not mass.tasks
257
258
259async def test_close_saves_change_made_while_a_save_was_writing(tmp_path: Path) -> None:
260 """A change made after a save took its snapshot is not in that write, so the stop must do it."""
261 controller, mass = _make_controller(tmp_path)
262
263 async with _change_made_while_saving(controller, mass):
264 pass
265
266 await controller.close()
267
268 assert json.loads(Path(controller.filename).read_text()) == {"first": 1, "second": 2}
269
270
271async def test_close_saves_change_whose_save_waited_for_a_running_one(tmp_path: Path) -> None:
272 """
273 A change whose save queued up behind a running save must survive a stop.
274
275 The running save finishes first and marks the settings saved, but its write was
276 prepared before the change, so the queued save is the one that still owes a write.
277 """
278 controller, mass = _make_controller(tmp_path)
279 save_to_disk = controller._save_to_disk
280 writing = threading.Event()
281 release = threading.Event()
282
283 def blocking_save_to_disk(json_data: str) -> None:
284 writing.set()
285 assert release.wait(5), "the test never released the save"
286 save_to_disk(json_data)
287
288 with patch.object(controller, "_save_to_disk", new=blocking_save_to_disk):
289 controller.set("first", 1, immediate=True)
290 assert await asyncio.to_thread(writing.wait, 5), "the save never reached the write"
291 running = mass.tasks.pop()
292 with patch(
293 "music_assistant.controllers.config.controller.async_json_dumps",
294 new=_stalled_json_dumps,
295 ):
296 controller.set("second", 2, immediate=True)
297 release.set()
298 await running
299 await _cancel_save_tasks(mass)
300
301 await controller.close()
302
303 assert json.loads(Path(controller.filename).read_text()) == {"first": 1, "second": 2}
304
305
306async def test_close_saves_that_change_once_its_timer_has_fired(tmp_path: Path) -> None:
307 """
308 The same change must also survive once its debounce timer has already fired.
309
310 From that point the timer handle is gone too, so the save it started is the only
311 remaining record that the change is not on disk - and the stop cancels it.
312 """
313 controller, mass = _make_controller(tmp_path)
314
315 async with _change_made_while_saving(controller, mass):
316 assert controller._save_written == controller._save_requested - 1
317 assert controller._timer_handle is not None
318
319 with patch(
320 "music_assistant.controllers.config.controller.async_json_dumps", new=_stalled_json_dumps
321 ):
322 controller._timer_handle.cancel()
323 controller._start_save()
324 await _cancel_save_tasks(mass)
325
326 await controller.close()
327
328 assert json.loads(Path(controller.filename).read_text()) == {"first": 1, "second": 2}
329