MiniYaml.From* methods support deferred execution.

Previously, the MiniYaml.From* helpers such as FromStream would consume the entire input and then return a list of top-level nodes. Now, the input is processed using deferred execution and top-level nodes are yielded as they are resolved from the input.

The motivating use-case is MapCache, which currently manually buffers nodes before passing to MiniYaml.FromString in order to improve responsiveness when large payloads are processed. Now that MiniYaml.FromStream yields results back as they come in, we can switch to that without disadvantage.

The maintains the performance where map cache can update search results as each node comes in over the network rather than having to wait for the entire batch to be transferred. Now we can also remove the string buffer that captures each node, reducing memory pressure and simplifying the code.
This commit is contained in:
RoosterDragon
2025-04-11 20:22:35 +01:00
committed by Gustas Kažukauskas
parent 5676d7be40
commit 8f46247dc9
17 changed files with 171 additions and 70 deletions

View File

@@ -110,7 +110,7 @@ namespace OpenRA
Package = package;
var stringPool = new HashSet<string>(); // Reuse common strings in YAML
var nodes = MiniYaml.FromStream(package.GetStream("mod.yaml"), $"{package.Name}:mod.yaml", stringPool: stringPool);
var nodes = MiniYaml.FromStream(package.GetStream("mod.yaml"), $"{package.Name}:mod.yaml", stringPool: stringPool).ToList();
for (var i = nodes.Count - 1; i >= 0; i--)
{
if (nodes[i].Key != "Include")

View File

@@ -14,7 +14,6 @@ using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using OpenRA.FileSystem;
@@ -141,7 +140,7 @@ namespace OpenRA
}
void LoadMapInternal(string map, IReadOnlyPackage package, MapClassification classification, string oldMap,
MapGridType? gridType = null, IEnumerable<List<MiniYamlNode>> modDataRules = null)
MapGridType? gridType = null, MiniYamlNode[][] modDataRules = null)
{
IReadOnlyPackage mapPackage = null;
try
@@ -242,41 +241,19 @@ namespace OpenRA
{
var client = HttpClientFactory.Create();
var stringPool = new HashSet<string>(); // Reuse common strings in YAML
var buffer = new StringBuilder();
// Limit each query to 50 maps at a time to avoid request size limits
for (var i = 0; i < queryUids.Count; i += 50)
foreach (var batchUids in queryUids.Chunk(50))
{
var batchUids = queryUids.Skip(i).Take(50).ToList();
var url = repositoryUrl + "hash/" + string.Join(",", batchUids) + "/yaml";
using (new PerfTimer("RemoteMapDetails"))
{
try
{
await using (var resultStream = await client.GetStreamAsync(url))
{
using (var resultReader = new StreamReader(resultStream))
{
while (true)
{
var line = await resultReader.ReadLineAsync();
if (line == null || !line.StartsWith('\t'))
{
var yaml = MiniYaml.FromString(buffer.ToString(), url, stringPool: stringPool);
buffer.Clear();
foreach (var kv in yaml)
previews[kv.Key].UpdateRemoteSearch(MapStatus.DownloadAvailable, kv.Value, mapDetailsReceived);
if (line == null)
break;
}
buffer.Append(line);
buffer.Append('\n');
}
}
}
var result = await client.GetStreamAsync(url);
var yaml = MiniYaml.FromStream(result, url, stringPool: stringPool);
foreach (var kv in yaml)
previews[kv.Key].UpdateRemoteSearch(MapStatus.DownloadAvailable, kv.Value, mapDetailsReceived);
foreach (var uid in batchUids)
{

View File

@@ -113,7 +113,7 @@ namespace OpenRA
return key == "world" || key == "player";
}
public void SetCustomRules(ModData modData, IReadOnlyFileSystem fileSystem, Dictionary<string, MiniYaml> yaml, IEnumerable<List<MiniYamlNode>> modDataRules)
public void SetCustomRules(ModData modData, IReadOnlyFileSystem fileSystem, Dictionary<string, MiniYaml> yaml, MiniYamlNode[][] modDataRules)
{
RuleDefinitions = LoadRuleSection(yaml, "Rules");
WeaponDefinitions = LoadRuleSection(yaml, "Weapons");
@@ -341,7 +341,7 @@ namespace OpenRA
/// A new copy of the map package will be opened lazily when needed.
/// </summary>
public void UpdateFromMapWithoutOwningPackage(IReadOnlyPackage p, IReadOnlyPackage parent, MapClassification classification,
MapGridType? gridType = null, IEnumerable<List<MiniYamlNode>> modDataRules = null)
MapGridType? gridType = null, MiniYamlNode[][] modDataRules = null)
{
UpdateFromMap(p, classification, gridType, modDataRules);
parentPackage = parent;
@@ -353,7 +353,7 @@ namespace OpenRA
/// The package remains in memory and must not be disposed.
/// </summary>
public void UpdateFromMap(IReadOnlyPackage p, MapClassification classification,
MapGridType? gridType = null, IEnumerable<List<MiniYamlNode>> modDataRules = null)
MapGridType? gridType = null, MiniYamlNode[][] modDataRules = null)
{
Path = p.Name;
package = p;

View File

@@ -198,7 +198,8 @@ namespace OpenRA
Nodes = nodes.ToImmutableArray();
}
static List<MiniYamlNode> FromLines(IEnumerable<ReadOnlyMemory<char>> lines, string name, bool discardCommentsAndWhitespace, HashSet<string> stringPool)
static IEnumerable<MiniYamlNode> FromLines(
IEnumerable<ReadOnlyMemory<char>> lines, string name, bool discardCommentsAndWhitespace, HashSet<string> stringPool)
{
// YAML config often contains repeated strings for key, values, comments.
// Pool these strings so we only need one copy of each unique string.
@@ -335,12 +336,19 @@ namespace OpenRA
parsedLines.Add((level, keyString, valueString, commentString, location));
}
foreach (var topLevelNode in result[0])
yield return topLevelNode;
result[0].Clear();
}
if (parsedLines.Count > 0)
{
BuildCompletedSubNode(0);
return result[0];
foreach (var topLevelNode in result[0])
yield return topLevelNode;
result[0].Clear();
}
void BuildCompletedSubNode(int level)
{
@@ -375,22 +383,22 @@ namespace OpenRA
}
}
public static List<MiniYamlNode> FromFile(string path, bool discardCommentsAndWhitespace = true, HashSet<string> stringPool = null)
public static IEnumerable<MiniYamlNode> FromFile(string path, bool discardCommentsAndWhitespace = true, HashSet<string> stringPool = null)
{
return FromStream(File.OpenRead(path), path, discardCommentsAndWhitespace, stringPool);
}
public static List<MiniYamlNode> FromStream(Stream s, string name, bool discardCommentsAndWhitespace = true, HashSet<string> stringPool = null)
public static IEnumerable<MiniYamlNode> FromStream(Stream s, string name, bool discardCommentsAndWhitespace = true, HashSet<string> stringPool = null)
{
return FromLines(s.ReadAllLinesAsMemory(), name, discardCommentsAndWhitespace, stringPool);
}
public static List<MiniYamlNode> FromString(string text, string name, bool discardCommentsAndWhitespace = true, HashSet<string> stringPool = null)
public static IEnumerable<MiniYamlNode> FromString(string text, string name, bool discardCommentsAndWhitespace = true, HashSet<string> stringPool = null)
{
return FromLines(text.Split(["\r\n", "\n"], StringSplitOptions.None).Select(s => s.AsMemory()), name, discardCommentsAndWhitespace, stringPool);
}
public static List<MiniYamlNode> Merge(IEnumerable<IReadOnlyCollection<MiniYamlNode>> sources)
public static List<MiniYamlNode> Merge(IEnumerable<IEnumerable<MiniYamlNode>> sources)
{
var sourcesList = sources.ToList();
if (sourcesList.Count == 0)
@@ -398,6 +406,7 @@ namespace OpenRA
var tree = sourcesList
.Where(s => s != null)
.Select(s => s as IReadOnlyCollection<MiniYamlNode> ?? s.ToList())
.Select(MergeSelfPartial)
.Aggregate(MergePartial)
.Where(n => n.Key != null)
@@ -665,7 +674,7 @@ namespace OpenRA
}
var stringPool = new HashSet<string>(); // Reuse common strings in YAML
IEnumerable<IReadOnlyCollection<MiniYamlNode>> yaml = files.Select(s => FromStream(fileSystem.Open(s), s, stringPool: stringPool));
var yaml = files.Select(s => FromStream(fileSystem.Open(s), s, stringPool: stringPool));
if (mapRules != null && mapRules.Nodes.Length > 0)
yaml = yaml.Append(mapRules.Nodes);

View File

@@ -154,10 +154,10 @@ namespace OpenRA
entry.Value.Load(map);
}
public List<MiniYamlNode>[] GetRulesYaml()
public MiniYamlNode[][] GetRulesYaml()
{
var stringPool = new HashSet<string>(); // Reuse common strings in YAML
return Manifest.Rules.Select(s => MiniYaml.FromStream(DefaultFileSystem.Open(s), s, stringPool: stringPool)).ToArray();
return Manifest.Rules.Select(s => MiniYaml.FromStream(DefaultFileSystem.Open(s), s, stringPool: stringPool).ToArray()).ToArray();
}
public void Dispose()

View File

@@ -123,7 +123,7 @@ namespace OpenRA.Network
lastSyncPacket = rs.ReadBytes(Order.SyncHashOrderLength);
var globalSettings = MiniYaml.FromString(rs.ReadLengthPrefixedString(Encoding.UTF8, Connection.MaxOrderLength), $"{filepath}:globalSettings");
GlobalSettings = Session.Global.Deserialize(globalSettings[0].Value);
GlobalSettings = Session.Global.Deserialize(globalSettings.First().Value);
var slots = MiniYaml.FromString(rs.ReadLengthPrefixedString(Encoding.UTF8, Connection.MaxOrderLength), $"{filepath}:slots");
Slots = [];

View File

@@ -183,7 +183,7 @@ namespace OpenRA.Network
if (!string.IsNullOrEmpty(order.TargetString))
{
var data = MiniYaml.FromString(order.TargetString, order.OrderString);
var data = MiniYaml.FromString(order.TargetString, order.OrderString).ToList();
var saveLastOrdersFrame = data.FirstOrDefault(n => n.Key == "SaveLastOrdersFrame");
if (saveLastOrdersFrame != null)
orderManager.GameSaveLastFrame =
@@ -203,7 +203,7 @@ namespace OpenRA.Network
case "SaveTraitData":
{
var data = MiniYaml.FromString(order.TargetString, order.OrderString)[0];
var data = MiniYaml.FromString(order.TargetString, order.OrderString).First();
var traitIndex = Exts.ParseInt32Invariant(data.Key);
world?.AddGameSaveTraitData(traitIndex, data.Value);

View File

@@ -1016,7 +1016,7 @@ namespace OpenRA.Server
{
if (GameSave != null)
{
var data = MiniYaml.FromString(o.TargetString, o.OrderString)[0];
var data = MiniYaml.FromString(o.TargetString, o.OrderString).First();
GameSave.AddTraitData(OpenRA.Exts.ParseInt32Invariant(data.Key), data.Value);
}

View File

@@ -348,7 +348,7 @@ namespace OpenRA
if (File.Exists(settingsFile))
{
yamlCache = MiniYaml.FromFile(settingsFile, false);
yamlCache = MiniYaml.FromFile(settingsFile, false).ToList();
foreach (var yamlSection in yamlCache)
{
if (yamlSection.Key != null && Sections.TryGetValue(yamlSection.Key, out var settingsSection))

View File

@@ -211,7 +211,7 @@ namespace OpenRA
{
var offset = 0;
int read;
while ((read = sr.ReadBlock(buffer, offset, buffer.Length - offset)) != 0)
while ((read = sr.Read(buffer, offset, buffer.Length - offset)) != 0)
{
offset += read;

View File

@@ -38,7 +38,8 @@ namespace OpenRA.Mods.Common.UpdateRules.Rules
// Keep a resolved copy of the sequences so we can account for values imported through inheritance or Defaults.
// This will be modified during processing, so take a deep copy to avoid side-effects on other update rules.
this.resolvedImagesNodes = MiniYaml.FromString(resolvedImagesNodes.WriteToString(), nameof(BeforeUpdateSequences))
.ConvertAll(n => new MiniYamlNodeBuilder(n));
.Select(n => new MiniYamlNodeBuilder(n))
.ToList();
var requiredMetadata = new HashSet<string>();
foreach (var imageNode in resolvedImagesNodes)
@@ -286,7 +287,7 @@ namespace OpenRA.Mods.Common.UpdateRules.Rules
imageNode.Value.Nodes.Insert(inheritsNodeIndex, defaultsNode);
}
var nodes = MiniYaml.FromString(duplicateTilesetCount.First(kv => kv.Value == maxDuplicateTilesetCount).Key, nameof(UpdateSequenceNode));
var nodes = MiniYaml.FromString(duplicateTilesetCount.First(kv => kv.Value == maxDuplicateTilesetCount).Key, nameof(UpdateSequenceNode)).ToList();
defaultTilesetFilenamesNode = new MiniYamlNodeBuilder("TilesetFilenames", "", nodes);
defaultsNode.Value.Nodes.Insert(0, defaultTilesetFilenamesNode);
}

View File

@@ -40,7 +40,8 @@ namespace OpenRA.Mods.Common.UpdateRules
name,
MiniYaml
.FromStream(package.GetStream(name), $"{package.Name}:{name}", false)
.ConvertAll(n => new MiniYamlNodeBuilder(n))));
.Select(n => new MiniYamlNodeBuilder(n))
.ToList()));
}
return yaml;
@@ -78,7 +79,8 @@ namespace OpenRA.Mods.Common.UpdateRules
filename,
MiniYaml
.FromStream(mapPackage.GetStream(filename), $"{mapPackage.Name}:{filename}", false)
.ConvertAll(n => new MiniYamlNodeBuilder(n))));
.Select(n => new MiniYamlNodeBuilder(n))
.ToList()));
else if (modData.ModFiles.Exists(filename))
externalFilenames.Add(filename);
}
@@ -104,7 +106,7 @@ namespace OpenRA.Mods.Common.UpdateRules
return manualSteps;
}
var yaml = new MiniYamlBuilder(null, MiniYaml.FromStream(mapStream, $"{mapPackage.Name}:map.yaml", false));
var yaml = new MiniYamlBuilder(null, MiniYaml.FromStream(mapStream, $"{mapPackage.Name}:map.yaml", false).ToList());
files = [(mapPackage, "map.yaml", yaml.Nodes)];
manualSteps.AddRange(rule.BeforeUpdate(modData));

