556 lines
17 KiB
C#
556 lines
17 KiB
C#
using Appccelerate.StateMachine;
|
|
using Appccelerate.StateMachine.Machine;
|
|
using RobotNet10.Script;
|
|
using RobotNet10.ScriptEngine.Shared;
|
|
using System.Diagnostics;
|
|
using System.Threading;
|
|
|
|
namespace RobotNet10.ScriptEngine.Models;
|
|
|
|
/// <summary>
|
|
/// Represents a periodic task with state machine management.
|
|
/// </summary>
|
|
public class ScriptTask : IDisposable
|
|
{
|
|
private readonly PassiveStateMachine<ScriptTaskState, TaskTrigger> _stateMachine;
|
|
private readonly ScriptTaskModel _model;
|
|
private readonly ScriptGlobals _globals;
|
|
private readonly ILogger _logger;
|
|
private Thread? _timerThread;
|
|
private volatile bool _timerThreadRunning;
|
|
private bool _isExecuting;
|
|
private bool _isPaused;
|
|
private bool _disposed;
|
|
private Exception? _lastError;
|
|
private readonly object _lockObject = new();
|
|
private ScriptTaskState _currentState;
|
|
private long _executionCount;
|
|
|
|
/// <summary>
|
|
/// Task triggers for state machine transitions.
|
|
/// </summary>
|
|
public enum TaskTrigger
|
|
{
|
|
Start,
|
|
Pause,
|
|
Resume,
|
|
Stop,
|
|
PausingCompleted,
|
|
ResumingCompleted,
|
|
StoppingCompleted,
|
|
ErrorOccurred,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the name of the task.
|
|
/// </summary>
|
|
public string Name => _model.Name;
|
|
|
|
/// <summary>
|
|
/// Gets the interval in seconds between task executions.
|
|
/// </summary>
|
|
public int Interval => _model.Interval;
|
|
|
|
/// <summary>
|
|
/// Gets whether the task should auto-start when engine starts.
|
|
/// </summary>
|
|
public bool AutoStart => _model.AutoStart;
|
|
|
|
/// <summary>
|
|
/// Gets the current state of the task.
|
|
/// </summary>
|
|
public ScriptTaskState State => _currentState;
|
|
|
|
/// <summary>
|
|
/// Gets the last error that occurred during task execution.
|
|
/// </summary>
|
|
public Exception? LastError => _lastError;
|
|
|
|
/// <summary>
|
|
/// Gets whether the task is currently executing.
|
|
/// </summary>
|
|
public bool IsExecuting => _isExecuting;
|
|
|
|
/// <summary>
|
|
/// Gets the number of times the task has been executed.
|
|
/// </summary>
|
|
public long ExecutionCount => _executionCount;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the ScriptTask class.
|
|
/// </summary>
|
|
/// <param name="model">The task model containing task metadata and runner.</param>
|
|
/// <param name="globals">The script globals dictionary.</param>
|
|
public ScriptTask(
|
|
ScriptTaskModel model,
|
|
ScriptGlobals globals)
|
|
{
|
|
_model = model ?? throw new ArgumentNullException(nameof(model));
|
|
_globals = globals ?? throw new ArgumentNullException(nameof(globals));
|
|
|
|
// Get logger from globals.ScriptRobotNet
|
|
if (_globals.RobotNet.TryGetValue("get_Logger", out object? getLogger) &&
|
|
getLogger is Func<ILogger> getLoggerFunc)
|
|
{
|
|
_logger = getLoggerFunc.Invoke();
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException($"Failed to get Logger from ScriptRobotNet globals for task '{model.Name}'");
|
|
}
|
|
|
|
var builder = new StateMachineDefinitionBuilder<ScriptTaskState, TaskTrigger>();
|
|
|
|
// Configure state machine transitions according to StateMachine_Design.md
|
|
ConfigureStateMachine(builder);
|
|
|
|
_stateMachine = builder
|
|
.WithInitialState(ScriptTaskState.Idle)
|
|
.Build()
|
|
.CreatePassiveStateMachine();
|
|
|
|
_currentState = ScriptTaskState.Idle;
|
|
_stateMachine.Start();
|
|
}
|
|
|
|
private void ConfigureStateMachine(StateMachineDefinitionBuilder<ScriptTaskState, TaskTrigger> builder)
|
|
{
|
|
// Idle state
|
|
builder.In(ScriptTaskState.Idle)
|
|
.On(TaskTrigger.Start)
|
|
.Goto(ScriptTaskState.Running);
|
|
|
|
// Running state - configure entry/exit actions separately
|
|
builder.In(ScriptTaskState.Running)
|
|
.ExecuteOnEntry(() => { _currentState = ScriptTaskState.Running; OnEnterRunning(); })
|
|
.On(TaskTrigger.Pause)
|
|
.Goto(ScriptTaskState.Pausing)
|
|
.On(TaskTrigger.Stop)
|
|
.Goto(ScriptTaskState.Stopping)
|
|
.On(TaskTrigger.ErrorOccurred)
|
|
.Goto(ScriptTaskState.Error)
|
|
.Execute(() => { _currentState = ScriptTaskState.Error; OnEnterError(); });
|
|
|
|
// Pausing state
|
|
builder.In(ScriptTaskState.Pausing)
|
|
.ExecuteOnEntry(() => { _currentState = ScriptTaskState.Pausing; OnEnterPausing(); })
|
|
.ExecuteOnExit(() => OnExitPausing())
|
|
.On(TaskTrigger.PausingCompleted)
|
|
.Goto(ScriptTaskState.Paused)
|
|
.On(TaskTrigger.ErrorOccurred)
|
|
.Goto(ScriptTaskState.Error)
|
|
.Execute(() => { _currentState = ScriptTaskState.Error; OnEnterError(); });
|
|
|
|
// Paused state
|
|
builder.In(ScriptTaskState.Paused)
|
|
.ExecuteOnEntry(() => { _currentState = ScriptTaskState.Paused; OnEnterPaused(); })
|
|
.ExecuteOnExit(() => OnExitPaused())
|
|
.On(TaskTrigger.Resume)
|
|
.Goto(ScriptTaskState.Resuming)
|
|
.On(TaskTrigger.Stop)
|
|
.Goto(ScriptTaskState.Stopping);
|
|
|
|
// Resuming state
|
|
builder.In(ScriptTaskState.Resuming)
|
|
.ExecuteOnEntry(() => { _currentState = ScriptTaskState.Resuming; OnEnterResuming(); })
|
|
.ExecuteOnExit(() => OnExitResuming())
|
|
.On(TaskTrigger.ResumingCompleted)
|
|
.Goto(ScriptTaskState.Running)
|
|
.On(TaskTrigger.ErrorOccurred)
|
|
.Goto(ScriptTaskState.Error)
|
|
.Execute(() => { _currentState = ScriptTaskState.Error; OnEnterError(); });
|
|
|
|
// Stopping state
|
|
builder.In(ScriptTaskState.Stopping)
|
|
.ExecuteOnEntry(() => { _currentState = ScriptTaskState.Stopping; OnEnterStopping(); })
|
|
.On(TaskTrigger.StoppingCompleted)
|
|
.Goto(ScriptTaskState.Stopped)
|
|
.On(TaskTrigger.ErrorOccurred)
|
|
.Goto(ScriptTaskState.Error)
|
|
.Execute(() => { _currentState = ScriptTaskState.Error; OnEnterError(); });
|
|
|
|
// Stopped state
|
|
builder.In(ScriptTaskState.Stopped)
|
|
.ExecuteOnEntry(() => { _currentState = ScriptTaskState.Stopped; OnEnterStopped(); })
|
|
.On(TaskTrigger.Start)
|
|
.Goto(ScriptTaskState.Running);
|
|
|
|
// Error state
|
|
builder.In(ScriptTaskState.Error)
|
|
.ExecuteOnEntry(() => { _currentState = ScriptTaskState.Error; OnEnterError(); })
|
|
.ExecuteOnExit(() => OnExitError())
|
|
.On(TaskTrigger.Start)
|
|
.Goto(ScriptTaskState.Running);
|
|
}
|
|
|
|
#region State Machine Event Handlers
|
|
|
|
private void OnEnterRunning()
|
|
{
|
|
lock (_lockObject)
|
|
{
|
|
// Start high-priority thread with SpinWait
|
|
StartTimerThread();
|
|
}
|
|
}
|
|
|
|
private void StartTimerThread()
|
|
{
|
|
if (_timerThread == null || !_timerThread.IsAlive)
|
|
{
|
|
_timerThreadRunning = true;
|
|
_timerThread = new Thread(TimerThreadProc)
|
|
{
|
|
IsBackground = false,
|
|
Priority = ThreadPriority.Highest,
|
|
Name = $"TaskTimer-{_model.Name}"
|
|
};
|
|
_timerThread.Start();
|
|
_logger.LogInfo($"Task '{_model.Name}' started running with high-priority thread (interval: {_model.Interval}ms).");
|
|
}
|
|
}
|
|
|
|
private void TimerThreadProc()
|
|
{
|
|
_isExecuting = true;
|
|
Thread.BeginThreadAffinity();
|
|
try
|
|
{
|
|
// Convert interval from seconds to milliseconds
|
|
var intervalMs = _model.Interval;
|
|
var intervalTicks = intervalMs * TimeSpan.TicksPerMillisecond;
|
|
var stopwatch = Stopwatch.StartNew();
|
|
var nextExecutionTime = stopwatch.ElapsedTicks + intervalTicks;
|
|
var spinWait = new SpinWait();
|
|
long currentTicks = 0;
|
|
|
|
while (_timerThreadRunning)
|
|
{
|
|
currentTicks = stopwatch.ElapsedTicks;
|
|
|
|
// Check if it's time to execute
|
|
if (currentTicks >= nextExecutionTime)
|
|
{
|
|
if (_isPaused) continue;
|
|
|
|
try
|
|
{
|
|
lock (_lockObject)
|
|
{
|
|
_executionCount++;
|
|
}
|
|
|
|
// Execute the script runner with globals directly
|
|
var result = _model.Runner(_globals).GetAwaiter().GetResult();
|
|
|
|
// Handle async result if needed
|
|
if (result is Task taskResult)
|
|
{
|
|
taskResult.GetAwaiter().GetResult();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_lastError = ex;
|
|
_logger.LogError($"Task '{_model.Name}' execution error: {ex.Message}");
|
|
_stateMachine.Fire(TaskTrigger.ErrorOccurred);
|
|
break;
|
|
}
|
|
|
|
// Calculate next execution time
|
|
nextExecutionTime = currentTicks + intervalTicks;
|
|
}
|
|
spinWait.SpinOnce();
|
|
spinWait.SpinOnce();
|
|
spinWait.SpinOnce();
|
|
spinWait.SpinOnce();
|
|
spinWait.SpinOnce();
|
|
spinWait.SpinOnce();
|
|
spinWait.SpinOnce();
|
|
spinWait.SpinOnce();
|
|
spinWait.SpinOnce();
|
|
spinWait.SpinOnce();
|
|
spinWait.Reset();
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Thread.EndThreadAffinity();
|
|
_isExecuting = false;
|
|
}
|
|
}
|
|
|
|
private void OnEnterPausing()
|
|
{
|
|
// Wait for current execution to complete if running
|
|
// Then fire PausingCompleted trigger
|
|
Task.Run(async () =>
|
|
{
|
|
await WaitForExecutionComplete();
|
|
_stateMachine.Fire(TaskTrigger.PausingCompleted);
|
|
});
|
|
}
|
|
|
|
private void OnExitPausing()
|
|
{
|
|
// Set paused flag - timer continues but ExecuteTask will skip execution
|
|
_isPaused = true;
|
|
}
|
|
|
|
private void OnEnterPaused()
|
|
{
|
|
_isPaused = true;
|
|
_logger.LogInfo($"Task '{_model.Name}' paused (timer continues, execution skipped).");
|
|
}
|
|
|
|
private void OnExitPaused()
|
|
{
|
|
// Clear paused flag when exiting paused state
|
|
_isPaused = false;
|
|
}
|
|
|
|
private void OnEnterResuming()
|
|
{
|
|
// Clear paused flag immediately
|
|
_isPaused = false;
|
|
// Fire ResumingCompleted immediately (no async operation needed)
|
|
_stateMachine.Fire(TaskTrigger.ResumingCompleted);
|
|
}
|
|
|
|
private void OnExitResuming()
|
|
{
|
|
// Ensure paused flag is cleared
|
|
_isPaused = false;
|
|
}
|
|
|
|
private void OnEnterStopping()
|
|
{
|
|
// Stop timer thread, wait for current execution to complete
|
|
lock (_lockObject)
|
|
{
|
|
// Stop the timer thread loop
|
|
_timerThreadRunning = false;
|
|
}
|
|
|
|
// Wait for timer thread to finish (with timeout)
|
|
if (_timerThread != null && _timerThread.IsAlive)
|
|
{
|
|
if (!_timerThread.Join(TimeSpan.FromSeconds(2)))
|
|
{
|
|
_logger.LogWarning($"Task '{_model.Name}' timer thread did not stop within timeout. Proceeding anyway.");
|
|
}
|
|
}
|
|
|
|
Task.Run(async () =>
|
|
{
|
|
// Wait for execution to complete, but only timeout if actually executing
|
|
await WaitForExecutionComplete();
|
|
|
|
// If still executing after timeout, it's ScriptRunner blocking
|
|
// Otherwise, proceed even if state machine didn't transition
|
|
_stateMachine.Fire(TaskTrigger.StoppingCompleted);
|
|
});
|
|
}
|
|
|
|
private void OnEnterStopped()
|
|
{
|
|
_logger.LogInfo($"Task '{_model.Name}' stopped.");
|
|
}
|
|
|
|
private void OnEnterError()
|
|
{
|
|
_logger.LogError($"Task '{_model.Name}' entered error state. Last error: {_lastError?.Message}");
|
|
}
|
|
|
|
private void OnExitError()
|
|
{
|
|
_lastError = null;
|
|
}
|
|
|
|
|
|
#endregion
|
|
|
|
#region Public Control Methods
|
|
|
|
/// <summary>
|
|
/// Starts the task (transitions from Idle/Stopped/Error to Running).
|
|
/// </summary>
|
|
public void Start()
|
|
{
|
|
try
|
|
{
|
|
_stateMachine.Fire(TaskTrigger.Start);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError($"Failed to start task '{_model.Name}': {ex.Message}");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pauses the task (transitions from Running to Pausing → Paused).
|
|
/// </summary>
|
|
public void Pause()
|
|
{
|
|
try
|
|
{
|
|
_stateMachine.Fire(TaskTrigger.Pause);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError($"Failed to pause task '{_model.Name}': {ex.Message}");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resumes the task (transitions from Paused to Resuming → Running).
|
|
/// </summary>
|
|
public void Resume()
|
|
{
|
|
try
|
|
{
|
|
_stateMachine.Fire(TaskTrigger.Resume);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError($"Failed to resume task '{_model.Name}': {ex.Message}");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops the task (transitions from Running/Paused to Stopping → Stopped).
|
|
/// </summary>
|
|
public void Stop()
|
|
{
|
|
try
|
|
{
|
|
_stateMachine.Fire(TaskTrigger.Stop);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError($"Failed to stop task '{_model.Name}': {ex.Message}");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Task Execution
|
|
|
|
private async Task WaitForExecutionComplete()
|
|
{
|
|
// Wait for current execution to complete (max 30 seconds)
|
|
// Only timeout if task is actually executing (ScriptRunner running)
|
|
var timeout = TimeSpan.FromSeconds(30);
|
|
var startTime = DateTime.UtcNow;
|
|
|
|
while (_isExecuting && (DateTime.UtcNow - startTime) < timeout)
|
|
{
|
|
await Task.Delay(100);
|
|
}
|
|
|
|
if (_isExecuting)
|
|
{
|
|
// Task is still executing - ScriptRunner is blocking
|
|
_logger.LogWarning($"Task '{_model.Name}' execution timeout while waiting for completion. ScriptRunner may be blocking.");
|
|
}
|
|
// If task is not executing, it's effectively stopped (even if state machine didn't transition)
|
|
// No need to log - proceed silently
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region IDisposable
|
|
|
|
/// <summary>
|
|
/// Disposes the task. Can be called from any state.
|
|
/// </summary>
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_lockObject)
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
_disposed = true;
|
|
}
|
|
|
|
// Stop the task if it's running
|
|
try
|
|
{
|
|
if (_currentState == ScriptTaskState.Running ||
|
|
_currentState == ScriptTaskState.Paused ||
|
|
_currentState == ScriptTaskState.Pausing ||
|
|
_currentState == ScriptTaskState.Resuming)
|
|
{
|
|
_stateMachine.Fire(TaskTrigger.Stop);
|
|
// Wait a bit for stopping to complete
|
|
var stopTimeout = TimeSpan.FromSeconds(2);
|
|
var startTime = DateTime.UtcNow;
|
|
while ((_currentState == ScriptTaskState.Running ||
|
|
_currentState == ScriptTaskState.Paused ||
|
|
_currentState == ScriptTaskState.Pausing ||
|
|
_currentState == ScriptTaskState.Resuming ||
|
|
_currentState == ScriptTaskState.Stopping) &&
|
|
(DateTime.UtcNow - startTime) < stopTimeout)
|
|
{
|
|
Thread.Sleep(50);
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Ignore errors during stop
|
|
}
|
|
|
|
// Ensure timer thread is stopped
|
|
lock (_lockObject)
|
|
{
|
|
_timerThreadRunning = false;
|
|
}
|
|
|
|
// Wait for timer thread to finish (with timeout)
|
|
if (_timerThread != null && _timerThread.IsAlive)
|
|
{
|
|
if (!_timerThread.Join(TimeSpan.FromSeconds(2)))
|
|
{
|
|
_logger.LogWarning($"Task '{_model.Name}' timer thread did not stop within timeout during dispose.");
|
|
}
|
|
}
|
|
_timerThread = null;
|
|
|
|
// Wait for execution to complete
|
|
try
|
|
{
|
|
WaitForExecutionComplete().Wait(TimeSpan.FromSeconds(5));
|
|
}
|
|
catch
|
|
{
|
|
// Ignore errors
|
|
}
|
|
|
|
// Stop state machine
|
|
try
|
|
{
|
|
_stateMachine.Stop();
|
|
}
|
|
catch
|
|
{
|
|
// Ignore errors
|
|
}
|
|
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
#endregion
|
|
}
|