Skip to content

Commit 4316761

Browse files
committed
サイレント catch を解消してテキスト比較フォールバックと .NET 判定失敗の診断性を改善
1 parent c72ff78 commit 4316761

8 files changed

Lines changed: 212 additions & 84 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1414
- Moved aggregated `MD5Mismatch` console warnings into `ProgramRunner`, kept `ReportGenerateService` report-only, and updated related docs and automated tests.
1515
- Replaced one-off `string.Format(...)` usage with interpolated strings, removed broad `#region` usage, and deleted now-unused format/message constants.
1616
- Updated the developer and testing guides to reflect the current source-style expectations and latest passing test count.
17+
- Made `.NET` executable detection distinguish `NotDotNetExecutable` from detection failure, log a warning for non-fatal detection failures, and let chunk-parallel text-diff exceptions bubble to the existing sequential fallback path instead of silently returning `false`.
1718

1819
### [1.2.2] - 2026-03-14
1920

@@ -210,6 +211,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
210211
- 集約後の `MD5Mismatch` コンソール警告を `ProgramRunner` に移し、`ReportGenerateService` はレポート専用の責務に整理しました。あわせて関連ドキュメントと自動テストを更新しました。
211212
- 単発利用の `string.Format(...)` を補間文字列へ置き換え、広範な `#region` 利用をやめ、不要になった書式・メッセージ定数を削除しました。
212213
- 開発ガイドとテストガイドを更新し、現在のソースコード方針と最新の通過テスト件数を反映しました。
214+
- `.NET` 実行可能判定で `NotDotNetExecutable` と判定失敗を区別するようにし、致命ではない判定失敗は warning を残して継続するようにしました。あわせて並列テキスト比較の例外は `false` に潰さず、既存の逐次比較フォールバック経路へ伝播させるようにしました。
213215

214216
### [1.2.2] - 2026-03-14
215217

FolderDiffIL4DotNet.Tests/Services/FileDiffServiceTests.cs

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.IO;
4+
using System.Linq;
45
using System.Threading.Tasks;
56
using FolderDiffIL4DotNet.Models;
67
using FolderDiffIL4DotNet.Services;
@@ -89,20 +90,75 @@ public async Task FilesAreEqualAsync_WhenPrimaryTextDiffThrows_LogsWarningAndFal
8990

9091
Assert.False(areEqual);
9192
Assert.Equal(FileDiffResultLists.DiffDetailResult.TextMismatch, resultLists.FileRelativePathToDiffDetailDictionary[fileRelativePath]);
92-
var warningLog = Assert.Single(logger.Entries, entry => entry.LogLevel == AppLogLevel.Warning);
93+
var warningLog = Assert.Single(logger.Entries, entry => entry.LogLevel == AppLogLevel.Warning && entry.Message.Contains("Falling back to sequential text diff", StringComparison.Ordinal));
9394
Assert.Contains("Falling back to sequential text diff", warningLog.Message);
9495
Assert.IsType<IOException>(warningLog.Exception);
96+
Assert.Contains(logger.Entries, entry => entry.LogLevel == AppLogLevel.Warning && entry.Message.Contains("Failed to detect whether 'sample.txt' is a .NET executable", StringComparison.Ordinal));
9597
}
9698
finally
9799
{
98100
exclusiveLockStream?.Dispose();
99101
}
100102
}
101103

