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:
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"));
|
||||
}
|
||||
|
||||
[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]
|
||||
public void LoadJsonNullReturnsDefaults()
|
||||
{
|
||||
|
||||
+8
-1
@@ -12,10 +12,17 @@ sealed class Program
|
||||
{
|
||||
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();
|
||||
}
|
||||
finally
|
||||
{
|
||||
App.Services?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using YKanBan.Storage.AppData;
|
||||
using YKanBan.Storage.Workspace;
|
||||
|
||||
@@ -63,9 +64,21 @@ public sealed class AppServices : IDisposable
|
||||
}
|
||||
|
||||
/// <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>
|
||||
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>
|
||||
/// Releases the workspace session, if any.
|
||||
|
||||
@@ -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
|
||||
/// 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
|
||||
/// 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) —
|
||||
/// losing the last session's settings to a crash is an accepted trade-off.
|
||||
/// </summary>
|
||||
@@ -52,11 +53,12 @@ public sealed class AppConfigStore
|
||||
/// <summary>
|
||||
/// Loads the configuration and records it as the persisted baseline for
|
||||
/// <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
|
||||
/// used; invalid fields fall back to their defaults individually.
|
||||
/// is not a JSON object is backed up to app.json.bak (best effort) and then
|
||||
/// 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>
|
||||
/// <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()
|
||||
{
|
||||
AppConfig config = Read();
|
||||
@@ -108,7 +110,16 @@ public sealed class AppConfigStore
|
||||
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;
|
||||
try
|
||||
{
|
||||
@@ -137,12 +148,20 @@ public sealed class AppConfigStore
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <returns>The default configuration.</returns>
|
||||
private AppConfig BackUpAndUseDefaults()
|
||||
{
|
||||
try
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -16,7 +16,7 @@
|
||||
- 网络共享明确不支持;命令行参数边界;macOS 无参启动行为写入文档(§2.3、§4.1、附录)
|
||||
- 补充:导出转义规则、卡片列表虚拟化、模态窗口 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)
|
||||
- 实现回补:搜索括号嵌套上限 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)
|
||||
|
||||
## 1. 项目概述
|
||||
@@ -142,6 +142,8 @@ card_tags: (card_id, tag_id) 复合主键,双向 ON DELETE CASCADE
|
||||
- 文件缺失 → 默认值启动照常运行(下次脏退出时自然创建)
|
||||
- JSON 本身无法解析 → 备份为 `app.json.bak`(覆盖旧备份)→ 以默认值启动
|
||||
- JSON 完好但个别字段非法(未知枚举值、未知语言、列宽越界 160–720、类型不符等)→ **仅该字段回退默认值**,其余字段保留,不生成备份
|
||||
- 文件存在但无法读取(IO / 权限错误)→ 以默认值启动;`.bak` 备份写不出时跳过备份,照常以默认值启动
|
||||
- 退出写盘失败(IO / 权限错误)→ 忽略,等同丢失当次会话的设置变更;无论写盘成败都释放工作区锁与连接
|
||||
|
||||
### 4.4 全窗口错误页体系
|
||||
|
||||
|
||||
Reference in new issue
Block a user