Skip to content

Commit 134555b

Browse files
committed
fix: テキスト比較フォールバック時に警告ログを追加しネットワーク判定の例外捕捉を限定
1 parent a4c5e1e commit 134555b

6 files changed

Lines changed: 231 additions & 57 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Threading.Tasks;
5+
using FolderDiffIL4DotNet.Models;
6+
using FolderDiffIL4DotNet.Services;
7+
using FolderDiffIL4DotNet.Services.Caching;
8+
using Microsoft.Extensions.DependencyInjection;
9+
using Xunit;
10+
11+
namespace FolderDiffIL4DotNet.Tests.Services
12+
{
13+
public sealed class FileDiffServiceTests : IDisposable
14+
{
15+
private readonly string _rootDir;
16+
17+
public FileDiffServiceTests()
18+
{
19+
_rootDir = Path.Combine(Path.GetTempPath(), "fd-filediff-tests-" + Guid.NewGuid().ToString("N"));
20+
Directory.CreateDirectory(_rootDir);
21+
}
22+
23+
public void Dispose()
24+
{
25+
try
26+
{
27+
if (Directory.Exists(_rootDir))
28+
{
29+
Directory.Delete(_rootDir, recursive: true);
30+
}
31+
}
32+
catch
33+
{
34+
// ignore cleanup errors in tests
35+
}
36+
}
37+
38+
[Fact]
39+
public async Task FilesAreEqualAsync_WhenPrimaryTextDiffThrows_LogsWarningAndFallsBackToSequentialDiff()
40+
{
41+
var oldDir = Path.Combine(_rootDir, "old");
42+
var newDir = Path.Combine(_rootDir, "new");
43+
Directory.CreateDirectory(oldDir);
44+
Directory.CreateDirectory(newDir);
45+
46+
const string fileRelativePath = "sample.txt";
47+
var oldFileAbsolutePath = Path.Combine(oldDir, fileRelativePath);
48+
var newFileAbsolutePath = Path.Combine(newDir, fileRelativePath);
49+
File.WriteAllText(oldFileAbsolutePath, "old-content");
50+
File.WriteAllText(newFileAbsolutePath, "new");
51+
52+
var config = new ConfigSettings
53+
{
54+
TextFileExtensions = new List<string> { ".txt" },
55+
IgnoredExtensions = new List<string>(),
56+
ShouldOutputILText = false,
57+
EnableILCache = false,
58+
OptimizeForNetworkShares = true
59+
};
60+
61+
FileStream exclusiveLockStream = new FileStream(oldFileAbsolutePath, FileMode.Open, FileAccess.Read, FileShare.None);
62+
var logger = new TestLogger(entry =>
63+
{
64+
if (entry.LogLevel == AppLogLevel.Warning && entry.Message.Contains("Falling back to sequential text diff", StringComparison.Ordinal))
65+
{
66+
exclusiveLockStream?.Dispose();
67+
exclusiveLockStream = null;
68+
}
69+
});
70+
71+
var resultLists = new FileDiffResultLists();
72+
var provider = new ServiceCollection()
73+
.AddSingleton<ILoggerService>(logger)
74+
.AddSingleton(resultLists)
75+
.AddSingleton(new DotNetDisassemblerCache(logger))
76+
.BuildServiceProvider();
77+
78+
try
79+
{
80+
var ilOutputService = new ILOutputService(config, oldDir, newDir, provider, logger);
81+
var service = new FileDiffService(config, ilOutputService, oldDir, newDir, optimizeForNetworkShares: true, resultLists, logger);
82+
83+
var areEqual = await service.FilesAreEqualAsync(fileRelativePath, maxParallel: 1);
84+
85+
Assert.False(areEqual);
86+
Assert.Equal(FileDiffResultLists.DiffDetailResult.TextMismatch, resultLists.FileRelativePathToDiffDetailDictionary[fileRelativePath]);
87+
var warningLog = Assert.Single(logger.Entries, entry => entry.LogLevel == AppLogLevel.Warning);
88+
Assert.Contains("Falling back to sequential text diff", warningLog.Message);
89+
Assert.IsType<IOException>(warningLog.Exception);
90+
}
91+
finally
92+
{
93+
exclusiveLockStream?.Dispose();
94+
provider.Dispose();
95+
}
96+
}
97+
98+
private sealed class TestLogger : ILoggerService
99+
{
100+
private readonly Action<LogEntry> _onEntry;
101+
102+
public TestLogger(Action<LogEntry> onEntry)
103+
{
104+
_onEntry = onEntry;
105+
}
106+
107+
public string LogFileAbsolutePath => null;
108+
109+
public List<LogEntry> Entries { get; } = new();
110+
111+
public void Initialize() { }
112+
113+
public void CleanupOldLogFiles(int maxLogGenerations) { }
114+
115+
public void LogMessage(AppLogLevel logLevel, string message, bool shouldOutputMessageToConsole, Exception exception = null)
116+
=> LogMessage(logLevel, message, shouldOutputMessageToConsole, consoleForegroundColor: null, exception);
117+
118+
public void LogMessage(AppLogLevel logLevel, string message, bool shouldOutputMessageToConsole, ConsoleColor? consoleForegroundColor, Exception exception = null)
119+
{
120+
var entry = new LogEntry(logLevel, message, exception);
121+
Entries.Add(entry);
122+
_onEntry?.Invoke(entry);
123+
}
124+
}
125+
126+
private sealed record LogEntry(AppLogLevel LogLevel, string Message, Exception Exception);
127+
}
128+
}

