Files
romanett 2c431ad728 Client/transport: one recv per small chunk, ManagedSession lock fast path, write coalescing (#4737)
Follow-ups from the "Not changed" list of #4720 (client per-request
allocation and CPU): three changes that the paired A/B shows a gain for,
plus a write-up of the fourth (discarded `ResponseHeader`), which is not
changed.

## Results

Paired A/B, each change against the commit before it: 4 alternating
rounds, medians, 20 sessions, 24-core machine shared with other
sessions. Client = the harness process, server = the reference server
child. Allocated bytes per operation are deterministic; throughput and
latency carry the run-to-run noise noted below.

| Change | Scenario | Throughput | p50 | p90 | Client CPU / op | Client
alloc / op | Server CPU / op |
|---|---|---|---|---|---|---|---|
| Receive staging | Read, 1 value | +4% | **−9%** | **−13%** | ≈ | ±0 |
≈ |
| | Read, 1 value, SignAndEncrypt | +6% | −6% | −11% | ≈ | ±0 | ≈ |
| | Read, 100 values | +2% | −4% | ≈ | ≈ | ±0 | ≈ |
| | Data changes | ≈ | ≈ | ≈ | ≈ | ±0 | ≈ |
| ManagedSession lock | Read, 1 value, ManagedSession | ≈ | ≈ | ≈ | ≈ |
**−125 B** (2632 → 2507) | ≈ |
| | Same, inflight 8 | ≈ | ≈ | ≈ | ≈ | −133 B | ≈ |
| Write coalescing | Read, 1 value, 20 sessions on one shared channel,
inflight 4 | **+13%** | **−13%** | −3% | −6% | ±0 | −4% |
| | Same, inflight 1 | +20% ¹ | −8% | ¹ | −9% | ±0 | −13% |
| | Read, 1 value, inflight 8 (one channel per session) | +2% | −4% |
−6% | ≈ | ±0 | ≈ |
| | Read, 100 values, inflight 8 | +3% | −4% | −8% | ≈ | ±0 | ≈ |
| | Data changes | ≈ | ≈ | ≈ | ≈ | ±0 | ≈ |

≈: within run-to-run noise (about ±5% for CPU per op here).
¹ Rounds ranged from −42% to +32%; the inflight-4 runs were better in
all four rounds.

## Changes

- **`TcpByteTransport` receive**: `ReceiveChunkAsync` read the 8-byte
chunk header and then the body, two `recv` calls per chunk. It now waits
for data with a small staging buffer (8 KB, allocated on the first
receive, not from the `BufferManager` budget). One receive returns the
header and, when the chunk is small and already there, the whole chunk;
bytes of following chunks are kept for the next call. A larger chunk is
completed with a receive into the rented buffer, as before.
- The receive buffer is still rented only after the header has been
validated (message type, size against the negotiated limit), so an idle
peer holds none of the budget.
- A receive cancelled while its bytes are in the staging buffer keeps
them for the next call; on .NET Framework the staging receive takes no
token, so cancellation is observed once data arrives.
- A receive that fails while reading the body of a chunk larger than the
staging buffer has consumed part of the chunk; later receives then fail
with `BadTcpInternalError` rather than read body bytes as a header.
Before this PR such a failure lost the header and body bytes silently.
The channel cancels receives only when it closes.
  - Server listener channels use the same transport.