View File

@@ -63,7 +63,7 @@ namespace OpenRA.Mods.Common.UtilityCommands
modData.ModFiles.TryGetPackageContaining(chrome, out var chromePackage, out var chromeName);
var chromePath = Path.Combine(chromePackage.Name, chromeName);
var yaml = MiniYaml.FromFile(chromePath, false).ConvertAll(n => new MiniYamlNodeBuilder(n));
var yaml = MiniYaml.FromFile(chromePath, false).Select(n => new MiniYamlNodeBuilder(n)).ToList();
yamlSet.Add(((IReadWritePackage)chromePackage, chromeName, yaml));
var extractionCandidates = new List<ExtractionCandidate>();

View File

@@ -57,7 +57,7 @@ namespace OpenRA.Mods.Common.UtilityCommands
if (mapStream == null)
continue;
var yaml = new MiniYamlBuilder(null, MiniYaml.FromStream(mapStream, $"{package.Name}:map.yaml", false));
var yaml = new MiniYamlBuilder(null, MiniYaml.FromStream(mapStream, $"{package.Name}:map.yaml", false).ToList());
var mapRulesNode = yaml.NodeWithKeyOrDefault("Rules");
if (mapRulesNode != null)
modRules.AddRange(UpdateUtils.LoadExternalMapYaml(modData, mapRulesNode.Value, []));
@@ -76,7 +76,7 @@ namespace OpenRA.Mods.Common.UtilityCommands
if (mapStream == null)
continue;
var yaml = new MiniYamlBuilder(null, MiniYaml.FromStream(mapStream, $"{package.Name}:map.yaml", false));
var yaml = new MiniYamlBuilder(null, MiniYaml.FromStream(mapStream, $"{package.Name}:map.yaml", false).ToList());
var mapRules = new YamlFileSet() { (package, "map.yaml", yaml.Nodes) };
var mapRulesNode = yaml.NodeWithKeyOrDefault("Rules");

