Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions src/Stunts.UnitTests/Scenarios/Synchronized.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
#pragma warning disable IDE0079
#pragma warning disable CS0436
using System;
using System.Threading;
using System.Threading.Tasks;
using Stunts;
using Xunit;

namespace Stunts.Scenarios.Synchronized
{
public interface IWorker
{
int Work(int id);
Task<int> WorkAsync(int id);
ValueTask<int> WorkValueAsync(int id);
int Reentrant();
}

/// <summary>
/// A synchronized behavior serializes overlapping calls using a standard lock.
/// </summary>
public class Test : IRunnable
{
public void Run()
{
OverlappingSyncCalls_RunOneAtATime();
ReentrantCall_Succeeds_WithReentrantLock();
SharedLock_CoordinatesTwoStunts();
DefaultLock_DoesNotCoordinateTwoStunts();
}

public void OverlappingSyncCalls_RunOneAtATime()
{
var stunt = Stunt.For<IWorker>();
stunt.AddBehavior(new SynchronizedBehavior());
var work = new TrackingWork();
stunt.AddBehavior(work);
IWorker worker = stunt.ToObject();

var barrier = new Barrier(2);
var tasks = new Task[2];
for (int i = 0; i < 2; i++)
tasks[i] = Task.Run(() =>
{
barrier.SignalAndWait();
worker.Work(1);
});

Task.WaitAll(tasks);

Assert.Equal(1, work.MaxConcurrent);
Assert.Equal(2, work.Calls);
}

public void ReentrantCall_Succeeds_WithReentrantLock()
{
var stunt = Stunt.For<IWorker>();
stunt.AddBehavior(new SynchronizedBehavior());
IWorker? worker = null;
stunt.AddBehavior(new ReentrantWork(v => worker!.Work(1)));
stunt.AddBehavior(new LambdaWork(invocation =>
invocation.MethodBase.Name == nameof(IWorker.Work)
? new MethodReturn(invocation, 0, invocation.Arguments)
: new MethodReturn(invocation, 0, invocation.Arguments)));
worker = stunt.ToObject();

// Reentrant Monitor: same-thread re-entry succeeds (no deadlock, no throw).
var result = worker.Reentrant();
Assert.Equal(0, result);
}

public void SharedLock_CoordinatesTwoStunts()
{
var syncRoot = new object();
var barrier = new Barrier(2);
int concurrent = 0;
int maxConcurrent = 0;

IWorker Make()
{
var stunt = Stunt.For<IWorker>();
stunt.AddBehavior(new SynchronizedBehavior(syncRoot));
stunt.AddBehavior(new LambdaWork(invocation =>
{
var c = Interlocked.Increment(ref concurrent);
int max;
do { max = maxConcurrent; }
while (c > max && Interlocked.CompareExchange(ref maxConcurrent, c, max) != max);
try
{
barrier.SignalAndWait(5000);
Thread.Sleep(50);
return new MethodReturn(invocation, 0, invocation.Arguments);
}
finally
{
Interlocked.Decrement(ref concurrent);
}
}));
return stunt.ToObject();
}

var w1 = Make();
var w2 = Make();

var t1 = Task.Run(() => w1.Work(1));
var t2 = Task.Run(() => w2.Work(2));
Task.WaitAll(t1, t2);

Assert.Equal(1, maxConcurrent);
}

public void DefaultLock_DoesNotCoordinateTwoStunts()
{
var barrier = new Barrier(2);
int concurrent = 0;
int maxConcurrent = 0;

IWorker Make()
{
var stunt = Stunt.For<IWorker>();
stunt.AddBehavior(new SynchronizedBehavior());
stunt.AddBehavior(new LambdaWork(invocation =>
{
var c = Interlocked.Increment(ref concurrent);
int max;
do { max = maxConcurrent; }
while (c > max && Interlocked.CompareExchange(ref maxConcurrent, c, max) != max);
try
{
barrier.SignalAndWait(5000);
Thread.Sleep(50);
return new MethodReturn(invocation, 0, invocation.Arguments);
}
finally
{
Interlocked.Decrement(ref concurrent);
}
}));
return stunt.ToObject();
}

var w1 = Make();
var w2 = Make();

var t1 = Task.Run(() => w1.Work(1));
var t2 = Task.Run(() => w2.Work(2));
Task.WaitAll(t1, t2);

Assert.Equal(2, maxConcurrent);
}

class TrackingWork : IStuntBehavior
{
public int Calls;
public int MaxConcurrent;
int current;

public bool AppliesTo(IMethodInvocation invocation)
=> invocation.MethodBase.Name == nameof(IWorker.Work);

public IMethodReturn Execute(IMethodInvocation invocation, ExecuteHandler next)
{
var c = Interlocked.Increment(ref current);
int max;
do { max = MaxConcurrent; }
while (c > max && Interlocked.CompareExchange(ref MaxConcurrent, c, max) != max);
try
{
Interlocked.Increment(ref Calls);
Thread.Sleep(50);
return new MethodReturn(invocation, 0, invocation.Arguments);
}
finally
{
Interlocked.Decrement(ref current);
}
}
}

class ReentrantWork : IStuntBehavior
{
readonly Action<IMethodInvocation> reenter;

public ReentrantWork(Action<IMethodInvocation> reenter) => this.reenter = reenter;

public bool AppliesTo(IMethodInvocation invocation)
=> invocation.MethodBase.Name == nameof(IWorker.Reentrant);

public IMethodReturn Execute(IMethodInvocation invocation, ExecuteHandler next)
{
reenter(invocation);
return new MethodReturn(invocation, 0, invocation.Arguments);
}
}

class LambdaWork : IStuntBehavior
{
readonly Func<IMethodInvocation, IMethodReturn> handler;

public LambdaWork(Func<IMethodInvocation, IMethodReturn> handler) => this.handler = handler;

public bool AppliesTo(IMethodInvocation invocation) => true;

public IMethodReturn Execute(IMethodInvocation invocation, ExecuteHandler next)
=> handler(invocation);
}
}
}
76 changes: 76 additions & 0 deletions src/Stunts/SynchronizedBehavior.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System;

