Skip to content

Do not abort startup when the config file cannot be written#1340

Merged
ManlyMarco merged 2 commits into
BepInEx:masterfrom
HetCreep:fix/config-save-readonly-degrade
Jun 11, 2026
Merged

Do not abort startup when the config file cannot be written#1340
ManlyMarco merged 2 commits into
BepInEx:masterfrom
HetCreep:fix/config-save-readonly-degrade

Conversation

@HetCreep

@HetCreep HetCreep commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #1339

ConfigFile.Save() opens the file for writing with no exception handling and is invoked automatically via SaveOnConfigSet (default true) while settings are bound during startup, before logging is initialized. A read-only or otherwise unwritable config file -- a read-only install directory, or a file locked by another process / antivirus -- therefore throws UnauthorizedAccessException out of the early startup path and shows the "Failed to start BepInEx" dialog (with no LogOutput.log, since logging isn't up yet).

This routes the automatic saves (on init, on bind, on change) through a TrySave helper that logs a warning and keeps the in-memory configuration instead of propagating IOException / UnauthorizedAccessException. Explicit Save() calls are unchanged, so a plugin that calls Save() directly still sees the exception.

Repro: make BepInEx/config/BepInEx.cfg read-only, then launch -- before this change BepInEx fails to start; after it, it logs a warning and continues with the in-memory config.

ConfigFile.Save opens the file for writing with no exception handling and is invoked automatically via SaveOnConfigSet while settings are bound during startup, before logging is initialized. A read-only or otherwise unwritable config file (a read-only install directory, a file locked by another process or antivirus) therefore throws UnauthorizedAccessException out of the early startup path and shows the Failed to start BepInEx dialog.

Route the automatic saves (on init, on bind, on change) through a TrySave helper that logs a warning and keeps the in-memory configuration instead of propagating IOException / UnauthorizedAccessException. Explicit Save calls are unchanged.

@ManlyMarco ManlyMarco left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about this one. I think this is a fatal issue and should crash rather than be silently ignored but I'm open to arguments otherwise.

@HetCreep

Copy link
Copy Markdown
Contributor Author

Fair concern — here's why I went with degrade-and-warn, but happy to change it if you still prefer fatal.

The save isn't silent: TrySave logs a Warning with the file path and the underlying error before continuing.

The reason I avoided crashing here specifically: the first automatic save runs while ConfigFile.CoreConfig is being constructed — before the logging backend is up. When the file is read-only/locked, the UnauthorizedAccessException escapes that static init and BepInEx aborts with "Failed to start BepInEx" and no LogOutput.log at all, so the user just sees the game fail to launch with nothing to go on. (Repro: mark BepInEx.cfg read-only → hard crash, empty log dir.)

Also, only the automatic saves go through TrySave (init + the SaveOnConfigSet paths). An explicit Save() still throws as before, so code that deliberately persists can still observe the failure.

That said, you know the project's expectations best. If you'd rather keep it fatal, one option is to log an actionable error and rethrow — though with the pre-logging timing above, a throw there still produces no log, whereas warn-and-degrade gives both the diagnostic and a launchable game. Glad to go whichever way you prefer.

@ManlyMarco

Copy link
Copy Markdown
Member

The reason I avoided crashing here specifically: the first automatic save runs while ConfigFile.CoreConfig is being constructed — before the logging backend is up. When the file is read-only/locked, the UnauthorizedAccessException escapes that static init and BepInEx aborts with "Failed to start BepInEx" and no LogOutput.log at all, so the user just sees the game fail to launch with nothing to go on. (Repro: mark BepInEx.cfg read-only → hard crash, empty log dir.)

This is a very good point, I can see the utility now.

The problem with only crashing on Save is that almost no plugin ever uses it, everything instead relies on automatic saving on setting set. Doing this would effectively catch all config save exceptions.

Would it be enough to only catch exceptions on creation? I don't think BepInEx sets any of the settings internally, so the crash should only ever happen on creation.

Per review: setting changes save through Save() and throw again;
only the initial file write and new-entry binds use TrySave, since
those can run before the logging backend is up.
@HetCreep HetCreep force-pushed the fix/config-save-readonly-degrade branch from 429eed7 to 4459278 Compare June 11, 2026 11:51
@HetCreep

Copy link
Copy Markdown
Contributor Author

Done in 4459278 — narrowed exactly as you suggested:

Builds clean across all TFMs.

@ManlyMarco ManlyMarco merged commit da64b22 into BepInEx:master Jun 11, 2026
1 check passed
@HetCreep HetCreep deleted the fix/config-save-readonly-degrade branch June 14, 2026 06:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Read-only BepInEx.cfg causes "Failed to start BepInEx" instead of degrading

2 participants