diff --git a/app/Tests/Tools/HTMLParserConcurrencyTests.cs b/app/Tests/Tools/HTMLParserConcurrencyTests.cs new file mode 100644 index 00000000..d10e89b2 --- /dev/null +++ b/app/Tests/Tools/HTMLParserConcurrencyTests.cs @@ -0,0 +1,121 @@ +using System.Collections.Concurrent; +using AIStudio.Tools; + +namespace AIStudio.Tests.Tools; + +/// +/// Checks that converting several pages to Markdown at the same time keeps them apart. +/// +/// +/// A web search reads up to four result pages in parallel, and every one of them is converted +/// through the same entry point. The converter doing that work tracks the ancestors of the node it +/// is at, and it does so without any synchronization, so sharing one converter between those +/// conversions let them write into each other's ancestor lists.

+/// That went wrong in two ways, and this test covers both. Loudly, as a torn list throwing an index +/// out of range — which is what showed up in the logs. And quietly, as a list indented by the depth +/// a different page happened to be at, which nothing reports and which only a comparison against a +/// known-good conversion catches. +///
+[TestFixture] +public sealed class HTMLParserConcurrencyTests +{ + private const int THREAD_COUNT = 8; + private const int CONVERSIONS_PER_THREAD = 40; + + [Test] + public void ParallelConversionsDoNotInterfereWithEachOther() + { + var html = BuildPageHtml(); + + // Converted alone, with nothing else running, this is what the page has to come back as: + var expected = HTMLParser.ParseToMarkdown(html); + + var results = new ConcurrentBag(); + var failures = new ConcurrentBag(); + + // + // Real threads released by a barrier rather than a parallel loop: the conversions have to + // overlap for this test to mean anything, and only starting them together makes that + // certain. + // + using var startSignal = new Barrier(THREAD_COUNT); + var threads = new List(THREAD_COUNT); + for (var threadIndex = 0; threadIndex < THREAD_COUNT; threadIndex++) + { + var thread = new Thread(() => + { + startSignal.SignalAndWait(); + for (var conversion = 0; conversion < CONVERSIONS_PER_THREAD; conversion++) + { + try + { + results.Add(HTMLParser.ParseToMarkdown(html)); + } + catch (Exception exception) + { + failures.Add(exception); + } + } + }); + + thread.Start(); + threads.Add(thread); + } + + foreach (var thread in threads) + thread.Join(); + + var failureKinds = string.Join(", ", failures.Select(x => x.GetType().Name).Distinct(StringComparer.Ordinal)); + var deviatingCount = results.Count(x => !string.Equals(x, expected, StringComparison.Ordinal)); + + Assert.Multiple(() => + { + Assert.That(failures, Is.Empty, $"Converting in parallel threw {failures.Count} times ({failureKinds}). A conversion must not depend on what another thread is converting."); + Assert.That(deviatingCount, Is.Zero, $"{deviatingCount} of {results.Count} conversions came back different from the same page converted on its own. Their indentation was counted from ancestors belonging to another conversion."); + }); + } + + /// + /// Builds a page out of the elements the reported stack traces named. + /// + /// + /// The nested lists are what makes this sharp: their indentation is computed from the ancestors + /// the converter is tracking, so a conversion which picked up somebody else's ancestors comes + /// back indented differently rather than failing outright. The block is repeated so that the + /// conversions take long enough to actually overlap. + /// + private static string BuildPageHtml() + { + const string BLOCK = + """ +
+

An introduction to the topic at hand.

+
    +
  1. First item +
      +
    • Nested item +
        +
      1. Deeply nested item
      2. +
      3. Another one +
        • And one level deeper still
        +
      4. +
      +
    • +
    +
  2. +
  3. Second item
  4. +
+ + + + + + +
Column AColumn B

A cell holding a paragraph.

  • A cell holding a list
  • with two entries
+

A closing paragraph with bold and emphasized text.

+
+ """; + + return string.Concat(Enumerable.Repeat(BLOCK, 20)); + } +} \ No newline at end of file