namespace Stunts
{
/// <summary>
/// An <see cref="IStuntBehavior"/> that serializes overlapping calls on a stunt
/// using a standard <see cref="object"/> lock.
/// </summary>
/// <remarks>
/// The lock is acquired before <c>next</c> runs and released when it returns,
/// following the normal <c>lock</c> statement semantics (reentrant).
/// <para>
/// By default each behavior owns a private lock object, so one behavior locks
/// one stunt. Pass an explicit <c>syncRoot</c> to coordinate multiple behaviors
/// on the same lock.
/// </para>
/// <para>
/// The behavior implements <see cref="ICloneable"/>: the default private lock
/// is fresh per clone, while an explicit lock object stays shared across clones.
/// </para>
/// </remarks>
public class SynchronizedBehavior : IStuntBehavior, ICloneable
{
readonly object syncRoot;
readonly bool isShared;

/// <summary>
/// Initializes the behavior with a private lock owned by the behavior.
/// </summary>
public SynchronizedBehavior() : this(new object(), false) { }

/// <summary>
/// Initializes the behavior.
/// </summary>
/// <param name="syncRoot">
/// Optional lock object. When <see langword="null"/> (default), the behavior
/// uses a private object, so one behavior locks one stunt. When provided,
/// the behavior locks on that object.
/// </param>
public SynchronizedBehavior(object? syncRoot)
: this(syncRoot ?? new object(), syncRoot != null)
{
}

SynchronizedBehavior(object syncRoot, bool isShared)
{
this.syncRoot = syncRoot;
this.isShared = isShared;
}

/// <summary>
/// Returns a new <see cref="SynchronizedBehavior"/>. A default private lock
/// is fresh per clone; an explicit lock object stays shared.
/// </summary>
public object Clone() => new SynchronizedBehavior(
isShared ? syncRoot : new object(),
isShared);

/// <summary>
/// Applies to every method invocation: overlapping calls on the stunt are
/// excluded from running concurrently.
/// </summary>
public bool AppliesTo(IMethodInvocation invocation) => true;

/// <summary>
/// Acquires the lock, invokes <paramref name="next"/>, and releases the lock.
/// </summary>
public IMethodReturn Execute(IMethodInvocation invocation, ExecuteHandler next)
{
lock (syncRoot)
{
return next(invocation, next);
}
}
}
}
Loading