Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 46 additions & 23 deletions NewLife.Core/Configuration/FileConfigProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,21 @@ public override Boolean SaveAll()
fileName = fileName.GetBasePath();
fileName.EnsureDirectory(true);

// 写入文件
OnWrite(fileName, Root);
_lastTime = fileName.AsFile().LastWriteTime;
// 共享锁:与 Save<T> 互斥,避免并发写入交叉
lock (this)
{
// 抑制自身 watcher 引发的 reload 重入
_reading = true;
try
{
OnWrite(fileName, Root);
_lastTime = fileName.AsFile().LastWriteTime;
}
finally
{
_reading = false;
}
}

// 通知绑定对象,配置数据有改变
NotifyChange();
Expand Down Expand Up @@ -143,32 +155,43 @@ public override Boolean Save<T>(T model, String? path = null)
protected virtual void OnWrite(String fileName, IConfigSection section)
{
var str = GetString(section);

// 空内容防御:序列化异常退化为空时,不修改目标文件
if (str.IsNullOrEmpty()) return;

var old = "";
if (File.Exists(fileName)) old = File.ReadAllText(fileName)?.Trim() ?? "";
if (File.Exists(fileName)) old = File.ReadAllText(fileName) ?? "";

if (str != null && str != old)
{
if (old.IsNullOrEmpty())
{
XTrace.WriteLine("新建配置:{0}", fileName);
}
else
{
// 如果文件内容有变化,输出差异
var i = 0;
while (i < str.Length && i < old.Length && str[i] == old[i]) i++;
// 双边 trim 比较,避免末尾换行差异导致的无谓重写
if (str.Trim() == old.Trim()) return;

var s = i > 16 ? i - 16 : 0;
var e = i + 32 < old.Length ? i + 32 : old.Length;
var ori = old[s..e].Replace("\r", "\\r").Replace("\n", "\\n");
var e2 = i + 32 < str.Length ? i + 32 : str.Length;
var diff = str[s..e2].Replace("\r", "\\r").Replace("\n", "\\n");
if (old.IsNullOrEmpty())
{
XTrace.WriteLine("新建配置:{0}", fileName);
}
else
{
// 如果文件内容有变化,输出差异
var i = 0;
while (i < str.Length && i < old.Length && str[i] == old[i]) i++;

XTrace.WriteLine("更新配置:{0},原:\"{1}\",新:\"{2}\"", fileName, ori, diff);
}
var s = i > 16 ? i - 16 : 0;
var e = i + 32 < old.Length ? i + 32 : old.Length;
var ori = old[s..e].Replace("\r", "\\r").Replace("\n", "\\n");
var e2 = i + 32 < str.Length ? i + 32 : str.Length;
var diff = str[s..e2].Replace("\r", "\\r").Replace("\n", "\\n");

File.WriteAllText(fileName, str);
XTrace.WriteLine("更新配置:{0},原:\"{1}\",新:\"{2}\"", fileName, ori, diff);
}

// 原子写入:先写临时文件,再原子替换。任何中途崩溃都不会让目标文件出现空白或半截内容
var tmp = fileName + ".tmp";
File.WriteAllText(tmp, str);

if (File.Exists(fileName))
File.Replace(tmp, fileName, null, ignoreMetadataErrors: true);
else
File.Move(tmp, fileName);
}

/// <summary>获取字符串形式</summary>
Expand Down
15 changes: 14 additions & 1 deletion NewLife.Core/Json/JsonConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -297,10 +297,23 @@ public virtual void Save(String filename)
var json1 = File.Exists(filename) ? File.ReadAllText(filename).Trim() : null;
var json2 = GetJson();

// 空内容防御:序列化异常退化为空时,不修改目标文件
if (json2.IsNullOrEmpty()) return;

// 双边 trim 比较:json1 已 trim,json2 在此处 trim 后比较,但写入时使用未 trim 的原始内容
if (json2.Trim() == json1) return;

//if (File.Exists(filename)) File.Delete(filename);
filename.EnsureDirectory(true);

if (json1 != json2) File.WriteAllText(filename, json2);
// 原子写入:先写临时文件,再原子替换。任何中途崩溃都不会让目标文件出现空白或半截内容
var tmp = filename + ".tmp";
File.WriteAllText(tmp, json2);

if (File.Exists(filename))
File.Replace(tmp, filename, null, ignoreMetadataErrors: true);
else
File.Move(tmp, filename);
}
}