FolderDiffIL4DotNet.Tests/Utils/FileSystemUtilityTests.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ public void IsLikelyNetworkPath_LocalTempPath_ReturnsFalse()
123123
Assert.False(FileSystemUtility.IsLikelyNetworkPath(_tempDir));
124124
}
125125

126+
[Fact]
127+
public void IsLikelyNetworkPath_InvalidPathCharacters_ReturnsFalse()
128+
{
129+
var invalidPath = $"invalid{'\0'}path";
130+
Assert.False(FileSystemUtility.IsLikelyNetworkPath(invalidPath));
131+
}
132+
126133
[Fact]
127134
public void GetBestMatchingMountFileSystemType_PicksMostSpecificMountPoint()
128135
{

README.en.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ Prerequisites:
8282
- .NET SDK 8.x is installed.
8383
- `dotnet-ildasm` / `ilspycmd` are not required for unit tests (they are only needed for IL comparison in the application runtime path).
8484
- The command is intended to be run from the repository root.
85-
- Dedicated core-service unit tests include `FolderDiffServiceTests`, `ReportGenerateServiceTests`, `ILOutputServiceTests`, and `DotNetDisassembleServiceTests`.
85+
- Dedicated core-service unit tests include `FolderDiffServiceTests`, `FileDiffServiceTests`, `ReportGenerateServiceTests`, `ILOutputServiceTests`, and `DotNetDisassembleServiceTests`.
8686
- `ProgramTests` exercises `Main` via reflection with the `--no-pause` execution path, covering both invalid-argument and minimal success scenarios.
8787
- `FileSystemUtilityTests` directly validates network-path mount parsing logic (longest matching mount point selection).
8888

@@ -115,6 +115,7 @@ Relation to CI:
115115
1. **MD5 hash** – if hashes match, the file is `Unchanged (MD5Match)`.
116116
2. **IL diff** – if the file is a .NET assembly (detected via PE/CLR headers, regardless of extension), the app disassembles both versions with the same disassembler/version identity, strips `// MVID:` lines, and compares them line by line. If `ShouldIgnoreILLinesContainingConfiguredStrings` is enabled, lines containing any entry in `ILIgnoreLineContainingStrings` are also ignored (substring match). Matches become `Unchanged (ILMatch)`; mismatches become `Modified (ILMismatch)`.
117117
3. **Text diff** – if the extension appears in `TextFileExtensions` (checked with `StringComparison.OrdinalIgnoreCase`), a line-based text diff runs. Matches are `Unchanged (TextMatch)`; mismatches are `Modified (TextMismatch)`.
118+
- If parallel text diffing throws, the app logs a warning and falls back to sequential text diffing.
118119
4. **Fallback** – remaining files are treated as `Modified (MD5Mismatch)`.
119120

120121
## Configuration (`config.json`)
@@ -238,7 +239,7 @@ Place `config.json` next to the executable. Example:
238239
| `ILCacheMaxDiskFileCount` | Upper bound for disk cache files. Default is `1000`. `<= 0` disables trimming. Oldest entries are removed first. |
239240
| `ILCacheMaxDiskMegabytes` | Disk cache size limit (MB). Default is `512`. `<= 0` disables trimming. Oldest entries are removed until under the limit. |
240241
| `OptimizeForNetworkShares` | Optimizes comparisons on NAS/SMB shares by skipping MD5 pre-warming, reducing parallelism, and forcing sequential diffing of large text files. |
241-
| `AutoDetectNetworkShares` | Detects network paths automatically (UNC on Windows, `statfs` on macOS, `/proc/mounts`/`/etc/mtab` on Linux/Unix) and enables the same optimizations automatically. |
242+
| `AutoDetectNetworkShares` | Detects network paths automatically (UNC on Windows, `statfs` on macOS, `/proc/mounts`/`/etc/mtab` on Linux/Unix) and enables the same optimizations automatically. Recoverable detection failures (invalid path, permission issues, transient I/O) fall back to `false` so the diff itself keeps running. |
242243

243244
Notes:
244245

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ dotnet test FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj --nologo
8282
- .NET SDK 8.x がインストールされていること。
8383
- 単体テストの実行に `dotnet-ildasm` / `ilspycmd` は不要(アプリ本体の IL 比較時のみ必要)。
8484
- リポジトリルートでコマンドを実行することを想定。
85-
- コアサービスの専用ユニットテストとして `FolderDiffServiceTests` / `ReportGenerateServiceTests` / `ILOutputServiceTests` / `DotNetDisassembleServiceTests` を含みます。
85+
- コアサービスの専用ユニットテストとして `FolderDiffServiceTests` / `FileDiffServiceTests` / `ReportGenerateServiceTests` / `ILOutputServiceTests` / `DotNetDisassembleServiceTests` を含みます。
8686
- `ProgramTests` では `Main` をリフレクション経由で実行し、`--no-pause` を付けた実行パス(引数異常系と最小構成の成功系)を検証します。
8787
- `FileSystemUtilityTests` ではネットワークパス判定の mounts 解析(最長一致マウントポイント選択)を直接検証します。
8888

@@ -136,6 +136,7 @@ CI との関係:
136136

137137
3) テキストベースのファイル(`config.json`のTextFileExtensionsに指定された拡張子か否かを `StringComparison.OrdinalIgnoreCase` で判定)であれば行単位で比較します。
138138
- 一致ならばUnchanged, TextMatch、不一致ならばModified, TextMismatchと判定し次のファイル比較へ
139+
- 並列テキスト比較で例外が発生した場合は warning ログを出力し、逐次テキスト比較へフォールバックします。
139140

