fix(config): never let app.json I/O stop startup or skip releasing the workspace

An app.json that exists but cannot be read, or a .bak that cannot be written,
now yields defaults instead of an unhandled exception before the main window
exists. A failed write at exit is logged and ignored, and the session is
disposed in a finally block, so the lock and connection are always released.
This commit is contained in:
doyaGu committed 2026-10-03 09:00:40 -04:00
1 parent c9523d0953
commit d3b071b0be
5 files changed
+88 -10

No files matched your search

@@ -45,6 +45,43 @@ public class AppConfigStoreTests
Assert.AreEqual("{ this is not valid json", File.ReadAllText(path + ".bak")); Assert.AreEqual("{ this is not valid json", File.ReadAllText(path + ".bak"));
} }
[TestMethod]
public void LoadUnreadableFileReturnsDefaults()
{
using var directory = new TempDirectory();
string path = Path.Combine(directory.FullPath, "app.json");
File.WriteAllText(path, "{ \"language\": \"zh-Hans\" }");
var store = new AppConfigStore(path);
AppConfig config;
using (new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
config = store.Load();
}
Assert.AreEqual(AppConfig.DefaultLanguage, config.Language);
Assert.IsFalse(File.Exists(path + ".bak"));
}
[TestMethod]
public void LoadCorruptFileReturnsDefaultsWhenTheBackupCannotBeWritten()
{
using var directory = new TempDirectory();
string path = Path.Combine(directory.FullPath, "app.json");
File.WriteAllText(path, "{ this is not valid json");
File.WriteAllText(path + ".bak", "older backup");
var store = new AppConfigStore(path);
AppConfig config;
using (new FileStream(path + ".bak", FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
config = store.Load();
}
Assert.AreEqual(AppConfig.CurrentFormatVersion, config.Version);
Assert.AreEqual("older backup", File.ReadAllText(path + ".bak"));
}
[TestMethod] [TestMethod]
public void LoadJsonNullReturnsDefaults() public void LoadJsonNullReturnsDefaults()
{ {
+8 -1
View File
@@ -12,10 +12,17 @@ sealed class Program
{ {
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
// The lifetime has ended: persist the configuration once, then release services. // The lifetime has ended: persist the configuration once, then release
// services; the workspace lock and connection are released even if saving fails.
try
{
App.Services?.SaveConfiguration(); App.Services?.SaveConfiguration();
}
finally
{
App.Services?.Dispose(); App.Services?.Dispose();
} }
}
// Avalonia configuration, don't remove; also used by visual designer. // Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp() public static AppBuilder BuildAvaloniaApp()
+15 -2
View File
@@ -1,3 +1,4 @@
using System.Diagnostics;
using YKanBan.Storage.AppData; using YKanBan.Storage.AppData;
using YKanBan.Storage.Workspace; using YKanBan.Storage.Workspace;
@@ -63,9 +64,21 @@ public sealed class AppServices : IDisposable
} }
/// <summary> /// <summary>
/// Persists the configuration when this session changed a setting; called once at shutdown. /// Persists the configuration when this session changed a setting; called
/// once at shutdown. A write failure only loses this session's settings
/// changes, the same accepted loss as a crash, so it is logged and ignored.
/// </summary> /// </summary>
public void SaveConfiguration() => ConfigStore.SaveIfChanged(Config); public void SaveConfiguration()
{
try
{
ConfigStore.SaveIfChanged(Config);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
Debug.WriteLine(exception);
}
}
/// <summary> /// <summary>
/// Releases the workspace session, if any. /// Releases the workspace session, if any.
+25 -6
View File
@@ -9,7 +9,8 @@ namespace YKanBan.Storage.AppData;
/// defaults; a file that is not a JSON object is backed up to app.json.bak and /// defaults; a file that is not a JSON object is backed up to app.json.bak and
/// then defaults are used; an individual invalid field (unknown enum value or /// then defaults are used; an individual invalid field (unknown enum value or
/// language, out-of-range width, wrong type) falls back to its own default while /// language, out-of-range width, wrong type) falls back to its own default while
/// the other fields are kept. Saving happens only when the configuration differs /// the other fields are kept; a file that exists but cannot be read (I/O or
/// permission error) also yields defaults. Saving happens only when the configuration differs
/// from what was loaded, and writes the file directly (no atomic replace) — /// from what was loaded, and writes the file directly (no atomic replace) —
/// losing the last session's settings to a crash is an accepted trade-off. /// losing the last session's settings to a crash is an accepted trade-off.
/// </summary> /// </summary>
@@ -52,11 +53,12 @@ public sealed class AppConfigStore
/// <summary> /// <summary>
/// Loads the configuration and records it as the persisted baseline for /// Loads the configuration and records it as the persisted baseline for
/// <see cref="SaveIfChanged"/>. A missing file yields defaults; a file that /// <see cref="SaveIfChanged"/>. A missing file yields defaults; a file that
/// is not a JSON object is backed up to app.json.bak and then defaults are /// is not a JSON object is backed up to app.json.bak (best effort) and then
/// used; invalid fields fall back to their defaults individually. /// defaults are used; invalid fields fall back to their defaults
/// individually; a file that cannot be read yields defaults. Never throws for
/// file-system reasons, because a bad app.json must not stop the app.
/// </summary> /// </summary>
/// <returns>The loaded configuration, or defaults when none could be read.</returns> /// <returns>The loaded configuration, or defaults when none could be read.</returns>
/// <exception cref="IOException">The file exists but cannot be read.</exception>
public AppConfig Load() public AppConfig Load()
{ {
AppConfig config = Read(); AppConfig config = Read();
@@ -108,7 +110,16 @@ public sealed class AppConfigStore
return new AppConfig(); return new AppConfig();
} }
string json = File.ReadAllText(FilePath); string json;
try
{
json = File.ReadAllText(FilePath);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return new AppConfig();
}
JToken root; JToken root;
try try
{ {
@@ -137,12 +148,20 @@ public sealed class AppConfigStore
} }
/// <summary> /// <summary>
/// Keeps a copy of an unusable file for inspection, then continues with defaults. /// Keeps a copy of an unusable file for inspection, then continues with
/// defaults; a backup that cannot be written is skipped.
/// </summary> /// </summary>
/// <returns>The default configuration.</returns> /// <returns>The default configuration.</returns>
private AppConfig BackUpAndUseDefaults() private AppConfig BackUpAndUseDefaults()
{
try
{ {
File.Copy(FilePath, FilePath + ".bak", overwrite: true); File.Copy(FilePath, FilePath + ".bak", overwrite: true);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// The backup is only a diagnostic aid; starting with defaults matters more.
}
return new AppConfig(); return new AppConfig();
} }
+3 -1
View File
@@ -16,7 +16,7 @@
- 网络共享明确不支持;命令行参数边界;macOS 无参启动行为写入文档(§2.3、§4.1、附录) - 网络共享明确不支持;命令行参数边界;macOS 无参启动行为写入文档(§2.3、§4.1、附录)
- 补充:导出转义规则、卡片列表虚拟化、模态窗口 FlowDirection、`Resources` 类名使用规则、CI osx-x64 运行器(§5、§7、§8) - 补充:导出转义规则、卡片列表虚拟化、模态窗口 FlowDirection、`Resources` 类名使用规则、CI osx-x64 运行器(§5、§7、§8)
- 依赖:Avalonia 11.3.6 → 11.3.22,Semi.Avalonia 11.2.1.10 → 11.3.22(§7) - 依赖:Avalonia 11.3.6 → 11.3.22,Semi.Avalonia 11.2.1.10 → 11.3.22(§7)
- 实现回补:搜索括号嵌套上限 64 层、单个表达式至多 256 个原子,空短语、短语紧贴文本、`key:` 后空白均为非法(§6.1、§6.2);导出表格单元格转义 `\`(§5.8);初始化中途锁冲突进锁冲突页、数据库文件缺失不静默新建(§4.4) - 实现回补:搜索括号嵌套上限 64 层、单个表达式至多 256 个原子,空短语、短语紧贴文本、`key:` 后空白均为非法(§6.1、§6.2);导出表格单元格转义 `\`(§5.8);初始化中途锁冲突进锁冲突页、数据库文件缺失不静默新建(§4.4);app.json 读不出 / 备份写不出 / 退出写盘失败均不阻断启动与退出(§4.3)
- 里程碑:标注 M0–M4 已完成,新增 **M4R(v1.9 回补)**(§9) - 里程碑:标注 M0–M4 已完成,新增 **M4R(v1.9 回补)**(§9)
## 1. 项目概述 ## 1. 项目概述
@@ -142,6 +142,8 @@ card_tags: (card_id, tag_id) 复合主键,双向 ON DELETE CASCADE
- 文件缺失 → 默认值启动照常运行(下次脏退出时自然创建) - 文件缺失 → 默认值启动照常运行(下次脏退出时自然创建)
- JSON 本身无法解析 → 备份为 `app.json.bak`(覆盖旧备份)→ 以默认值启动 - JSON 本身无法解析 → 备份为 `app.json.bak`(覆盖旧备份)→ 以默认值启动
- JSON 完好但个别字段非法(未知枚举值、未知语言、列宽越界 160–720、类型不符等)→ **仅该字段回退默认值**,其余字段保留,不生成备份 - JSON 完好但个别字段非法(未知枚举值、未知语言、列宽越界 160–720、类型不符等)→ **仅该字段回退默认值**,其余字段保留,不生成备份
- 文件存在但无法读取(IO / 权限错误)→ 以默认值启动;`.bak` 备份写不出时跳过备份,照常以默认值启动
- 退出写盘失败(IO / 权限错误)→ 忽略,等同丢失当次会话的设置变更;无论写盘成败都释放工作区锁与连接
### 4.4 全窗口错误页体系 ### 4.4 全窗口错误页体系