//
// Copyright (c) 2010-2025 Antmicro
//
// This file is licensed under the MIT License.
// Full license text is available in 'licenses/MIT.txt'.
//
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using Antmicro.Renode.Core;
using Antmicro.Renode.Debugging;
using Antmicro.Renode.Logging;
using Antmicro.Renode.Utilities;
using Antmicro.Migrant;
namespace Antmicro.Renode.Time
{
///
/// Provides common base for implementations.
///
public abstract class TimeSourceBase : IdentifiableObject, ITimeSource, IDisposable
{
///
/// Creates new instance of time source.
///
public TimeSourceBase()
{
virtualTimeSyncLock = new object();
isOnSyncPhaseThreadLock = new object();
blockingEvent = new ManualResetEvent(true);
delayedActions = new SortedSet();
handles = new HandlesCollection();
stopwatch = new Stopwatch();
hostTicksElapsed = new TimeVariantValue(10);
virtualTicksElapsed = new TimeVariantValue(10);
sync = new PrioritySynchronizer();
Quantum = DefaultQuantum;
this.Trace();
}
///
/// Disposes this instance.
///
public virtual void Dispose()
{
delayedActions.Clear();
stopwatch.Stop();
BlockHook = null;
StopRequested = null;
SyncHook = null;
TimePassed = null;
SinksReportedHook = null;
using(sync.HighPriority)
{
handles.LatchAllAndCollectGarbage();
handles.UnlatchAll();
foreach(var slave in handles.All)
{
slave.Dispose();
}
}
}
///
/// Activates sources of the time source and provides an object that deactivates them on dispose.
///
protected IDisposable ObtainSourceActiveState()
{
using(sync.HighPriority)
{
foreach(var slave in handles.All)
{
slave.SourceSideActive = true;
slave.RequestStart();
}
}
var result = new DisposableWrapper();
result.RegisterDisposeAction(() =>
{
using(sync.HighPriority)
{
foreach(var slave in handles.All)
{
slave.SourceSideActive = false;
}
}
});
return result;
}
///
/// Starts the time source and provides an object that stops it on dispose.
///
protected IDisposable ObtainStartedState()
{
Start();
return new DisposableWrapper().RegisterDisposeAction(() => Stop());
}
///
/// Starts this time source and activates all associated slaves.
///
/// False it the handle has already been started.
protected bool Start()
{
if(isStarted)
{
this.Trace("Already started");
return false;
}
using(sync.HighPriority)
{
if(isStarted)
{
this.Trace("Already started");
return false;
}
stopwatch.Start();
isStarted = true;
return true;
}
}
///
/// Requests start of all registered slaves.
///
///
/// The method should be called after activating this source using ,
/// otherwise a race condition situation might happen.
///
protected void RequestSlavesStart()
{
using(sync.HighPriority)
{
foreach(var slave in handles.All)
{
slave.RequestStart();
}
}
}
///
/// Calls event.
///
protected void RequestStop()
{
StopRequested?.Invoke();
}
///
/// Stops this time source and deactivates all associated slaves.
///
protected void Stop()
{
RequestStop();
using(sync.HighPriority)
{
if(!isStarted)
{
this.Trace("Not started");
return;
}
stopwatch.Stop();
isStarted = false;
sync.Pulse();
blockingEvent.Set();
}
}
///
/// Queues an action to execute in the nearest synced state.
///
/// Flag indicating if the action should be executed immediately when executed in already synced context or should it wait for the next synced state.
public void ExecuteInNearestSyncedState(Action what, bool executeImmediately = false)
{
if(IsOnSyncPhaseThread && executeImmediately)
{
what(new TimeStamp(ElapsedVirtualTime, Domain));
return;
}
lock(delayedActions)
{
delayedActions.Add(new DelayedTask(what, new TimeStamp(), ++delayedTaskId));
}
}
///
/// Queues an action to execute in the nearest synced state after time point.
///
///
/// If the time stamp comes from other time domain it will be executed in the nearest synced state.
///
///
/// The ID of the action, which can be used to cancel it with .
///
public ulong ExecuteInSyncedState(Action what, TimeStamp when)
{
lock(delayedActions)
{
var id = ++delayedTaskId;
delayedActions.Add(new DelayedTask(what, when.Domain != Domain ? new TimeStamp() : when, id));
return id;
}
}
///
/// Removes a queued action to execute in the synced state by ID.
///
/// The ID of the action to remove.
///
/// True if the action was successfully removed, otherwise false.
///
public bool CancelActionToExecuteInSyncedState(ulong actionId)
{
lock(delayedActions)
{
return delayedActions.RemoveWhere(action => action.Id == actionId) == 1;
}
}
///
public void RegisterSink(ITimeSink sink)
{
using(sync.HighPriority)
{
var handle = new TimeHandle(this, sink) { SourceSideActive = isStarted };
StopRequested += handle.RequestPause;
handles.Add(handle);
#if DEBUG
this.Trace($"Registering sink ({(sink as IIdentifiable)?.GetDescription()}) in source ({this.GetDescription()}) via handle ({handle.GetDescription()})");
#endif
// assigning TimeHandle to a sink must be done when everything is configured, otherwise a race condition might happen (dispatcher starts its execution when time source and handle are not yet ready)
sink.TimeHandle = handle;
}
}
public IEnumerable Sinks { get { using(sync.HighPriority) { return handles.Select(x => x.TimeSink); } } }
///
public void ReportHandleActive()
{
blockingEvent.Set();
}
///
public void ReportTimeProgress()
{
SynchronizeVirtualTime();
}
private void SynchronizeVirtualTime()
{
lock(virtualTimeSyncLock)
{
if(!handles.TryGetCommonElapsedTime(out var currentCommonElapsedTime))
{
return;
}
if(currentCommonElapsedTime == ElapsedVirtualTime)
{
return;
}
DebugHelper.Assert(currentCommonElapsedTime > ElapsedVirtualTime, $"A slave reports time from the past! The current virtual time is {ElapsedVirtualTime}, but {currentCommonElapsedTime} has been reported");
var timeDiff = currentCommonElapsedTime - ElapsedVirtualTime;
this.Trace($"Reporting time passed: {timeDiff}");
// this will update ElapsedVirtualTime
UpdateTime(timeDiff);
TimePassed?.Invoke(timeDiff);
}
}
public override string ToString()
{
return string.Join("\n",
$"Elapsed Virtual Time: {ElapsedVirtualTime}",
$"Elapsed Host Time: {ElapsedHostTime}",
$"Current load: {CurrentLoad}",
$"Cumulative load: {CumulativeLoad}",
$"State: {State}",
$"Advance immediately: {AdvanceImmediately}",
$"Quantum: {Quantum}");
}
///
public abstract ITimeDomain Domain { get; }
// TODO: this name does not give a lot to a user - maybe we should rename it?
///
/// Gets or sets flag indicating if the time flow should be slowed down to reflect real time or be as fast as possible.
///
///
/// Setting this flag to True has the same effect as setting to a very high value.
///
public bool AdvanceImmediately { get; set; }
///
/// Gets current state of this time source.
///
public TimeSourceState State { get; private set; }
// TODO: do not allow to set Quantum of 0
///
public TimeInterval Quantum
{
get => quantum;
set
{
if(quantum == value)
{
return;
}
var oldQuantum = quantum;
quantum = value;
QuantumChanged?.Invoke(oldQuantum, quantum);
}
}
///
/// Gets the value representing current load, i.e., value indicating how much time the emulation spends sleeping in order to match the expected .
///
///
/// Value 1 means that there is no sleeping, i.e., it is not possible to execute faster. Value > 1 means that the execution is slower than expected. Value < 1 means that increasing will lead to faster execution.
/// This value is calculated as an average of 10 samples.
///
public double CurrentLoad { get { lock(hostTicksElapsed) { return hostTicksElapsed.AverageValue * 1.0 / virtualTicksElapsed.AverageValue; } } }
///
/// Gets the value representing load (see ) calculated from all samples.
///
public double CumulativeLoad { get { lock(hostTicksElapsed) { return hostTicksElapsed.CumulativeValue * 1.0 / virtualTicksElapsed.CumulativeValue; } } }
///
/// Gets the amount of virtual time elapsed from the perspective of this time source.
///
///
/// This is a minimum value of all associated .
///
public TimeInterval ElapsedVirtualTime { get { return TimeInterval.FromTicks(virtualTicksElapsed.CumulativeValue); } }
///
/// Gets the amount of host time elapsed from the perspective of this time source.
///
public TimeInterval ElapsedHostTime { get { return TimeInterval.FromTicks(hostTicksElapsed.CumulativeValue); } }
///
/// Gets the amount that the virtual time is ahead of the host time from the perspective of this time source,
/// or 0 if the virtual time is behind the host time.
///
public TimeInterval ElapsedVirtualHostTimeDifference
{
get
{
lock(hostTicksElapsed)
{
var hostTicks = hostTicksElapsed.CumulativeValue;
var virtualTicks = virtualTicksElapsed.CumulativeValue;
if(virtualTicks <= hostTicks)
{
return TimeInterval.Empty;
}
return TimeInterval.FromTicks(virtualTicks - hostTicks);
}
}
}
///
/// Gets the virtual time point of the nearest synchronization of all associated .
///
public TimeInterval NearestSyncPoint { get; private set; }
///
/// Gets the number of synchronizations points reached so far.
///
public long NumberOfSyncPoints { get; private set; }
///
/// Gets or sets the flag indicating if the current thread is a safe thread executing sync phase.
///
public bool IsOnSyncPhaseThread
{
get
{
lock(isOnSyncPhaseThreadLock)
{
return executeThreadId == Thread.CurrentThread.ManagedThreadId;
}
}
private set
{
lock(isOnSyncPhaseThreadLock)
{
executeThreadId = value ? Thread.CurrentThread.ManagedThreadId : (int?)null;
}
}
}
///
/// Forces the execution phase of time sinks to be done in serial.
///
///
/// Using this option might reduce the performance of the execution, but ensures the determinism.
///
public bool ExecuteInSerial { get; set; }
///
/// Action to be executed on every synchronization point.
///
public event Action SyncHook;
///
/// An event called when the time source is blocked by at least one of the sinks.
///
public event Action BlockHook;
///
/// An event called when no sink is in progress.
///
public event Action SinksReportedHook;
///
/// An event informing about the amount of passed virtual time. Might be called many times between two consecutive synchronization points.
///
public event Action TimePassed;
///
/// An event called when the Quantum is changed.
///
public event Action QuantumChanged;
///
/// Execute one iteration of time-granting loop.
///
///
/// The steps are as follows:
/// (1) remove and forget all slave handles that requested detaching
/// (2) check if there are any blocked slaves; if so DO NOT grant a time interval
/// (2.1) if there are no blocked slaves grant a new time interval to every slave
/// (3) wait for all slaves that are relevant in this execution (it can be either all slaves or just blocked ones) until they report back
/// (4) update elapsed virtual time
/// (5) execute sync hook and delayed actions if any
///
/// Contains the amount of virtual time that passed during execution of this method. It is the minimal value reported by a slave (i.e, some slaves can report higher/lower values).
/// Maximum amount of virtual time that can pass during the execution of this method. If not set, current is used.
///
/// True if sync point has just been reached or False if the execution has been blocked.
///
protected bool InnerExecute(out TimeInterval virtualTimeElapsed, TimeInterval? timeLimit = null)
{
if(updateNearestSyncPoint)
{
NearestSyncPoint += timeLimit.HasValue ? TimeInterval.Min(timeLimit.Value, Quantum) : Quantum;
updateNearestSyncPoint = false;
this.Trace($"Updated NearestSyncPoint to: {NearestSyncPoint}");
}
DebugHelper.Assert(NearestSyncPoint.Ticks >= ElapsedVirtualTime.Ticks, $"Nearest sync point set in the past: EVT={ElapsedVirtualTime} NSP={NearestSyncPoint}");
isBlocked = false;
var quantum = NearestSyncPoint - ElapsedVirtualTime;
this.Trace($"Starting a loop with #{quantum.Ticks} ticks");
SynchronizeVirtualTime();
var elapsedVirtualTimeAtStart = ElapsedVirtualTime;
using(sync.LowPriority)
{
handles.LatchAllAndCollectGarbage();
var shouldGrantTime = handles.AreAllReadyForNewGrant;
this.Trace($"Iteration start: slaves left {handles.ActiveCount}; will we try to grant time? {shouldGrantTime}");
if(handles.ActiveCount > 0)
{
var executor = new PhaseExecutor>();
if(!shouldGrantTime)
{
if(ExecuteInSerial)
{
// We only test in serial execution to ensure determinism
executor.RegisterTestPhase(ExecuteReadyForUnblockTestPhase);
}
executor.RegisterPhase(ExecuteUnblockPhase);
executor.RegisterPhase(ExecuteWaitPhase);
}
else if(quantum != TimeInterval.Empty)
{
executor.RegisterPhase(s => ExecuteGrantPhase(s, quantum));
executor.RegisterPhase(ExecuteWaitPhase);
}
if(ExecuteInSerial)
{
executor.ExecuteInSerial(handles.WithLinkedListNode);
}
else
{
executor.ExecuteInParallel(handles.WithLinkedListNode);
}
SynchronizeVirtualTime();
virtualTimeElapsed = ElapsedVirtualTime - elapsedVirtualTimeAtStart;
}
else
{
this.Trace($"There are no slaves, updating VTE by {quantum.Ticks}");
// if there are no slaves just make the time pass
virtualTimeElapsed = quantum;
UpdateTime(quantum);
// here we must trigger `TimePassed` manually as no handles has been updated so they won't reflect the passed time
TimePassed?.Invoke(quantum);
}
handles.UnlatchAll();
}
SinksReportedHook?.Invoke();
if(!isBlocked)
{
ExecuteSyncPhase();
updateNearestSyncPoint = true;
}
else
{
BlockHook?.Invoke();
}
State = TimeSourceState.Idle;
this.Trace($"The end of {nameof(InnerExecute)} with result={!isBlocked}");
return !isBlocked;
}
private void UpdateTime(TimeInterval virtualTimeElapsed)
{
lock(hostTicksElapsed)
{
// Converting to TimeInterval truncates to whole microseconds. Convert before saving the value to
// elapsedAtLastUpdate, as otherwise we would lose this decimal part.
var currentTimestamp = TimeInterval.FromTimeSpan(stopwatch.Elapsed);
var elapsedThisTime = currentTimestamp - elapsedAtLastUpdate;
elapsedAtLastUpdate = currentTimestamp;
this.Trace($"Updating virtual time by {virtualTimeElapsed.TotalMicroseconds} us");
this.virtualTicksElapsed.Update(virtualTimeElapsed.Ticks);
this.hostTicksElapsed.Update(elapsedThisTime.Ticks);
}
}
///
/// Activates all slaves from source side perspective, i.e., tells them that there will be time granted in the nearest future.
///
protected void ActivateSlavesSourceSide(bool state = true)
{
using(sync.HighPriority)
{
foreach(var slave in handles.All)
{
slave.SourceSideActive = state;
if(state)
{
slave.RequestStart();
}
}
}
}
///
/// Deactivates all slaves from source side perspective, i.e., tells them that there will be no grants in the nearest future.
///
protected void DeactivateSlavesSourceSide()
{
ActivateSlavesSourceSide(false);
}
///
/// Suspends an execution of the calling thread if blocking event is set.
///
///
/// This is just to improve performance of the emulation - avoid spinning when any of the sinks is blocking.
///
protected void WaitIfBlocked()
{
// this 'if' statement and 'canBeBlocked' variable are here for performance only
// calling `WaitOne` in every iteration can cost a lot of time;
// waiting on 'blockingEvent' is not required for the time framework to work properly,
// but decreases cpu usage when any handle is known to be blocking
if(isBlocked)
{
// value of 'isBlocked' will be reevaluated in 'ExecuteInner' method
blockingEvent.WaitOne(10);
// this parameter here is kind of a hack:
// in theory we could use an overload without timeout,
// but there is a bug and sometimes it blocks forever;
// this is just a simple workaround
}
}
///
/// Forces value of elapsed virtual time and nearest sync point.
///
///
/// It is called when attaching a new time handle to synchronize the initial value of virtual time.
///
protected void ResetVirtualTime(TimeInterval interval)
{
lock(hostTicksElapsed)
{
DebugHelper.Assert(ElapsedVirtualTime <= interval, $"Couldn't reset back in time from {ElapsedVirtualTime} to {interval}.");
virtualTicksElapsed.Reset(interval.Ticks);
NearestSyncPoint = interval;
using(sync.HighPriority)
{
foreach(var handle in handles.All)
{
handle.Reset();
}
}
}
}
///
/// Grants time interval to a single handle.
///
private void ExecuteGrantPhase(LinkedListNode handle, TimeInterval quantum)
{
State = TimeSourceState.ReportingElapsedTime;
handle.Value.GrantTimeInterval(quantum);
}
///
/// Unblocks a single handle allowing it to continue
/// execution of the previously granted time interval.
///
private void ExecuteUnblockPhase(LinkedListNode handle)
{
handle.Value.UnblockHandle();
}
///
/// Tests the given handle for readiness to be unblocked.
/// If the handle is not ready the execution is blocked.
///
private bool ExecuteReadyForUnblockTestPhase(LinkedListNode handle)
{
var isReady = handle.Value.IsReadyToBeUnblocked;
isBlocked |= !isReady;
return isReady;
}
///
/// Waits until the handle finishes its execution.
///
///
/// This method must be called with a locked.
///
private void ExecuteWaitPhase(LinkedListNode handle)
{
State = TimeSourceState.WaitingForReportBack;
var result = handle.Value.WaitUntilDone(out var usedInterval);
if(!result.IsDone)
{
EnterBlockedState(!handle.Value.SinkSideActive);
}
using(sync.HighPriority)
{
handles.UpdateHandle(handle);
}
}
///
/// Sets blocking event to true.
///
/// Describes if we should block until external event
private void EnterBlockedState(bool waitForEvent)
{
isBlocked = true;
// The blocking event is by default in the `set` state.
// It enters the `unset` state only temporarily between calls to `EnterBlockedState` (with the wait for event flag set) and `ReportHandleActive`.
if(waitForEvent)
{
blockingEvent.Reset();
}
}
///
/// Executes sync phase actions in a safe state.
///
private void ExecuteSyncPhase()
{
this.Trace($"Before syncpoint, EVT={ElapsedVirtualTime.Ticks}, NSP={NearestSyncPoint.Ticks}");
// if no slave returned blocking state, sync point should be reached
DebugHelper.Assert(ElapsedVirtualTime == NearestSyncPoint);
this.Trace($"We are at the sync point #{NumberOfSyncPoints}");
State = TimeSourceState.ExecutingSyncHook;
DelayedTask[] tasksAsArray;
TimeStamp timeNow;
lock(delayedActions)
{
IsOnSyncPhaseThread = true;
SyncHook?.Invoke(ElapsedVirtualTime);
State = TimeSourceState.ExecutingDelayedActions;
timeNow = new TimeStamp(ElapsedVirtualTime, Domain);
// we are not incrementing delayedTaskId here because DelayedTask object is only created temporarily for comparison,
// all operations on delayedActions are in the lock() blocks and delayedTaskId is never decremented so its current value
// is greater or equal to every currently existing DelayedTask object which is what we care for in this comparison
var tasksToExecute = delayedActions.GetViewBetween(DelayedTask.Zero, new DelayedTask(null, timeNow, delayedTaskId));
tasksAsArray = tasksToExecute.ToArray();
tasksToExecute.Clear();
}
foreach(var task in tasksAsArray)
{
task.What(timeNow);
}
IsOnSyncPhaseThread = false;
NumberOfSyncPoints++;
}
// This value is dropped because it should always be false after deserialization in order to start the emulation properly,
// otherwise starting stopwatch is omitted in TimeSourceBase.Start() method.
// If it wasn't marked as transient it could be true when the emulation wasn't paused before serialization because it was already in a safe state.
[Transient]
protected volatile bool isStarted;
protected bool isPaused;
protected readonly HandlesCollection handles;
protected readonly Stopwatch stopwatch;
// we use special object for locking as it was observed that idle dispatcher thread can starve other threads when using simple lock(object)
protected readonly PrioritySynchronizer sync;
///
/// Used to request a pause on sinks before trying to acquire their locks.
///
///
/// Triggering this event can improve pausing efficiency by interrupting the sink execution in the middle of a quant.
///
private event Action StopRequested;
[Antmicro.Migrant.Constructor(true)]
private ManualResetEvent blockingEvent;
private TimeInterval elapsedAtLastUpdate;
private bool isBlocked;
private bool updateNearestSyncPoint;
private int? executeThreadId;
private ulong delayedTaskId;
private TimeInterval quantum;
private readonly TimeVariantValue virtualTicksElapsed;
private readonly TimeVariantValue hostTicksElapsed;
private readonly SortedSet delayedActions;
private readonly object virtualTimeSyncLock;
private readonly object isOnSyncPhaseThreadLock;
private static readonly TimeInterval DefaultQuantum = TimeInterval.FromMicroseconds(100);
///
/// Allows locking without starvation.
///
protected class PrioritySynchronizer : IdentifiableObject, IDisposable
{
public PrioritySynchronizer()
{
innerLock = new object();
}
///
/// Used to obtain lock with low priority.
///
///
/// Any thread already waiting on the lock with high priority is guaranteed to obtain it prior to this one.
/// There are no guarantees for many threads with the same priority.
///
public PrioritySynchronizer LowPriority
{
get
{
// here we assume that `highPriorityRequestPending` will be reset soon,
// so there is no point of using more complicated synchronization methods
while(highPriorityRequestPendingCounter > 0) ;
Monitor.Enter(innerLock);
return this;
}
}
///
/// Used to obtain lock with high priority.
///
///
/// It is guaranteed that the thread wanting to lock with high priority will not wait indefinitely if all other threads lock with low priority.
/// There are no guarantees for many threads with the same priority.
///
public PrioritySynchronizer HighPriority
{
get
{
Interlocked.Increment(ref highPriorityRequestPendingCounter);
Monitor.Enter(innerLock);
Interlocked.Decrement(ref highPriorityRequestPendingCounter);
return this;
}
}
public void Dispose()
{
Monitor.Exit(innerLock);
}
public void WaitWhile(Func condition, string reason)
{
innerLock.WaitWhile(condition, reason);
}
public void Pulse()
{
Monitor.PulseAll(innerLock);
}
private readonly object innerLock;
private volatile int highPriorityRequestPendingCounter;
}
///
/// Represents a time-variant value.
///
private class TimeVariantValue
{
public TimeVariantValue(int size)
{
buffer = new ulong[size];
}
///
/// Resets the value and clears the internal buffer.
///
public void Reset(ulong value = 0)
{
position = 0;
CumulativeValue = 0;
partialSum = 0;
Array.Clear(buffer, 0, buffer.Length);
Update(value);
}
///
/// Updates the .
///
public void Update(ulong value)
{
RawValue = value;
CumulativeValue += value;
partialSum += value;
partialSum -= buffer[position];
buffer[position] = value;
position = (position + 1) % buffer.Length;
}
public ulong RawValue { get; private set; }
///
/// Returns average of over the last samples.
///
public ulong AverageValue { get { return (ulong)(partialSum / (ulong)buffer.Length); } }
///
/// Returns total sum of all so far.
///
public ulong CumulativeValue { get; private set; }
private readonly ulong[] buffer;
private int position;
private ulong partialSum;
}
///
/// Represents a task that is scheduled for execution in the future.
///
private struct DelayedTask : IComparable
{
static DelayedTask()
{
Zero = new DelayedTask();
}
public DelayedTask(Action what, TimeStamp when, ulong id) : this()
{
What = what;
When = when;
Id = id;
}
public int CompareTo(DelayedTask other)
{
var result = When.TimeElapsed.CompareTo(other.When.TimeElapsed);
return result != 0 ? result : Id.CompareTo(other.Id);
}
public Action What { get; private set; }
public TimeStamp When { get; private set; }
public static DelayedTask Zero { get; private set; }
public ulong Id { get; }
}
///
/// Allows to execute registered actions in serial or in parallel.
///
private class PhaseExecutor
{
public PhaseExecutor()
{
testPhases = new List>();
phases = new List>();
}
public void RegisterPhase(Action action)
{
phases.Add(action);
}
public void RegisterTestPhase(Func predicate)
{
testPhases.Add(predicate);
}
public void ExecuteInSerial(IEnumerable targets)
{
if(phases.Count == 0)
{
return;
}
if(!ExecuteTestPhase(targets))
{
return;
}
foreach(var target in targets)
{
foreach(var phase in phases)
{
phase(target);
}
}
}
public void ExecuteInParallel(IEnumerable targets)
{
if(!ExecuteTestPhase(targets))
{
return;
}
foreach(var phase in phases)
{
foreach(var target in targets)
{
phase(target);
}
}
}
private bool ExecuteTestPhase(IEnumerable targets)
{
foreach(var test in testPhases)
{
foreach(var target in targets)
{
if(!test(target))
{
return false;
}
}
}
return true;
}
private readonly List> testPhases;
private readonly List> phases;
}
}
}