140141
4) Modified, MD5Mismatchと判定し次のファイル比較へ
141142

@@ -260,7 +261,7 @@ CI との関係:
260261
| ILCacheMaxDiskFileCount | ディスク IL キャッシュの最大ファイル数。既定値は `1000``0` 以下で無制限。超過時は最終アクセスの古い順に削除。 |
261262
| ILCacheMaxDiskMegabytes | ディスク IL キャッシュのサイズ上限(MB)。既定値は `512``0` 以下で無制限。超過時はサイズが下回るまで古い順に削除。 |
262263
| OptimizeForNetworkShares | ネットワーク共有(NAS/SMB など)上のフォルダ比較に最適化。<br>`true` の場合:<br>- 事前MD5プリウォーム(ILCacheのPrecompute)とILキャッシュ先読み(Prefetch)をスキップし、ネットワークI/Oの二重読みを回避<br>- 既定の最大並列度を上限8に抑制(`MaxParallelism`が0以下の場合) <br>- 大きなテキストのチャンク並列比較を使わず逐次比較に統一。<br>1回限りや大規模フォルダの共有ドライブ比較で有効。 |
263-
| AutoDetectNetworkShares | 旧/新フォルダのパスからネットワーク共有を自動検出して「ネットワーク最適化」を自動有効化。<br>macOS:<br>- `statfs` の P/Invoke で `f_flags``MNT_LOCAL`)や `f_fstypename`(例: `smbfs`/`afpfs`/`webdav`/`nfs`/`sshfs`/`fusefs` 等)を確認し、ネットワークFSを検出。<br>Linux/Unix:<br>- `/proc/mounts` または `/etc/mtab` を解析し、`nfs`/`nfs4`/`cifs`/`smbfs`/`sshfs`/`fuse.sshfs`/`fuse.gvfsd-fuse`/`davfs`/`afpfs`/`ceph`/`glusterfs`/`9p` 等のネットワーク系 FS を検出。<br>Windows:<br>- UNC パス (`\\server\\share` / `\\?\\UNC\\...`) とネットワークドライブを検出。<br>※自動検出で `true` になった場合は `OptimizeForNetworkShares``false` のままでも最適化が有効になります。自動検出が `false` となった場合でも `OptimizeForNetworkShares``true` に設定すれば手動で最適化を強制できます。 |
264+
| AutoDetectNetworkShares | 旧/新フォルダのパスからネットワーク共有を自動検出して「ネットワーク最適化」を自動有効化。<br>macOS:<br>- `statfs` の P/Invoke で `f_flags`(`MNT_LOCAL`)や `f_fstypename`(例: `smbfs`/`afpfs`/`webdav`/`nfs`/`sshfs`/`fusefs` 等)を確認し、ネットワークFSを検出。<br>Linux/Unix:<br>- `/proc/mounts` または `/etc/mtab` を解析し、`nfs`/`nfs4`/`cifs`/`smbfs`/`sshfs`/`fuse.sshfs`/`fuse.gvfsd-fuse`/`davfs`/`afpfs`/`ceph`/`glusterfs`/`9p` 等のネットワーク系 FS を検出。<br>Windows:<br>- UNC パス (`\\server\\share` / `\\?\\UNC\\...`) とネットワークドライブを検出。<br>※パス不正・権限不足・一時的な I/O エラーなどの回復可能な検出失敗時は「ローカル扱い(false)」にフォールバックし、比較処理本体は継続します。<br>※自動検出で `true` になった場合は `OptimizeForNetworkShares` が `false` のままでも最適化が有効になります。自動検出が `false` となった場合でも `OptimizeForNetworkShares` を `true` に設定すれば手動で最適化を強制できます。 |
264265

