fix(transport): cap LineDiffer input size — oversized script diffs degrade to summary instead of O((N+M)^2) memory
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -51,6 +51,16 @@ public sealed record LineDiffResult(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class LineDiffer
|
public static class LineDiffer
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Hard ceiling on the combined old+new line count fed to the Myers algorithm. Myers
|
||||||
|
/// records a V frontier snapshot per edit distance <c>d</c> (up to <c>n + m</c> rounds),
|
||||||
|
/// each snapshot sized <c>O(n + m)</c>, so a fully-divergent diff is <c>O((n + m)^2)</c>
|
||||||
|
/// transient memory. To keep a bloated or crafted bundle from OOM-ing the active central
|
||||||
|
/// node during an import preview, oversized inputs short-circuit to a summary-only result
|
||||||
|
/// (the same shape the output cap emits) instead of running the trace.
|
||||||
|
/// </summary>
|
||||||
|
private const int MaxInputLines = 4000;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Computes a minimal per-line diff between <paramref name="oldText"/> and
|
/// Computes a minimal per-line diff between <paramref name="oldText"/> and
|
||||||
/// <paramref name="newText"/> (either may be <see langword="null"/>, treated as empty).
|
/// <paramref name="newText"/> (either may be <see langword="null"/>, treated as empty).
|
||||||
@@ -68,6 +78,13 @@ public static class LineDiffer
|
|||||||
string[] oldLines = SplitLines(oldText);
|
string[] oldLines = SplitLines(oldText);
|
||||||
string[] newLines = SplitLines(newText);
|
string[] newLines = SplitLines(newText);
|
||||||
|
|
||||||
|
// Input-size guard: bound the ALGORITHM INPUT (distinct from the output cap below) so an
|
||||||
|
// oversized diff degrades to a summary instead of allocating O((n + m)^2) Myers snapshots.
|
||||||
|
if (oldLines.Length + newLines.Length > MaxInputLines)
|
||||||
|
{
|
||||||
|
return SummaryOnlyResult(oldLines.Length, newLines.Length);
|
||||||
|
}
|
||||||
|
|
||||||
IReadOnlyList<LineDiffLine> full = BuildDiff(oldLines, newLines, out int added, out int removed);
|
IReadOnlyList<LineDiffLine> full = BuildDiff(oldLines, newLines, out int added, out int removed);
|
||||||
|
|
||||||
bool truncated = full.Count > maxLines;
|
bool truncated = full.Count > maxLines;
|
||||||
@@ -91,6 +108,15 @@ public static class LineDiffer
|
|||||||
return new LineDiffResult(lines, truncated, added, removed);
|
return new LineDiffResult(lines, truncated, added, removed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the truncated summary-only shape returned when the diff cannot be materialized in
|
||||||
|
/// full — no hunks, <see cref="LineDiffResult.Truncated"/> set, and the add/remove totals
|
||||||
|
/// approximated as a full replacement (every new line added, every old line removed). Used
|
||||||
|
/// by the input-size guard so a diff too large to run cheaply still reports its magnitude.
|
||||||
|
/// </summary>
|
||||||
|
private static LineDiffResult SummaryOnlyResult(int oldCount, int newCount)
|
||||||
|
=> new([], Truncated: true, AddedCount: newCount, RemovedCount: oldCount);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Normalizes line endings (<c>\r\n</c> and lone <c>\r</c> to <c>\n</c>) and splits on <c>\n</c>.
|
/// Normalizes line endings (<c>\r\n</c> and lone <c>\r</c> to <c>\n</c>) and splits on <c>\n</c>.
|
||||||
/// A <see langword="null"/> or empty input yields an empty array (no lines), so the diff of two
|
/// A <see langword="null"/> or empty input yields an empty array (no lines), so the diff of two
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;
|
||||||
@@ -189,4 +191,29 @@ public sealed class LineDifferTests
|
|||||||
Assert.Equal(0, result.AddedCount);
|
Assert.Equal(0, result.AddedCount);
|
||||||
Assert.Equal(0, result.RemovedCount);
|
Assert.Equal(0, result.RemovedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OversizedInputs_ZeroCommonLines_DegradesToSummary_WithoutRunningMyers()
|
||||||
|
{
|
||||||
|
// Two 10,000-line inputs with zero common lines. Running the Myers trace here would
|
||||||
|
// record ~20,000 V-snapshots of ~160 KB each (multi-GB transient allocation), so the
|
||||||
|
// input-size guard must short-circuit to a summary-only result long before that.
|
||||||
|
var oldText = string.Join("\n", Enumerable.Range(1, 10_000).Select(i => $"old-line-{i}"));
|
||||||
|
var newText = string.Join("\n", Enumerable.Range(1, 10_000).Select(i => $"new-line-{i}"));
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var result = LineDiffer.Diff(oldText, newText);
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
// Summary-only shape: same as the output cap — Truncated, full totals, no hunks.
|
||||||
|
Assert.True(result.Truncated);
|
||||||
|
Assert.Empty(result.Lines);
|
||||||
|
Assert.Equal(10_000, result.AddedCount);
|
||||||
|
Assert.Equal(10_000, result.RemovedCount);
|
||||||
|
|
||||||
|
// Must return within a small time budget; pathological slowness is a failure.
|
||||||
|
Assert.True(
|
||||||
|
sw.Elapsed < TimeSpan.FromSeconds(2),
|
||||||
|
$"Diff of oversized inputs took {sw.Elapsed.TotalSeconds:F1}s — the input-size guard did not short-circuit.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user