Skip to content

Commit c81caf7

Browse files
authored
Merge pull request #3713 from rommapp/posthog-code/fix-config-parse-error-surfacing
fix: surface config.yml parse errors instead of silently using defaults
2 parents d200fa4 + 2a65371 commit c81caf7

27 files changed

Lines changed: 180 additions & 3 deletions

File tree

backend/config/config_manager.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ class NetplayICEServer(TypedDict):
107107
class Config:
108108
CONFIG_FILE_MOUNTED: bool
109109
CONFIG_FILE_WRITABLE: bool
110+
CONFIG_FILE_PARSE_ERROR: str | None
110111
EXCLUDED_PLATFORMS: list[str]
111112
EXCLUDED_SINGLE_EXT: list[str]
112113
EXCLUDED_SINGLE_FILES: list[str]
@@ -174,6 +175,7 @@ class ConfigManager:
174175
_raw_config: dict = {}
175176
_config_file_mounted: bool = False
176177
_config_file_writable: bool = False
178+
_config_file_parse_error: str | None = None
177179

178180
def __new__(cls, *args, **kwargs):
179181
if cls._self is None:
@@ -208,13 +210,18 @@ def _safe_load_yaml(self, cf) -> dict:
208210
"""Load YAML, falling back to an empty config on syntax errors so the
209211
app can still boot with defaults rather than crashing."""
210212
try:
211-
return yaml.load(cf, Loader=SafeLoader) or {}
213+
config = yaml.load(cf, Loader=SafeLoader) or {}
214+
self._config_file_parse_error = None
215+
return config
212216
except yaml.YAMLError as exc:
213217
log.critical(
214218
f"Failed to parse {hl(self.config_file, BLUE)}: {exc}. "
215219
"Falling back to default configuration, fix the YAML "
216220
"syntax to apply your settings."
217221
)
222+
# Keep the error around so it can be surfaced in the UI, since the
223+
# whole config (not just the broken part) is discarded here.
224+
self._config_file_parse_error = str(exc)
218225
return {}
219226