265266
補足:
266267
- 拡張子がないファイルも比較対象です。テキスト扱いにしたい場合はTextFileExtensionsに空文字("")を含める運用を検討してください。

Services/FileDiffService.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ public sealed class FileDiffService
2020
/// </summary>
2121
private const string LOG_IL_DIFF_FAILED = Constants.LABEL_IL + " diff failed for '{0}'.";
2222

23+
/// <summary>
24+
/// テキスト並列比較失敗時のフォールバックログ
25+
/// </summary>
26+
private const string LOG_TEXT_DIFF_PARALLEL_FALLBACK = "Parallel text diff failed for '{0}'. Falling back to sequential text diff.";
27+
2328
/// <summary>
2429
/// 1 KiB (2^10) を表すバイト数。
2530
/// </summary>
@@ -185,8 +190,9 @@ public async Task<bool> FilesAreEqualAsync(string fileRelativePath, int maxParal
185190
}
186191
}
187192
}
188-
catch
193+
catch (Exception ex)
189194
{
195+
_logger.LogMessage(AppLogLevel.Warning, string.Format(LOG_TEXT_DIFF_PARALLEL_FALLBACK, fileRelativePath), shouldOutputMessageToConsole: true, ex);
190196
areTextFilesEqual = await FileComparer.DiffTextFilesAsync(file1AbsolutePath, file2AbsolutePath);
191197
}
192198
_fileDiffResultLists.RecordDiffDetail(fileRelativePath, areTextFilesEqual ? FileDiffResultLists.DiffDetailResult.TextMatch : FileDiffResultLists.DiffDetailResult.TextMismatch);

0 commit comments

Comments
 (0)