- **`ManagedSession` service calls**: each call was an async wrapper
(await the reader lock, await the inner call), one more state machine
box per request.
- When services are available and no writer holds or waits for the lock,
the reader lock is taken synchronously
(`AsyncReaderWriterLock.TryEnterReader`). It is released by a cached
continuation on the inner call's task, registered before the caller's
await, so the lock is free again when the caller resumes.
- Otherwise the call waits for the lock as before, so reconnect and
failover still block new calls and drain the ones in flight.
- The remaining 88 B per call is the task's continuation list (two
continuations).
- **Write coalescing** (`UaSCUaBinaryChannel`): when more writes for the
same transport are queued behind the one being sent, the writer takes up
to 16 of them and sends their segments in one vectored send.
- Each write is then reported on its own, in order, with its size. A
failed send is reported for every write in it, and their buffers are
returned.
- Writes still never start on the caller's stack, and one writer sends
one batch at a time, so chunk order is unchanged.
- Only byte-stream transports opt in (`TcpByteTransport`, also through
the server's admission lease, via the internal `IUaSCChunkCoalescing`).
WebSocket and capture decorators keep one send per chunk.

### Behaviour notes
- Each TCP connection that has received holds an 8 KB staging array
(pinned on .NET 5+), outside the `BufferManager` budget. The previous
transport held an 8-byte header array.
- `ManagedSession` service calls: if the inner call completes while the
release continuation is being registered, the reader lock is released on
the thread pool shortly after, instead of before the caller resumes.
- Concurrent `ReceiveChunkAsync` calls on one `TcpByteTransport` now
throw `InvalidOperationException`. The channel has a single receive
loop, and interleaved receives on a stream were never valid.

## Not changed: discarded `ResponseHeader`
Every generated structure's `Initialize()` constructs its
structure-typed fields: `new ResponseHeader()` (112 B), `new
RequestHeader()` (104 B). The decoders always create the instance (`new
T()` or the activator) and then assign every field in `Decode`, so the
default is thrown away. That is 4.6% of the client's 2.4 KB per Read and
about 3% on the server, which discards a `RequestHeader` per decoded
request and a `ResponseHeader` per response it constructs.
`PublishResponse` also discards a default `NotificationMessage`. Options
considered:
- **Lazy getter** (`get => m_x ??= new X()`): `IsEqual`/`GetHashCode`
compare fields, so a never-read default (null) would no longer equal an
explicit default. Two threads reading an unset header could get
different instances. Pooled instances reset through `Initialize()`
anyway.
- **Decode-only constructor** called by the activator: only the plain
`Class`/`DerivedClass` templates assign every field in `Decode`, not
optional-field or union types. A derived type's constructor must chain
to a base built by an older generator in another assembly, which lacks
it (the same problem `ForeignDerivedPooledExtensionClass` works around).
It also needs a new public marker type in `Opc.Ua.Types`.
- **Decode into the existing instance**: needs a new `IDecoder` member
in all decoders. It also changes behaviour when a caller decodes into an
object whose header it still references.

Each touches every generated type for about 100 B per message, so this
is left as is.

## Harness
- `--client shared`: classic sessions whose secure channels come from
one `ClientChannelManager`.
- Created sequentially (`--connect-parallel`, default 1). Concurrent
`Session.CreateAsync` calls on one manager fail with
`BadSecureChannelClosed`, a separate bug.
- 20 sessions on one channel reach about 45–70k req/s, against about
140k on 20 channels.
- `ab.sh`: `CANDIDATE=` selects the candidate build.

## Testing
- New: `TcpByteTransportTests`: chunks sent together, a chunk split
across sends (inside the next header), a chunk larger than the staging
buffer, cancellation keeping staged bytes.
`UaSCBinaryChannelWriteQueueTests`: coalesced in order (1 + 16 + 16 + 7
sends), framing transport not coalesced, a failed batch reported for
every write and later writes still sent. `AsyncReaderWriterLockTests`:
`TryEnterReader` against a writer, the reader held until the operation
completes and released on failure and on a completed operation, the
awaiter resuming after the release.
- Before the rebase onto master, net10.0, run sequentially: Core
Stack+Security 4689 passed, 1 failed
(`ClientBaseTests.ActivityTraceFlags_Metrics_ShouldRecordMetricsOnly`, a
meter-listener flake fixed on master by #4708; passes 3/3 in isolation);
Client 2768; Sessions 851 (379 skipped); Server 8398; Subscriptions 643.
- After the rebase: Core Stack+Security 4811 passed; Client 2781; Stress
Contract+Integration 45 passed; all 0 failed. Sessions, Server and
Subscriptions were not rerun on the new base.
- net48 build of `UA.slnx`: clean (0 warnings, 0 errors); `dotnet format
whitespace` clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:30:29 +02:00

518 lines
22 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
namespace Opc.Ua.Client.Tests.AsyncPrimitives
{
/// <summary>
/// Unit tests for the local hand-rolled
/// <see cref="AsyncReaderWriterLock"/>.
/// </summary>
[TestFixture]
[Category("Unit")]
public sealed class AsyncReaderWriterLockTests
{
[TestCase(false)]
[TestCase(true)]
public async Task ReaderAvailabilityHookIsCapturedOnceAndNullIsAllowedAsync(bool hasHook)
{
using var rwLock = new AsyncReaderWriterLock();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
int calls = 0;
if (hasHook)
{
rwLock.BeforeReaderLockAsync = ct =>
{
Interlocked.Increment(ref calls);
return new ValueTask(gate.Task.WaitAsync(ct));
};
}
Task<AsyncReaderWriterLock.Releaser> pending = rwLock.ReaderLockAsync(timeout.Token).AsTask();
try
{
Assert.That(pending.IsCompleted, Is.EqualTo(!hasHook));
rwLock.BeforeReaderLockAsync = _ => throw new InvalidOperationException("Replacement was invoked.");
}
finally
{
gate.TrySetResult(true);
}
using AsyncReaderWriterLock.Releaser reader = await pending.ConfigureAwait(false);
Assert.That(calls, Is.EqualTo(hasHook ? 1 : 0));
}
/// <summary>
/// L2-3: recovery must not wait for readers stuck on a dead channel
/// until their request timeout. With a drain timeout the writer
/// proceeds while stale readers are still active, and new readers
/// stay excluded until the writer releases.
/// </summary>
[Test]
public async Task WriterWithDrainTimeoutProceedsPastStuckReaderAsync()
{
using var rwLock = new AsyncReaderWriterLock();
AsyncReaderWriterLock.Releaser stuckReader =
await rwLock.ReaderLockAsync().ConfigureAwait(false);
Task<AsyncReaderWriterLock.Releaser> writerTask = rwLock
.WriterLockAsync(TimeSpan.FromMilliseconds(100), TimeProvider.System, () => true)
.AsTask();
AsyncReaderWriterLock.Releaser writer =
await writerTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
Task<AsyncReaderWriterLock.Releaser> newReader = rwLock.ReaderLockAsync().AsTask();
await Task.Delay(50).ConfigureAwait(false);
Assert.That(newReader.IsCompleted, Is.False,
"New readers must wait while the writer holds the lock.");
stuckReader.Dispose();
writer.Dispose();
using AsyncReaderWriterLock.Releaser reader =
await newReader.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
// The lock is consistent afterwards: a plain writer drains normally.
reader.Dispose();
using AsyncReaderWriterLock.Releaser next = await rwLock.WriterLockAsync()
.AsTask().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
}
/// <summary>
/// Review G8a: while the readers can still complete normally (healthy
/// channel), the recovery writer stays exclusive past the drain
/// timeout and proceeds only once the readers leave.
/// </summary>
[Test]
public async Task WriterWithDrainTimeoutStaysExclusiveWhileReadersMayCompleteAsync()
{
using var rwLock = new AsyncReaderWriterLock();
AsyncReaderWriterLock.Releaser reader =
await rwLock.ReaderLockAsync().ConfigureAwait(false);
int checks = 0;
Task<AsyncReaderWriterLock.Releaser> writerTask = rwLock
.WriterLockAsync(TimeSpan.FromMilliseconds(50), TimeProvider.System, () =>
{
Interlocked.Increment(ref checks);
return false;
})
.AsTask();
await Task.Delay(400).ConfigureAwait(false);
Assert.That(writerTask.IsCompleted, Is.False,
"The writer must keep waiting for readers on a healthy channel.");
Assert.That(Volatile.Read(ref checks), Is.GreaterThan(1),
"The channel health must be re-checked at every drain timeout.");
reader.Dispose();
using AsyncReaderWriterLock.Releaser writer =
await writerTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
}
/// <summary>
/// Review G8a: the channel may die while the writer waits for the
/// readers; the next check then lets the writer proceed.
/// </summary>
[Test]
public async Task WriterWithDrainTimeoutProceedsOnceChannelBecomesUnhealthyAsync()
{
using var rwLock = new AsyncReaderWriterLock();
AsyncReaderWriterLock.Releaser stuckReader =
await rwLock.ReaderLockAsync().ConfigureAwait(false);
bool unhealthy = false;
Task<AsyncReaderWriterLock.Releaser> writerTask = rwLock
.WriterLockAsync(TimeSpan.FromMilliseconds(50), TimeProvider.System, () => Volatile.Read(ref unhealthy))
.AsTask();
await Task.Delay(200).ConfigureAwait(false);
Assert.That(writerTask.IsCompleted, Is.False);
Volatile.Write(ref unhealthy, true);
using (await writerTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false))
{
stuckReader.Dispose();
}
using AsyncReaderWriterLock.Releaser next = await rwLock.WriterLockAsync()
.AsTask().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
}
[Test]
public async Task ReadersDoNotMutuallyExcludeAsync()
{
using var rwLock = new AsyncReaderWriterLock();
// Acquire two readers concurrently — neither should
// block the other.
using AsyncReaderWriterLock.Releaser r1 =
await rwLock.ReaderLockAsync().ConfigureAwait(false);
using AsyncReaderWriterLock.Releaser r2 =
await rwLock.ReaderLockAsync().ConfigureAwait(false);
// No assertion needed — passing means the second
// ReaderLockAsync did not block on the first.
Assert.Pass();
}
[Test]
public async Task WriterExcludesReadersAsync()
{
using var rwLock = new AsyncReaderWriterLock();
AsyncReaderWriterLock.Releaser writer =
await rwLock.WriterLockAsync().ConfigureAwait(false);
Task<AsyncReaderWriterLock.Releaser> readerTask =
rwLock.ReaderLockAsync().AsTask();
// Reader must not complete while writer is held.
await Task.Delay(50).ConfigureAwait(false);
Assert.That(readerTask.IsCompleted, Is.False,
"Reader must wait while writer holds the lock.");
writer.Dispose();
using AsyncReaderWriterLock.Releaser reader =
await readerTask.ConfigureAwait(false);
// Reader proceeded after writer released.
}
[Test]
public async Task WriterWaitsForReadersToDrainAsync()
{
using var rwLock = new AsyncReaderWriterLock();
AsyncReaderWriterLock.Releaser r1 =
await rwLock.ReaderLockAsync().ConfigureAwait(false);
AsyncReaderWriterLock.Releaser r2 =
await rwLock.ReaderLockAsync().ConfigureAwait(false);
Task<AsyncReaderWriterLock.Releaser> writerTask =
rwLock.WriterLockAsync().AsTask();
await Task.Delay(50).ConfigureAwait(false);
Assert.That(writerTask.IsCompleted, Is.False,
"Writer must not acquire while readers are active.");
r1.Dispose();
await Task.Delay(50).ConfigureAwait(false);
Assert.That(writerTask.IsCompleted, Is.False,
"Writer must wait for ALL readers to drain, not just " +
"the first.");
r2.Dispose();
using AsyncReaderWriterLock.Releaser writer =
await writerTask.ConfigureAwait(false);
// Writer acquired after last reader drained.
}
[Test]
[Repeat(50)]
[CancelAfter(30_000)]
public async Task LastReaderNextReaderRaceDoesNotAdmitWriterEarlyAsync(
CancellationToken ct)
{
// Stress test the race the rubber-duck review identified:
// a naive `Interlocked` reader counter without serialized
// pairing of counter + drain signal could let a writer
// observe drain==signaled while a fresh reader has just
// entered. This test runs many tight reader cycles
// alongside one writer that asserts m_activeReaders == 0
// (via a probe) when it acquires.
using var rwLock = new AsyncReaderWriterLock();
int activeReaders = 0;
int maxObservedReadersInsideWriter = 0;
// Start a churn of readers in tight loops.
using var churnCts = CancellationTokenSource
.CreateLinkedTokenSource(ct);
var churn = new Task[8];
for (int i = 0; i < churn.Length; i++)
{
churn[i] = Task.Run(async () =>
{
while (!churnCts.IsCancellationRequested)
{
using AsyncReaderWriterLock.Releaser r =
await rwLock.ReaderLockAsync(churnCts.Token).ConfigureAwait(false);
Interlocked.Increment(ref activeReaders);
await Task.Yield();
Interlocked.Decrement(ref activeReaders);
}
}, churnCts.Token);
}
// Run several writer acquisitions interleaved with the churn.
for (int round = 0; round < 20; round++)
{
using AsyncReaderWriterLock.Releaser w = await rwLock
.WriterLockAsync(ct).ConfigureAwait(false);
int observed = Volatile.Read(ref activeReaders);
if (observed > maxObservedReadersInsideWriter)
{
maxObservedReadersInsideWriter = observed;
}
Assert.That(observed, Is.Zero,
$"Writer #{round} observed {observed} active " +
"readers while holding the writer lock — the " +
"reader-counter / drain-signal pairing is racy.");
await Task.Yield();
}
await churnCts.CancelAsync().ConfigureAwait(false);
try
{
await Task.WhenAll(churn).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// expected on shutdown
}
Assert.That(maxObservedReadersInsideWriter, Is.Zero);
}
[Test]
[CancelAfter(10_000)]
public async Task WriterCancellationWhileDrainingReleasesSemaphoreAsync(
CancellationToken testCt)
{
using var rwLock = new AsyncReaderWriterLock();
// Hold a reader so the writer must wait for drain.
AsyncReaderWriterLock.Releaser reader =
await rwLock.ReaderLockAsync(testCt).ConfigureAwait(false);
using var writerCts = new CancellationTokenSource();
Task<AsyncReaderWriterLock.Releaser> writerTask =
rwLock.WriterLockAsync(writerCts.Token).AsTask();
// Ensure the writer has reached the drain wait.
await Task.Delay(50, testCt).ConfigureAwait(false);
Assert.That(writerTask.IsCompleted, Is.False);
// Cancel the writer while draining.
await writerCts.CancelAsync().ConfigureAwait(false);
Assert.That(
() => writerTask,
Throws.InstanceOf<OperationCanceledException>());
// The cancelled writer must have released the writer-entry
// semaphore — the next writer should be able to acquire
// immediately once the reader leaves.
reader.Dispose();
using AsyncReaderWriterLock.Releaser nextWriter =
await rwLock.WriterLockAsync(testCt).ConfigureAwait(false);
// If the cancelled writer leaked the semaphore, the test
// would hang here (caught by [CancelAfter]).
}
[Test]
[CancelAfter(10_000)]
public async Task WriterCanReacquireAfterReleaseAsync(
CancellationToken ct)
{
using var rwLock = new AsyncReaderWriterLock();
using (AsyncReaderWriterLock.Releaser w1 =
await rwLock.WriterLockAsync(ct).ConfigureAwait(false))
{
// hold and release
}
using AsyncReaderWriterLock.Releaser w2 =
await rwLock.WriterLockAsync(ct).ConfigureAwait(false);
// would deadlock if the previous writer leaked.
}
[Test]
[CancelAfter(10_000)]
public async Task WriterIsNotReentrantAndDeadlocksWithSelfAsync(CancellationToken ct)
{
// Sanity: reentrancy is intentionally NOT supported. A
// writer that asks for the writer lock again on the same
// logical flow must NOT silently succeed (which would
// indicate accidental reentrancy). It deadlocks; we
// detect by short timeout.
using var rwLock = new AsyncReaderWriterLock();
using AsyncReaderWriterLock.Releaser outer =
await rwLock.WriterLockAsync(ct).ConfigureAwait(false);
using var innerCts = CancellationTokenSource
.CreateLinkedTokenSource(ct);
innerCts.CancelAfter(TimeSpan.FromMilliseconds(300));
Assert.That(
async () => await rwLock
.WriterLockAsync(innerCts.Token)
.ConfigureAwait(false),
Throws.InstanceOf<OperationCanceledException>());
}
[Test]
[CancelAfter(10_000)]
public async Task ManyParallelReadersAdmittedAsync(CancellationToken ct)
{
// Spin up 32 readers in parallel; all should hold the lock
// simultaneously without blocking each other.
using var rwLock = new AsyncReaderWriterLock();
var startGate = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
int holdingCount = 0;
int peakHolding = 0;
const int kReaders = 32;
var releaseGate = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
var readers = new Task[kReaders];
for (int i = 0; i < kReaders; i++)
{
readers[i] = Task.Run(async () =>
{
using AsyncReaderWriterLock.Releaser r =
await rwLock.ReaderLockAsync(ct).ConfigureAwait(false);
int now = Interlocked.Increment(ref holdingCount);
int peak;
do
{
peak = Volatile.Read(ref peakHolding);
if (now <= peak)
{
break;
}
} while (Interlocked.CompareExchange(ref peakHolding, now, peak) != peak);
await releaseGate.Task.ConfigureAwait(false);
Interlocked.Decrement(ref holdingCount);
}, ct);
}
// Give all readers time to acquire.
await Task.Delay(200, ct).ConfigureAwait(false);
releaseGate.SetResult(true);
await Task.WhenAll(readers).ConfigureAwait(false);
Assert.That(peakHolding, Is.EqualTo(kReaders),
"All 32 readers should have held the lock simultaneously.");
}
[Test]
public async Task TryEnterReaderFailsWhileAWriterHoldsTheLockAsync()
{
using var rwLock = new AsyncReaderWriterLock();
using (await rwLock.WriterLockAsync().ConfigureAwait(false))
{
Assert.That(rwLock.TryEnterReader(), Is.False);
}
Assert.That(rwLock.TryEnterReader(), Is.True);
rwLock.ExitReader();
}
[Test]
public async Task ReaderFromTryEnterIsHeldUntilTheOperationCompletesAsync()
{
using var rwLock = new AsyncReaderWriterLock();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var operation = new TaskCompletionSource<int>();
Assert.That(rwLock.TryEnterReader(), Is.True);
ValueTask<int> call = rwLock.ExitReaderOnCompletion(new ValueTask<int>(operation.Task));
Task<AsyncReaderWriterLock.Releaser> writer = rwLock.WriterLockAsync(timeout.Token).AsTask();
await Task.Delay(50, timeout.Token).ConfigureAwait(false);
Assert.That(writer.IsCompleted, Is.False, "the writer waits for the reader to drain");
operation.SetResult(42);
using (await writer.WaitAsync(timeout.Token).ConfigureAwait(false))
{
Assert.That(await call.ConfigureAwait(false), Is.EqualTo(42));
}
}
[Test]
public async Task AwaiterResumesAfterTheReaderFromTryEnterIsReleasedAsync()
{
using var rwLock = new AsyncReaderWriterLock();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var operation = new TaskCompletionSource<int>();
Assert.That(rwLock.TryEnterReader(), Is.True);
ValueTask<int> call = rwLock.ExitReaderOnCompletion(new ValueTask<int>(operation.Task));
// A writer requested in the awaiter's continuation finds no reader left,
// as after a using block around the awaited operation.
bool writerAcquiredAtOnce = false;
Task resumed = Task.Run(async () =>
{
await call.ConfigureAwait(false);
ValueTask<AsyncReaderWriterLock.Releaser> writer = rwLock.WriterLockAsync(timeout.Token);
writerAcquiredAtOnce = writer.IsCompleted;
(await writer.ConfigureAwait(false)).Dispose();
});
await Task.Delay(50, timeout.Token).ConfigureAwait(false);
operation.SetResult(42);
await resumed.WaitAsync(timeout.Token).ConfigureAwait(false);
Assert.That(writerAcquiredAtOnce, Is.True);
}
[Test]
public async Task ReaderFromTryEnterIsReleasedWhenTheOperationFailsAsync()
{
using var rwLock = new AsyncReaderWriterLock();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var operation = new TaskCompletionSource<int>();
Assert.That(rwLock.TryEnterReader(), Is.True);
ValueTask<int> call = rwLock.ExitReaderOnCompletion(new ValueTask<int>(operation.Task));
operation.SetException(new InvalidOperationException("failed"));
Assert.That(async () => await call.ConfigureAwait(false), Throws.InvalidOperationException);
using AsyncReaderWriterLock.Releaser writer =
await rwLock.WriterLockAsync(timeout.Token).ConfigureAwait(false);
}
[Test]
public async Task ReaderFromTryEnterIsReleasedAtOnceForACompletedOperationAsync()
{
using var rwLock = new AsyncReaderWriterLock();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
Assert.That(rwLock.TryEnterReader(), Is.True);
ValueTask<int> call = rwLock.ExitReaderOnCompletion(new ValueTask<int>(7));
Assert.That(call.IsCompleted, Is.True);
Assert.That(await call.ConfigureAwait(false), Is.EqualTo(7));
using AsyncReaderWriterLock.Releaser writer =
await rwLock.WriterLockAsync(timeout.Token).ConfigureAwait(false);
}
}
}