Expand Down
15 changes: 14 additions & 1 deletion NewLife.Core/Xml/XmlConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,20 @@ public virtual void Save(String? filename)
/// <param name="newXml">新配置文件的内容</param>
protected virtual void OnSaving(String filename, String? oldXml, String newXml)
{
if (oldXml != newXml) File.WriteAllText(filename, newXml);
// 空内容防御:序列化异常退化为空时,不修改目标文件
if (newXml.IsNullOrEmpty()) return;

// 双边 trim 比较:oldXml 在 Save 中已 trim,newXml 在此处 trim 后比较,但写入时使用未 trim 的原始内容
if (newXml.Trim() == oldXml) return;

// 原子写入:先写临时文件,再原子替换。任何中途崩溃都不会让目标文件出现空白或半截内容
var tmp = filename + ".tmp";
File.WriteAllText(tmp, newXml);

if (File.Exists(filename))
File.Replace(tmp, filename, null, ignoreMetadataErrors: true);
else
File.Move(tmp, filename);
}

/// <summary>保存到配置文件中去</summary>
Expand Down
60 changes: 59 additions & 1 deletion Test/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using NewLife.Caching;
using NewLife.Collections;
using NewLife.Common;
using NewLife.Configuration;
using NewLife.Data;
using NewLife.Http;
using NewLife.IO;
Expand Down Expand Up @@ -77,7 +78,7 @@
try
{
#endif
Test1();
Test19();
#if !DEBUG
}
catch (Exception ex)
Expand All @@ -96,7 +97,7 @@
}
}

static StarClient _client;

Check warning on line 100 in Test/Program.cs

View workflow job for this annotation

GitHub Actions / test

The field 'Program._client' is never used

Check warning on line 100 in Test/Program.cs

View workflow job for this annotation

GitHub Actions / test

The field 'Program._client' is never used

Check warning on line 100 in Test/Program.cs

View workflow job for this annotation

GitHub Actions / test

The field 'Program._client' is never used

Check warning on line 100 in Test/Program.cs

View workflow job for this annotation

GitHub Actions / test

The field 'Program._client' is never used

Check warning on line 100 in Test/Program.cs

View workflow job for this annotation

GitHub Actions / test

The field 'Program._client' is never used
private static void Test1()
{
var type = Type.GetType("UnityEngine.Application, UnityEngine");
Expand Down Expand Up @@ -298,7 +299,7 @@

var buf = new Byte[1024];
var rs = await client.ReceiveAsync(buf, default);
XTrace.WriteLine(new Packet(buf, 0, rs.Count).ToStr());

Check warning on line 302 in Test/Program.cs

View workflow job for this annotation

GitHub Actions / test

'Packet' is obsolete: '请使用 ArrayPacket 或 OwnerPacket 替代'

Check warning on line 302 in Test/Program.cs

View workflow job for this annotation

GitHub Actions / test

'Packet' is obsolete: '请使用 ArrayPacket 或 OwnerPacket 替代'

await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "通信完成", default);
XTrace.WriteLine("Close [{0}] {1}", client.CloseStatus, client.CloseStatusDescription);
Expand Down Expand Up @@ -337,7 +338,7 @@
{
XTrace.WriteLine("TLS加密通信");

var pfx = new X509Certificate2("../../../doc/newlife.pfx".GetFullPath(), "newlife");

Check warning on line 341 in Test/Program.cs

View workflow job for this annotation

GitHub Actions / test

'X509Certificate2.X509Certificate2(string, string?)' is obsolete: 'Loading certificate data through the constructor or Import is obsolete. Use X509CertificateLoader instead to load certificates.' (https://aka.ms/dotnet-warnings/SYSLIB0057)
//Console.WriteLine(pfx);

//using var svr = new ApiServer(1234);
Expand Down Expand Up @@ -906,4 +907,61 @@
Dic = new Dictionary<int, string> { }
};
}

