using System.IO.Pipes;
using System.Security.Cryptography;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol.V1;
namespace ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests;
///
/// lmxopcua#789: — on Windows the
/// that keeps worker pipe I/O off the completion port. Exercised on a real
/// pipe pair; on Unix the same tests cover the plain pipe it returns there.
///
[Trait("Category", "ScriptWorker")]
public sealed class BlockingPipeStreamTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static async Task<(Stream Server, Stream Client)> PairAsync()
{
var name = "otopcua-sw-test-" + Convert.ToHexString(RandomNumberGenerator.GetBytes(6));
var server = new NamedPipeServerStream(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte,
PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
var client = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
var accept = server.WaitForConnectionAsync(Ct);
client.Connect(TimeSpan.FromSeconds(5));
await accept;
return (ScriptWorkerFrameCodec.BlockingStream(server), ScriptWorkerFrameCodec.BlockingStream(client));
}
private static Envelope Frame(ulong id) => new()
{
ProtocolVersion = ScriptWorkerFrameCodec.ProtocolVersion,
Seq = id,
Heartbeat = new Heartbeat { Inflight = (uint)id },
};
[Fact]
public async Task On_windows_the_pipe_is_wrapped_and_elsewhere_it_is_the_pipe_itself()
{
var (server, client) = await PairAsync();
using (server)
using (client)
{
if (OperatingSystem.IsWindows()) server.ShouldBeOfType();
else server.ShouldBeAssignableTo();
}
}
///
/// Full duplex: each end has a read blocked on a dedicated thread while the other direction keeps writing — a
/// handle opened for synchronous I/O would serialise the write behind the blocked read and deadlock here.
///
[Fact]
public async Task Reads_blocked_on_both_ends_do_not_hold_up_writes_in_either_direction()
{
var (server, client) = await PairAsync();
using (server)
using (client)
{
const int frames = 2_000;
Task Pump(Stream from, Stream to)
{
var reader = Task.Factory.StartNew(() =>
{
var r = new ScriptWorkerFrameReader(from);
var seen = 0;
while (seen < frames && r.Read() is { } f)
{
f.Heartbeat.Inflight.ShouldBe((uint)seen);
seen++;
}
return seen;
}, Ct, TaskCreationOptions.LongRunning, TaskScheduler.Default);
_ = Task.Factory.StartNew(() =>
{
for (var i = 0; i < frames; i++) ScriptWorkerFrameCodec.Write(to, Frame((ulong)i), ScriptWorkerFrameCodec.DefaultMaxFrameBytes);
}, Ct, TaskCreationOptions.LongRunning, TaskScheduler.Default);
return reader;
}
var toClient = Pump(client, server);
var toServer = Pump(server, client);
(await toClient.WaitAsync(TimeSpan.FromSeconds(30), Ct)).ShouldBe(frames);
(await toServer.WaitAsync(TimeSpan.FromSeconds(30), Ct)).ShouldBe(frames);
}
}
[Fact]
public async Task A_large_frame_crosses_whole()
{
var (server, client) = await PairAsync();
using (server)
using (client)
{
var big = new Envelope
{
ProtocolVersion = ScriptWorkerFrameCodec.ProtocolVersion,
Load = new LoadScripts { Scripts = { new ScriptSource { Key = "k", Source = new string('x', 1_000_000) } } },
};
var read = Task.Factory.StartNew(() => new ScriptWorkerFrameReader(server).Read(), Ct, TaskCreationOptions.LongRunning,
TaskScheduler.Default);
ScriptWorkerFrameCodec.Write(client, big, ScriptWorkerFrameCodec.DefaultMaxFrameBytes);
var got = await read.WaitAsync(TimeSpan.FromSeconds(30), Ct);
got!.Load.Scripts[0].Source.Length.ShouldBe(1_000_000);
}
}
[Fact]
public async Task A_peer_that_goes_away_ends_a_blocked_read_and_fails_a_write()
{
var (server, client) = await PairAsync();
using (server)
{
var read = Task.Factory.StartNew(() => server.Read(new byte[16], 0, 16), Ct, TaskCreationOptions.LongRunning,
TaskScheduler.Default);
await Task.Delay(100, Ct);
read.IsCompleted.ShouldBeFalse("positive control: the read is blocked on the kernel");
client.Dispose();
(await read.WaitAsync(TimeSpan.FromSeconds(10), Ct)).ShouldBe(0, "end of stream");
Should.Throw(() =>
{
// A pipe can accept a write into its buffer before it notices; the second one must fail.
for (var i = 0; i < 4; i++) server.Write(new byte[64], 0, 64);
});
}
}
[Fact]
public async Task Disposing_an_end_wakes_its_own_blocked_reader()
{
var (server, client) = await PairAsync();
using (client)
{
var read = Task.Factory.StartNew(() => server.Read(new byte[16], 0, 16), Ct, TaskCreationOptions.LongRunning,
TaskScheduler.Default);
await Task.Delay(100, Ct);
read.IsCompleted.ShouldBeFalse("positive control: the read is blocked on the kernel");
server.Dispose();
// End of stream, or the disposed / I/O error the readers treat as the pipe ending — never a hang.
int ended;
try { ended = await read.WaitAsync(TimeSpan.FromSeconds(10), Ct); }
catch (Exception ex) when (ex is ObjectDisposedException or IOException) { ended = 0; }
ended.ShouldBe(0);
}
}
///
/// Writes until the pipe is full (the peer never reads), then returns the writing task, which is blocked in the
/// kernel at that point; it ends with the exception the write saw.
///
private static async Task BlockedWriterAsync(Stream end)
{
long written = 0;
var writer = Task.Factory.StartNew(() =>
{
var chunk = new byte[64 * 1024];
while (true)
{
end.Write(chunk, 0, chunk.Length);
Interlocked.Add(ref written, chunk.Length);
}
}, Ct, TaskCreationOptions.LongRunning, TaskScheduler.Default);
// Full once no progress is made for a while (a zero-quota Windows pipe blocks at once; a socket after its buffer).
long last;
do
{
last = Interlocked.Read(ref written);
await Task.Delay(300, Ct);
} while (Interlocked.Read(ref written) != last && !writer.IsCompleted);
writer.IsCompleted.ShouldBeFalse("positive control: the write is blocked on a full pipe");
return writer;
}
private static async Task ShouldEndAsync(Task blocked, string what)
{
var ended = await Task.WhenAny(blocked, Task.Delay(TimeSpan.FromSeconds(10), Ct));
ended.ShouldBeSameAs(blocked, $"{what} ended, not hung");
var ex = blocked.Exception?.GetBaseException();
ex.ShouldNotBeNull($"{what} ended with an error");
(ex is ObjectDisposedException or IOException).ShouldBeTrue(ex.ToString());
}
[Fact]
public async Task Disposing_an_end_wakes_its_own_write_blocked_on_a_full_pipe()
{
var (server, client) = await PairAsync();
using (client)
{
var writer = await BlockedWriterAsync(server);
server.Dispose();
await ShouldEndAsync(writer, "the blocked write");
}
}
[Fact]
public async Task Disposing_an_end_with_a_read_and_a_write_both_pending_wakes_both()
{
var (server, client) = await PairAsync();
using (client)
{
var read = Task.Factory.StartNew(() =>
{
var n = server.Read(new byte[16], 0, 16);
if (n == 0) throw new IOException("end of stream");
return n;
}, Ct, TaskCreationOptions.LongRunning, TaskScheduler.Default);
var writer = await BlockedWriterAsync(server);
read.IsCompleted.ShouldBeFalse("positive control: the read is pending beside the blocked write");
server.Dispose();
await ShouldEndAsync(writer, "the blocked write");
await ShouldEndAsync(read, "the pending read");
}
}
[Fact]
public async Task The_asynchronous_surface_is_refused_on_windows()
{
var (server, client) = await PairAsync();
using (server)
using (client)
{
if (!OperatingSystem.IsWindows()) return; // the Unix pipe itself keeps PipeStream's own async surface
await Should.ThrowAsync(() => server.ReadAsync(new byte[4], 0, 4, Ct));
await Should.ThrowAsync(async () => await server.WriteAsync(new byte[4].AsMemory(), Ct));
}
}
}