104+
[Fact]
105+
public async Task FilesAreEqualAsync_WhenParallelTextDiffThrows_LogsWarningAndFallsBackToSequentialDiff()
106+
{
107+
var oldDir = Path.Combine(_rootDir, "old-parallel");
108+
var newDir = Path.Combine(_rootDir, "new-parallel");
109+
Directory.CreateDirectory(oldDir);
110+
Directory.CreateDirectory(newDir);
111+
112+
const string fileRelativePath = "large.txt";
113+
var oldFileAbsolutePath = Path.Combine(oldDir, fileRelativePath);
114+
var newFileAbsolutePath = Path.Combine(newDir, fileRelativePath);
115+
File.WriteAllText(oldFileAbsolutePath, new string('A', 2048));
116+
File.WriteAllText(newFileAbsolutePath, new string('B', 2048));
117+
118+
var config = new ConfigSettings
119+
{
120+
TextFileExtensions = new List<string> { ".txt" },
121+
IgnoredExtensions = new List<string>(),
122+
ShouldOutputILText = false,
123+
EnableILCache = false,
124+
OptimizeForNetworkShares = false,
125+
TextDiffParallelThresholdKilobytes = 1,
126+
TextDiffChunkSizeKilobytes = 1
127+
};
128+
129+
var logger = new TestLogger();
130+
var resultLists = new FileDiffResultLists();
131+
var executionContext = new DiffExecutionContext(
132+
oldDir,
133+
newDir,
134+
Path.Combine(_rootDir, "report-parallel"),
135+
optimizeForNetworkShares: false,
136+
detectedNetworkOld: false,
137+
detectedNetworkNew: false);
138+
139+
var ilTextOutputService = new ILTextOutputService(executionContext, logger);
140+
var dotNetDisassembleService = new DotNetDisassembleService(config, ilCache: null, resultLists, logger, new DotNetDisassemblerCache(logger));
141+
var ilOutputService = new ILOutputService(config, executionContext, ilTextOutputService, dotNetDisassembleService, ilCache: null, logger);
142+
var service = new FileDiffService(config, ilOutputService, executionContext, resultLists, logger);
143+
144+
var areEqual = await service.FilesAreEqualAsync(fileRelativePath, maxParallel: 0);
145+
146+
Assert.False(areEqual);
147+
Assert.Equal(FileDiffResultLists.DiffDetailResult.TextMismatch, resultLists.FileRelativePathToDiffDetailDictionary[fileRelativePath]);
148+
var warningLog = Assert.Single(logger.Entries, entry => entry.LogLevel == AppLogLevel.Warning);
149+
Assert.Contains("Falling back to sequential text diff", warningLog.Message);
150+
Assert.IsType<ArgumentOutOfRangeException>(warningLog.Exception);
151+
}
152+
102153
private sealed class TestLogger : ILoggerService
103154
{
104155
private readonly Action<LogEntry> _onEntry;
105156

157+
public TestLogger()
158+
: this(null)
159+
{
160+
}
161+
106162
public TestLogger(Action<LogEntry> onEntry)
107163
{
108164
_onEntry = onEntry;

FolderDiffIL4DotNet.Tests/Utils/DotNetDetectorTests.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ public void IsDotNetExecutable_NonexistentFile_ReturnsFalse()
6161
Assert.False(DotNetDetector.IsDotNetExecutable(Path.Combine(_tempDir, "nonexistent.dll")));
6262
}
6363

64+
[Fact]
65+
public void DetectDotNetExecutable_NonexistentFile_ReturnsFailedWithException()
66+
{
67+
var result = DotNetDetector.DetectDotNetExecutable(Path.Combine(_tempDir, "nonexistent.dll"));
68+
69+
Assert.Equal(DotNetExecutableDetectionStatus.Failed, result.Status);
70+
Assert.False(result.IsDotNetExecutable);
71+
Assert.True(result.IsFailure);
72+
Assert.IsType<FileNotFoundException>(result.Exception);
73+
}
74+
6475
[Fact]
6576
public void IsDotNetExecutable_RandomBytes_ReturnsFalse()
6677
{
@@ -71,6 +82,19 @@ public void IsDotNetExecutable_RandomBytes_ReturnsFalse()
7182
Assert.False(DotNetDetector.IsDotNetExecutable(file));
7283
}
7384

85+
[Fact]
86+
public void DetectDotNetExecutable_TextFile_ReturnsNotDotNet()
87+
{
88+
var file = CreateTempFile("plain.txt", "This is not a PE file");
89+
90+
var result = DotNetDetector.DetectDotNetExecutable(file);
91+
92+
Assert.Equal(DotNetExecutableDetectionStatus.NotDotNetExecutable, result.Status);
93+
Assert.False(result.IsDotNetExecutable);
94+
Assert.False(result.IsFailure);
95+
Assert.Null(result.Exception);
96+
}
97+
7498
[Fact]
7599
public void IsDotNetExecutable_MinimalMZHeader_NoCLR_ReturnsFalse()
76100
{

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ Important details:
108108
- `Added`, `Removed`, `Unchanged`, and `Modified` are decided by relative path, not by file name alone.
109109
- IL comparison always ignores `// MVID:` lines, so build-specific assembly noise does not create false differences.
110110
- If `ShouldIgnoreILLinesContainingConfiguredStrings=true`, lines containing any configured ignore string are also skipped during IL comparison.
111-
- Text files may use different internal strategies depending on size and runtime mode, but the final result is still just text match or text mismatch.
111+
- Text files may use different internal strategies depending on size and runtime mode. If chunk-parallel comparison for a large local file throws, the run logs a warning and retries with sequential text comparison.
112112
- If IL comparison itself fails, the run stops instead of silently falling back to a weaker comparison.
113113

114114
## Configuration (`config.json`)
@@ -370,7 +370,7 @@ flowchart TD
370370
- `Added` / `Removed` / `Unchanged` / `Modified` は、ファイル名だけでなく相対パスを基準に決まります。
371371
- IL 比較では `// MVID:` 行を常に無視するため、ビルドごとの差分だけで別物扱いになりにくくしています。
372372
- `ShouldIgnoreILLinesContainingConfiguredStrings=true` の場合は、設定した文字列を含む行も IL 比較から除外します。
373-
- テキスト比較の内部実装はファイルサイズや実行モードで変わることがありますが、最終結果はテキスト一致か不一致です
373+
- テキスト比較の内部実装はファイルサイズや実行モードで変わることがあります。大きいローカルファイルの並列比較で例外が出た場合は warning を記録し、逐次比較へフォールバックします
374374
- IL 比較そのものに失敗した場合は、弱い比較へ黙って落とさず、その実行全体を停止します。
375375

376376
## 設定(`config.json`

Services/FileDiffService.cs

Lines changed: 64 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,17 @@ public async Task<bool> FilesAreEqualAsync(string fileRelativePath, int maxParal
121121
}
122122

123123
// 2) .NET アセンブリなら IL: IL 比較は行除外(MVID や設定文字列)などアセンブリ固有処理を伴うため別サービスに委譲。
124-
if (DotNetDetector.IsDotNetExecutable(file1AbsolutePath))
124+
var dotNetDetectionResult = DotNetDetector.DetectDotNetExecutable(file1AbsolutePath);
125+
if (dotNetDetectionResult.IsFailure)
126+
{
127+
_logger.LogMessage(
128+
AppLogLevel.Warning,
129+
$"Failed to detect whether '{fileRelativePath}' is a .NET executable. Skipping IL diff.",
130+
shouldOutputMessageToConsole: true,
131+
dotNetDetectionResult.Exception);
132+
}
133+
134+
if (dotNetDetectionResult.IsDotNetExecutable)
125135
{
126136
try
127137
{
@@ -200,83 +210,78 @@ public async Task<bool> FilesAreEqualAsync(string fileRelativePath, int maxParal
200210
/// <summary>
201211
/// サイズが閾値を超えるテキストファイルに対して高速化を目的に並列チャンク比較を行う実験的メソッド。
202212
/// 完全一致判定のみを行い、差分箇所の特定は行いません。
203-
/// なお、本メソッドはエラーや引数不正が発生した場合でも例外を呼出し側へ送出せず、false を返します
213+
/// エラーや引数不正は呼び出し側へ送出し、呼び出し側で逐次比較へのフォールバック可否を判断します
204214
/// </summary>
205215
/// <param name="file1AbsolutePath">ファイル1の絶対パス</param>
206216
/// <param name="file2AbsolutePath">ファイル2の絶対パス</param>
207217
/// <param name="largeFileSizeThresholdBytes">並列化閾値(バイト)。これ未満は逐次比較。</param>
208218
/// <param name="chunkSizeBytes">チャンクサイズ(バイト)。</param>
209219
/// <param name="maxParallel">最大並列度</param>
210-
/// <returns>一致すれば true。エラーや引数不正時は false。</returns>
220+
/// <returns>一致すれば true。不一致なら false。</returns>
221+
/// <exception cref="ArgumentOutOfRangeException"><paramref name="maxParallel"/> が 0 以下の場合。</exception>
222+
/// <exception cref="Exception">チャンク読み取りや比較準備で予期しない例外が発生した場合。</exception>
211223
private static async Task<bool> DiffTextFilesParallelAsync(string file1AbsolutePath, string file2AbsolutePath, long largeFileSizeThresholdBytes, int chunkSizeBytes, int maxParallel)
212224
{
213-
try
225+
var file1Info = new FileInfo(file1AbsolutePath);
226+
var file2Info = new FileInfo(file2AbsolutePath);
227+
// どちらかが存在しない、またはサイズが異なる場合は比較するまでもなく不一致。
228+
if (!file1Info.Exists || !file2Info.Exists)
214229
{
215-
var file1Info = new FileInfo(file1AbsolutePath);
216-
var file2Info = new FileInfo(file2AbsolutePath);
217-
// どちらかが存在しない、またはサイズが異なる場合は比較するまでもなく不一致。
218-
if (!file1Info.Exists || !file2Info.Exists)
219-
{
220-
return false;
221-
}
222-
if (file1Info.Length != file2Info.Length)
230+
return false;
231+
}
232+
if (file1Info.Length != file2Info.Length)
233+
{
234+
return false;
235+
}
236+
// 小さいファイルは既存の逐次比較に委譲して余計なオーバーヘッドを避ける。
237+
if (file1Info.Length < largeFileSizeThresholdBytes)
238+
{
239+
return await FileComparer.DiffTextFilesAsync(file1AbsolutePath, file2AbsolutePath);
240+
}
241+
if (maxParallel <= 0)
242+
{
243+
throw new ArgumentOutOfRangeException(nameof(maxParallel), maxParallel, Constants.ERROR_MAX_PARALLEL);
244+
}
245+
246+
// 大きなファイルは固定サイズのチャンクに分割し、読み取り→比較を並列実行する。
247+
int chunkCount = (int)((file1Info.Length + chunkSizeBytes - 1) / chunkSizeBytes);
248+
var differences = 0;
249+
await Parallel.ForEachAsync(Enumerable.Range(0, chunkCount), new ParallelOptions { MaxDegreeOfParallelism = maxParallel }, async (index, cancellationToken) =>
250+
{
251+
// 既に差分が見つかっていれば以降のチャンクは読む必要がない。
252+
if (Volatile.Read(ref differences) != 0)
223253
{
224-
return false;
254+
return;
225255
}
226-
// 小さいファイルは既存の逐次比較に委譲して余計なオーバーヘッドを避ける。
227-
if (file1Info.Length < largeFileSizeThresholdBytes)
256+
var buffer1 = new byte[chunkSizeBytes];
257+
var buffer2 = new byte[chunkSizeBytes];
258+
int read1, read2;
259+
using (var file1Stream = new FileStream(file1AbsolutePath, FileMode.Open, FileAccess.Read, FileShare.Read))
260+
using (var file2Stream = new FileStream(file2AbsolutePath, FileMode.Open, FileAccess.Read, FileShare.Read))
228261
{
229-
return await FileComparer.DiffTextFilesAsync(file1AbsolutePath, file2AbsolutePath);
262+
file1Stream.Seek((long)index * chunkSizeBytes, SeekOrigin.Begin);
263+
file2Stream.Seek((long)index * chunkSizeBytes, SeekOrigin.Begin);
264+
read1 = await file1Stream.ReadAsync(buffer1.AsMemory(0, chunkSizeBytes), cancellationToken);
265+
read2 = await file2Stream.ReadAsync(buffer2.AsMemory(0, chunkSizeBytes), cancellationToken);
230266
}
231-
if (maxParallel <= 0)
267+
// 同じオフセットのチャンクでも読み取りバイト数が異なれば即時不一致。
268+
if (read1 != read2)
232269
{
233-
throw new ArgumentOutOfRangeException(nameof(maxParallel), maxParallel, Constants.ERROR_MAX_PARALLEL);
270+
Interlocked.Exchange(ref differences, 1);
271+
return;
234272
}
235-
236-
// 大きなファイルは固定サイズのチャンクに分割し、読み取り→比較を並列実行する。
237-
int chunkCount = (int)((file1Info.Length + chunkSizeBytes - 1) / chunkSizeBytes);
238-
var differences = 0;
239-
await Parallel.ForEachAsync(Enumerable.Range(0, chunkCount), new ParallelOptions { MaxDegreeOfParallelism = maxParallel }, async (index, cancellationToken) =>
273+
// チャンク内で1バイトでも異なれば不一致とし、他チャンクも打ち切る。
274+
for (int i = 0; i < read1; i++)
240275
{
241-
// 既に差分が見つかっていれば以降のチャンクは読む必要がない。
242-
if (Volatile.Read(ref differences) != 0)
243-
{
244-
return;
245-
}
246-
var buffer1 = new byte[chunkSizeBytes];
247-
var buffer2 = new byte[chunkSizeBytes];
248-
int read1, read2;
249-
using (var file1Stream = new FileStream(file1AbsolutePath, FileMode.Open, FileAccess.Read, FileShare.Read))
250-
using (var file2Stream = new FileStream(file2AbsolutePath, FileMode.Open, FileAccess.Read, FileShare.Read))
251-
{
252-
file1Stream.Seek((long)index * chunkSizeBytes, SeekOrigin.Begin);
253-
file2Stream.Seek((long)index * chunkSizeBytes, SeekOrigin.Begin);
254-
read1 = await file1Stream.ReadAsync(buffer1.AsMemory(0, chunkSizeBytes), cancellationToken);
255-
read2 = await file2Stream.ReadAsync(buffer2.AsMemory(0, chunkSizeBytes), cancellationToken);
256-
}
257-
// 同じオフセットのチャンクでも読み取りバイト数が異なれば即時不一致。
258-
if (read1 != read2)
276+
if (buffer1[i] != buffer2[i])
259277
{
260278
Interlocked.Exchange(ref differences, 1);
261-
return;
279+
break;
262280
}
263-
// チャンク内で1バイトでも異なれば不一致とし、他チャンクも打ち切る。
264-
for (int i = 0; i < read1; i++)
265-
{
266-
if (buffer1[i] != buffer2[i])
267-
{
268-
Interlocked.Exchange(ref differences, 1);
269-
break;
270-
}
271-
}
272-
});
273-
// 差分フラグが立っていなければ完全一致。
274-
return differences == 0;
275-
}
276-
catch
277-
{
278-
return false;
279-
}
281+
}
282+
});
283+
// 差分フラグが立っていなければ完全一致。
284+
return differences == 0;
280285
}
281286

282287
/// <summary>

0 commit comments

Comments
 (0)