private static void Test19()
{
// ===== 热更新自激环手工验证 =====
// 期望:连续 SaveAll 期间,日志中【不应】出现 "配置文件改变,重新加载 ..."
// 因为是 SaveAll 自身写入触发的 watcher,已被 _reading 抑制;
// 最后一段"外部修改"模拟,日志中【应该】出现 "配置文件改变,重新加载 ...",
// 用于验证抑制只是临时的,watcher 仍然能感知真正的外部变更。

var fileName = "Config/HotReloadTest.json";
var fullPath = fileName.GetBasePath();

// 清理上次残留
if (File.Exists(fullPath)) File.Delete(fullPath);
if (File.Exists(fullPath + ".tmp")) File.Delete(fullPath + ".tmp");

var prv = new JsonConfigProvider { FileName = fileName, Period = 60 };

var model = new XYF { name = "init" };
prv.Save(model);
prv.Bind(model, autoReload: true);

XTrace.WriteLine("===== Phase 1: 连续 SaveAll × 100,期望日志中 NOT 出现 \"配置文件改变\" =====");
for (var i = 1; i <= 100; i++)
{
var saveIndex = i;
Task.Run(() =>
{
model.name = $"save-{saveIndex}";
prv.Save(model);

var fi = new FileInfo(fullPath);
XTrace.WriteLine(" 第 {0} 次 SaveAll 完成,LastWriteTime={1:HH:mm:ss.fff}", saveIndex, fi.LastWriteTime);
});
}

// 等够 watcher 防抖 (500ms) + 延迟读 (200ms) + 富余
XTrace.WriteLine("等待 1.5 秒,确认抑制窗口期内 watcher 没有触发 reload ...");
Thread.Sleep(1500);

XTrace.WriteLine("===== Phase 2: 模拟外部修改,期望日志中【应该】出现 \"配置文件改变\" =====");
// 直接用 File.WriteAllText 模拟"另一个进程改了配置文件"
// 这绕过 prv 自身的 SaveAll,触发的 watcher 不会被 _reading 挡住
var externalContent = File.ReadAllText(fullPath).Replace("save", "external-edit");
File.WriteAllText(fullPath, externalContent);
XTrace.WriteLine(" 外部修改已写入,等待 5 秒观察 watcher 是否触发 reload ...");

// watcher 有 200ms 延迟 + 防抖;定时器 60s(事件驱动可用时拉长),靠事件驱动
Thread.Sleep(5000);

XTrace.WriteLine("===== 验证结束 =====");
XTrace.WriteLine(" 通过条件:");
XTrace.WriteLine(" 1) Phase 1 期间,日志中 NOT 出现 \"配置文件改变,重新加载\"");
XTrace.WriteLine(" 2) Phase 2 期间,日志中【出现一次】\"配置文件改变,重新加载 {0}\"", fullPath);

prv.TryDispose();
}
}
143 changes: 143 additions & 0 deletions XUnitTest.Core/Configuration/FileConfigProviderAtomicWriteTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
using NewLife;
using NewLife.Configuration;
using Xunit;

namespace XUnitTest.Configuration;