View File

@@ -32,7 +32,7 @@ namespace OpenRA.Mods.Common.UtilityCommands
using (var pngStream = File.OpenRead(args[1]))
png = new Png(pngStream);
var yaml = MiniYaml.FromFile(Path.ChangeExtension(args[1], "yaml"));
var yaml = MiniYaml.FromFile(Path.ChangeExtension(args[1], "yaml")).ToList();
var frameSizeField = yaml.Where(y => y.Key == "FrameSize").Select(y => y.Value.Value).FirstOrDefault();
if (frameSizeField != null)

View File

@@ -492,7 +492,7 @@ namespace OpenRA.Mods.Common.Widgets.Logic
continue;
var game = new MiniYamlBuilder(MiniYaml.FromString(
bl.Data, $"BeaconLocation_{bl.Address}_{bl.LastAdvertised:s}", stringPool: stringPool)[0].Value);
bl.Data, $"BeaconLocation_{bl.Address}_{bl.LastAdvertised:s}", stringPool: stringPool).First().Value);
var idNode = game.NodeWithKeyOrDefault("Id");
// Skip beacons created by this instance and replace Id by expected int value

View File

@@ -10,7 +10,12 @@
#endregion
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
namespace OpenRA.Test
@@ -619,7 +624,7 @@ Test:
Assert.That(result.Count(n => n.Key == "Test"), Is.EqualTo(1), "Result should have exactly one Test node.");
var testNodes = result.First(n => n.Key == "Test").Value.Nodes;
Assert.That(testNodes.Select(n => n.Key), Is.EqualTo(new[] { "Merge", "Original", "Override" }), "Merged Test node has incorrect child nodes.");
Assert.That(testNodes.Select(n => n.Key), Is.EqualTo(["Merge", "Original", "Override"]), "Merged Test node has incorrect child nodes.");
var mergeNode = testNodes.First(n => n.Key == "Merge").Value;
Assert.That(mergeNode.Value, Is.EqualTo("override"), "Merge node has incorrect value.");
@@ -650,7 +655,7 @@ Test:
Assert.That(result.Count(n => n.Key == "Test"), Is.EqualTo(1), "Result should have exactly one Test node.");
var testNodes = result.First(n => n.Key == "Test").Value.Nodes;
Assert.That(testNodes.Select(n => n.Key), Is.EqualTo(new[] { "Merge", "Original", "Override" }), "Merged Test node has incorrect child nodes.");
Assert.That(testNodes.Select(n => n.Key), Is.EqualTo(["Merge", "Original", "Override"]), "Merged Test node has incorrect child nodes.");
var mergeNode = testNodes.First(n => n.Key == "Merge").Value;
Assert.That(mergeNode.Value, Is.EqualTo("override"), "Merge node has incorrect value.");
@@ -857,27 +862,27 @@ Test:
[TestCase(TestName = "Comments are correctly separated from values")]
public void TestEscapedHashInValues()
{
var trailingWhitespace = MiniYaml.FromString("key: value # comment", "", discardCommentsAndWhitespace: false)[0];
var trailingWhitespace = MiniYaml.FromString("key: value # comment", "", discardCommentsAndWhitespace: false).Single();
Assert.That("value", Is.EqualTo(trailingWhitespace.Value.Value));
Assert.That(" comment", Is.EqualTo(trailingWhitespace.Comment));
var noWhitespace = MiniYaml.FromString("key:value# comment", "", discardCommentsAndWhitespace: false)[0];
var noWhitespace = MiniYaml.FromString("key:value# comment", "", discardCommentsAndWhitespace: false).Single();
Assert.That("value", Is.EqualTo(noWhitespace.Value.Value));
Assert.That(" comment", Is.EqualTo(noWhitespace.Comment));
var escapedHashInValue = MiniYaml.FromString(@"key: before \# after # comment", "", discardCommentsAndWhitespace: false)[0];
var escapedHashInValue = MiniYaml.FromString(@"key: before \# after # comment", "", discardCommentsAndWhitespace: false).Single();
Assert.That("before # after", Is.EqualTo(escapedHashInValue.Value.Value));
Assert.That(" comment", Is.EqualTo(escapedHashInValue.Comment));
var emptyValueAndComment = MiniYaml.FromString("key:#", "", discardCommentsAndWhitespace: false)[0];
var emptyValueAndComment = MiniYaml.FromString("key:#", "", discardCommentsAndWhitespace: false).Single();
Assert.That(null, Is.EqualTo(emptyValueAndComment.Value.Value));
Assert.That("", Is.EqualTo(emptyValueAndComment.Comment));
var noValue = MiniYaml.FromString("key:", "", discardCommentsAndWhitespace: false)[0];
var noValue = MiniYaml.FromString("key:", "", discardCommentsAndWhitespace: false).Single();
Assert.That(null, Is.EqualTo(noValue.Value.Value));
Assert.That(null, Is.EqualTo(noValue.Comment));
var emptyKey = MiniYaml.FromString(" : value", "", discardCommentsAndWhitespace: false)[0];
var emptyKey = MiniYaml.FromString(" : value", "", discardCommentsAndWhitespace: false).Single();
Assert.That(null, Is.EqualTo(emptyKey.Key));
Assert.That("value", Is.EqualTo(emptyKey.Value.Value));
Assert.That(null, Is.EqualTo(emptyKey.Comment));
@@ -888,7 +893,7 @@ Test:
{
const string TestYaml = @"key: \ test value \ ";
var nodes = MiniYaml.FromString(TestYaml, "");
Assert.That(" test value ", Is.EqualTo(nodes[0].Value.Value));
Assert.That(" test value ", Is.EqualTo(nodes.Single().Value.Value));
}
[TestCase(TestName = "Comments should count toward line numbers")]
@@ -902,12 +907,12 @@ TestA:
TestB:
Nothing:
";
var resultDiscard = MiniYaml.FromString(Yaml, "");
var resultDiscard = MiniYaml.FromString(Yaml, "").ToList();
var resultDiscardLine = resultDiscard.First(n => n.Key == "TestB").Location.Line;
Assert.That(resultDiscardLine, Is.EqualTo(6), "Node TestB should report its location as line 6, but is not (discarding comments)");
Assert.That(resultDiscard[1].Key, Is.EqualTo("TestB"), "Node TestB should be the second child of the root node, but is not (discarding comments)");
var resultKeep = MiniYaml.FromString(Yaml, "", discardCommentsAndWhitespace: false);
var resultKeep = MiniYaml.FromString(Yaml, "", discardCommentsAndWhitespace: false).ToList();
var resultKeepLine = resultKeep.First(n => n.Key == "TestB").Location.Line;
Assert.That(resultKeepLine, Is.EqualTo(6), "Node TestB should report its location as line 6, but is not (parsing comments)");
Assert.That(resultKeep[4].Key, Is.EqualTo("TestB"), "Node TestB should be the fifth child of the root node, but is not (parsing comments)");
@@ -993,5 +998,112 @@ Parent: # comment without value
var result = MiniYaml.FromString(Yaml, "").WriteToString();
Assert.That(strippedYaml, Is.EqualTo(result));
}
[TestCase(TestName = "Can enumerate top-level nodes from a stream")]
public void FromStreamAsEnumerable()
{
const string FirstYaml =
@"Parent: First
Child: First
Parent: Second
";
const string SecondYaml =
@" Child: Second
";
var events = new List<(string Event, string Payload)>();
var stream = new TestStream();
var ars = new AutoResetEvent(false);
var readTask = Task.Run(() =>
{
foreach (var node in MiniYaml.FromStream(stream, ""))
{
events.Add(("Saw Node", new[] { node }.WriteToString()));
ars.Set();
}
});
events.Add(("Stream Write", FirstYaml));
stream.WriteBytes(Encoding.UTF8.GetBytes(FirstYaml));
if (!ars.WaitOne(TimeSpan.FromSeconds(1)))
Assert.Fail("Timeout waiting for first node");
events.Add(("Stream Write", SecondYaml));
stream.WriteBytes(Encoding.UTF8.GetBytes(SecondYaml));
events.Add(("Stream End", ""));
stream.WriteEnd();
if (!ars.WaitOne(TimeSpan.FromSeconds(1)))
Assert.Fail("Timeout waiting for second node");
if (!readTask.Wait(TimeSpan.FromSeconds(1)))
Assert.Fail("Timeout waiting for task completion");
Assert.That(events, Is.EquivalentTo([
("Stream Write", FirstYaml),
("Saw Node", "Parent: First\n\tChild: First\n"),
("Stream Write", SecondYaml),
("Stream End", ""),
("Saw Node", "Parent: Second\n\tChild: Second\n"),
]));
}
sealed class TestStream : Stream
{
readonly ManualResetEventSlim mres = new();
readonly List<byte> bytes = [];
bool ended;
public void WriteEnd()
{
ended = true;
mres.Set();
}
public void WriteBytes(ReadOnlySpan<byte> bytes)
{
if (ended) throw new InvalidOperationException();
lock (this.bytes)
{
this.bytes.AddRange(bytes);
mres.Set();
}
}
public override int Read(byte[] buffer, int offset, int count)
{
if (bytes.Count == 0 && ended)
return 0;
if (bytes.Count == 0)
mres.Wait();
lock (bytes)
{
var read = Math.Min(bytes.Count, count);
for (var i = 0; i < read; i++)
buffer[offset + i] = bytes[i];
bytes.RemoveRange(0, read);
if (bytes.Count == 0)
mres.Reset();
return read;
}
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public override void Flush() { }
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
}
}