220227
def _create_missing_config_file(self) -> None:
@@ -230,6 +237,7 @@ def _create_missing_config_file(self) -> None:
230237
# Reset any previously loaded singleton state so parsing reflects
231238
# the newly created empty config file.
232239
self._raw_config = {}
240+
self._config_file_parse_error = None
233241
self._config_file_mounted = True
234242
self._config_file_writable = os.access(self.config_file, os.W_OK)
235243
except PermissionError:
@@ -287,6 +295,7 @@ def _parse_config(self):
287295
self.config = Config(
288296
CONFIG_FILE_MOUNTED=self._config_file_mounted,
289297
CONFIG_FILE_WRITABLE=self._config_file_writable,
298+
CONFIG_FILE_PARSE_ERROR=self._config_file_parse_error,
290299
EXCLUDED_PLATFORMS=sorted(
291300
{
292301
*DEFAULT_EXCLUDED_DIRS,
@@ -723,6 +732,8 @@ def get_config(self) -> Config:
723732
self._raw_config = self._safe_load_yaml(config_file)
724733
except FileNotFoundError:
725734
log.debug("Config file not found!")
735+
# No file to parse, so clear any stale parse error from a prior load.
736+
self._config_file_parse_error = None
726737

727738
self._parse_config()
728739
self._validate_config()

backend/endpoints/configs.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ def get_config(request: Request) -> ConfigResponse:
4242
return ConfigResponse(
4343
CONFIG_FILE_MOUNTED=cfg.CONFIG_FILE_MOUNTED,
4444
CONFIG_FILE_WRITABLE=cfg.CONFIG_FILE_WRITABLE,
45+
# Raw parser error may leak the config file path, so only send when authenticated
46+
CONFIG_FILE_PARSE_ERROR=(
47+
cfg.CONFIG_FILE_PARSE_ERROR if request.user.is_authenticated else None
48+
),
4549
EXCLUDED_PLATFORMS=cfg.EXCLUDED_PLATFORMS,
4650
EXCLUDED_SINGLE_EXT=cfg.EXCLUDED_SINGLE_EXT,
4751
EXCLUDED_SINGLE_FILES=cfg.EXCLUDED_SINGLE_FILES,

backend/endpoints/responses/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
class ConfigResponse(TypedDict):
77
CONFIG_FILE_MOUNTED: bool
88
CONFIG_FILE_WRITABLE: bool
9+
CONFIG_FILE_PARSE_ERROR: str | None
910
EXCLUDED_PLATFORMS: list[str]
1011
EXCLUDED_SINGLE_EXT: list[str]
1112
EXCLUDED_SINGLE_FILES: list[str]

backend/tests/config/test_config_loader.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,74 @@ def test_malformed_yaml_falls_back_to_defaults():
160160
assert loader.config.ROMS_FOLDER_NAME == "roms"
161161
assert loader.config.FIRMWARE_FOLDER_NAME == "bios"
162162
assert loader.config.SCAN_MEDIA == ["box2d", "screenshot", "manual"]
163+
# The parse error is surfaced so the UI can warn the user their whole
164+
# config (not just the broken part) was discarded.
165+
assert loader.config.CONFIG_FILE_PARSE_ERROR is not None
166+
167+
168+
def test_valid_config_has_no_parse_error():
169+
"""A syntactically valid config should not report a parse error."""
170+
loader = ConfigManager(
171+
os.path.join(Path(__file__).resolve().parent, "fixtures", "config/config.yml")
172+
)
173+
174+
assert loader.config.CONFIG_FILE_PARSE_ERROR is None
175+
176+
177+
def test_mixed_gamelist_syntax_surfaces_parse_error(tmp_path):
178+
"""Regression for #3708: mixing the old (`scan.export`) and new
179+
(`scan.gamelist.export`) syntax produces invalid YAML. The whole config is
180+
discarded and defaults are used, so the parse error must be surfaced rather
181+
than silently swallowed."""
182+
config_file = tmp_path / "config.yml"
183+
config_file.write_text(
184+
"scan:\n"
185+
" gamelist:\n"
186+
" export: true\n"
187+
" - gamelist_xml: true\n"
188+
" media:\n"
189+
" - box2d\n"
190+
" - video\n"
191+
" - manual\n"
192+
)
193+
194+
loader = ConfigManager(str(config_file))
195+
196+
assert loader.config.CONFIG_FILE_PARSE_ERROR is not None
197+
# The user's scan.media list is lost, falling back to defaults.
198+
assert loader.config.SCAN_MEDIA == ["box2d", "screenshot", "manual"]
199+
200+
201+
def test_parse_error_is_cleared_when_config_is_missing(tmp_path):
202+
"""A stale parse error from a malformed config must not persist once the
203+
file is gone. The loader is a singleton, so a later reload that skips YAML
204+
parsing (e.g. the file was deleted) has to clear the flag."""
205+
config_file = tmp_path / "config.yml"
206+
config_file.write_text("scan:\n - broken: true\n media: [box2d]\n")
207+
208+
loader = ConfigManager(str(config_file))
209+
assert loader.config.CONFIG_FILE_PARSE_ERROR is not None
210+
211+
# get_config's FileNotFoundError branch must clear the stale error.
212+
config_file.unlink()
213+
loader.get_config()
214+
assert loader.config.CONFIG_FILE_PARSE_ERROR is None
215+
216+
217+
def test_parse_error_is_cleared_when_config_is_recreated(tmp_path):
218+
"""Reloading via a missing file goes through _create_missing_config_file,
219+
which must also clear a stale parse error from a prior malformed load."""
220+
broken_file = tmp_path / "config.yml"
221+
broken_file.write_text("scan:\n - broken: true\n media: [box2d]\n")
222+
223+
ConfigManager(str(broken_file))
224+
225+
# Reusing the singleton with a missing path exercises the __init__
226+
# FileNotFoundError -> _create_missing_config_file recreate path.
227+
broken_file.unlink()
228+
loader = ConfigManager(str(broken_file))
229+
230+
assert loader.config.CONFIG_FILE_PARSE_ERROR is None
163231

164232

165233
def test_config_updates_serialize_gamelist_media_as_plain_strings(tmp_path):

backend/tests/endpoints/test_config.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,28 @@ def test_config(client):
3131
assert config.get("GAMELIST_MEDIA_IMAGE") == "screenshot"
3232

3333

34+
def test_config_parse_error_gated_by_auth(client, access_token: str):
35+
# The raw parser error can leak the config file path, so it is only
36+
# returned to authenticated users.
37+
cfg = cm.get_config()
38+
cfg.CONFIG_FILE_PARSE_ERROR = 'in "/data/config.yml", line 2'
39+
40+
with patch.object(cm, "get_config", return_value=cfg):
41+
anon = client.get("/api/config")
42+
assert anon.status_code == status.HTTP_200_OK
43+
assert anon.json().get("CONFIG_FILE_PARSE_ERROR") is None
44+
45+
auth = client.get(
46+
"/api/config",
47+
headers={"Authorization": f"Bearer {access_token}"},
48+
)
49+
assert auth.status_code == status.HTTP_200_OK
50+
assert (
51+
auth.json().get("CONFIG_FILE_PARSE_ERROR")
52+
== 'in "/data/config.yml", line 2'
53+
)
54+
55+
3456
def test_add_platform_binding_payload_shape(client, access_token: str):
3557
with patch.object(cm, "add_platform_binding") as add_platform_binding:
3658
response = client.post(

frontend/src/__generated__/models/ConfigResponse.ts

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/locales/bg_BG/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
"config-file-not-mounted-title": "Конфигурационният файл не е монтиран!",
4444
"config-file-not-writable-desc": "Файлът config.yml не е записваем. Промените в конфигурацията няма да се запазят след рестартиране на приложението.",
4545
"config-file-not-writable-title": "Конфигурационният файл не е записваем!",
46+
"config-file-parse-error-desc": "The config.yml file has a syntax error, so RomM is running with default settings and ignoring your configuration. Fix the error and restart RomM to apply your settings. Details: {error}",
47+
"config-file-parse-error-title": "Configuration file could not be parsed!",
4648
"config-tab": "Конфигурация",
4749
"confirm-delete-mapping": "Потвърди?",
4850
"continue-playing-as-grid": "Продължи игра като решетка",

frontend/src/locales/cs_CZ/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
"config-file-not-mounted-title": "Konfigurační soubor není připojen!",
4444
"config-file-not-writable-desc": "Soubor config.yml nelze zapisovat. Veškeré změny provedené v konfiguraci se po restartování aplikace neuchovají.",
4545
"config-file-not-writable-title": "Konfigurační soubor nelze zapisovat!",
46+
"config-file-parse-error-desc": "The config.yml file has a syntax error, so RomM is running with default settings and ignoring your configuration. Fix the error and restart RomM to apply your settings. Details: {error}",
47+
"config-file-parse-error-title": "Configuration file could not be parsed!",
4648
"config-tab": "Konfigurace",
4749
"confirm-delete-mapping": "Potvrzujete?",
4850
"continue-playing-as-grid": "Pokračovat ve hraní jako mřížka",

frontend/src/locales/de_DE/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
"config-file-not-mounted-title": "Konfigurationsdatei nicht eingebunden!",
4444
"config-file-not-writable-desc": "Die config.yml-Datei ist nicht beschreibbar. Alle an der Konfiguration vorgenommenen Änderungen werden nach dem Neustart der Anwendung nicht beibehalten.",
4545
"config-file-not-writable-title": "Konfigurationsdatei nicht beschreibbar!",
46+
"config-file-parse-error-desc": "The config.yml file has a syntax error, so RomM is running with default settings and ignoring your configuration. Fix the error and restart RomM to apply your settings. Details: {error}",
47+
"config-file-parse-error-title": "Configuration file could not be parsed!",
4648
"config-tab": "Konfiguration",
4749
"confirm-delete-mapping": "Bestätigen Sie?",
4850
"continue-playing-as-grid": "Weiterspielen als Raster",

frontend/src/locales/en_GB/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
"config-file-not-mounted-title": "Configuration file not mounted!",
4444
"config-file-not-writable-desc": "The config.yml file is not writable. Any changes made to the configuration will not persist after the application restarts.",
4545
"config-file-not-writable-title": "Configuration file not writable!",
46+
"config-file-parse-error-desc": "The config.yml file has a syntax error, so RomM is running with default settings and ignoring your configuration. Fix the error and restart RomM to apply your settings. Details: {error}",
47+
"config-file-parse-error-title": "Configuration file could not be parsed!",
4648
"config-tab": "Config",
4749
"confirm-delete-mapping": "Do you confirm?",
4850
"continue-playing-as-grid": "Continue playing as grid",

0 commit comments

Comments
 (0)