/// <summary>FileConfigProvider 原子写入相关测试</summary>
/// <remarks>
/// 写入中途异常不留下半截文件、空内容不覆盖原文件、双边 trim 比较生效、
/// 并发 SaveAll 不出现交叉写入。
/// </remarks>
public class FileConfigProviderAtomicWriteTests
{
/// <summary>故障注入:tmp 写完后立即抛异常,模拟进程在原子替换前崩溃</summary>
private sealed class FaultyJsonConfigProvider : JsonConfigProvider
{
protected override void OnWrite(String fileName, IConfigSection section)
{
// 模拟基类原子写入的前半段:tmp 文件已经落盘(甚至内容是错的),
// 但还没来得及 File.Replace 时进程崩溃
var tmp = fileName + ".tmp";
File.WriteAllText(tmp, "broken half content");
throw new IOException("simulated crash mid-write");
}
}

/// <summary>覆盖:序列化退化为空字符串时不修改目标文件</summary>
private sealed class EmptyContentJsonConfigProvider : JsonConfigProvider
{
public override String GetString(IConfigSection? section = null) => "";
}

[Fact]
public void AtomicWrite_ProcessCrashMidWrite_KeepsOldContent()
{
// 先写一份正常内容打底
var fileName = "Config/atomic_crash.json";
var prv = new JsonConfigProvider { FileName = fileName };
var model = new AtomicTestModel { Name = "original", Count = 1 };
prv.Save(model);

var fullPath = fileName.GetBasePath();
var oldContent = File.ReadAllText(fullPath);
Assert.Contains("original", oldContent);

// 切换为故障 provider,模拟写入中途崩溃
var faulty = new FaultyJsonConfigProvider { FileName = fileName };
Assert.Throws<IOException>(() => faulty.Save(new AtomicTestModel { Name = "new", Count = 2 }));

// 关键断言:目标文件保持旧内容,不为空、不被截断
var afterCrash = File.ReadAllText(fullPath);
Assert.Equal(oldContent, afterCrash);
Assert.Contains("original", afterCrash);

// 清理可能残留的 tmp(不影响断言,仅避免污染下次运行)
var tmp = fullPath + ".tmp";
if (File.Exists(tmp)) File.Delete(tmp);
}

[Fact]
public void AtomicWrite_EmptyContent_DoesNotOverwrite()
{
// 先写一份正常内容打底
var fileName = "Config/atomic_empty.json";
var prv = new JsonConfigProvider { FileName = fileName };
prv.Save(new AtomicTestModel { Name = "keep-me", Count = 42 });

var fullPath = fileName.GetBasePath();
var oldContent = File.ReadAllText(fullPath);
var oldLastWrite = File.GetLastWriteTimeUtc(fullPath);

// 切换为返回空字符串的 provider
var emptyPrv = new EmptyContentJsonConfigProvider { FileName = fileName };
emptyPrv.Save(new AtomicTestModel { Name = "should-not-overwrite", Count = 0 });

// 关键断言:文件未被覆盖
var afterEmpty = File.ReadAllText(fullPath);
Assert.Equal(oldContent, afterEmpty);
Assert.Contains("keep-me", afterEmpty);
Assert.Equal(oldLastWrite, File.GetLastWriteTimeUtc(fullPath));
}

[Fact]
public void AtomicWrite_TrailingWhitespaceOnly_NoActualWrite()
{
// 写一份初始内容
var fileName = "Config/atomic_trim.json";
var prv = new JsonConfigProvider { FileName = fileName };
prv.Save(new AtomicTestModel { Name = "stable", Count = 7 });

var fullPath = fileName.GetBasePath();
var oldLastWrite = File.GetLastWriteTimeUtc(fullPath);

// 等待 mtime 分辨率,避免误判(Windows FAT/NTFS 至少 1ms,留 50ms 余量)
Thread.Sleep(50);

// 用同一份模型再保存一次。GetString 输出与磁盘内容仅在末尾换行可能不同,
// 双边 trim 后应判定为"无变化",不触发实际写入
var prv2 = new JsonConfigProvider { FileName = fileName };
prv2.Save(new AtomicTestModel { Name = "stable", Count = 7 });

var newLastWrite = File.GetLastWriteTimeUtc(fullPath);
Assert.Equal(oldLastWrite, newLastWrite);
}

[Fact]
public void AtomicWrite_ConcurrentSaveAll_NoCorruption()
{
var fileName = "Config/atomic_concurrent.json";
var prv = new JsonConfigProvider { FileName = fileName };
// 初始化一份,确保后续 SaveAll 走"目标存在"分支
prv.Save(new AtomicTestModel { Name = "init", Count = 0 });

// 并发 SaveAll:让 N 个线程同时改 Root 并保存。
// 共享同一把锁的前提下,磁盘上不会出现交叉写入或半截文件
const Int32 threads = 8;
const Int32 iterationsPerThread = 20;
Parallel.For(0, threads, t =>
{
for (var i = 0; i < iterationsPerThread; i++)
{
prv.Save(new AtomicTestModel { Name = $"t{t}", Count = i });
}
});

// 写入全部完成后,文件必须可解析为完整的 JSON 配置
var fullPath = fileName.GetBasePath();
var content = File.ReadAllText(fullPath);
Assert.False(String.IsNullOrWhiteSpace(content));

var verify = new JsonConfigProvider { FileName = fileName };
var loaded = verify.Load<AtomicTestModel>();
Assert.NotNull(loaded);
Assert.False(String.IsNullOrEmpty(loaded.Name));
}

/// <summary>仅供测试使用的最小配置模型</summary>
public class AtomicTestModel
{
public String Name { get; set; } = "";
public Int32 Count { get; set; }
}
}
Loading