fix(site-runtime): resolve SiteRuntime-012,013,015,016 — doc accuracy, shared LoggerFactory, execution-actor coverage; SiteRuntime-014 deferred

This commit is contained in:
Joseph Doherty
2026-05-16 22:32:30 -04:00
parent b1ea78a9fd
commit dd7626da63
6 changed files with 404 additions and 18 deletions

View File

@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-16 | | Last reviewed | 2026-05-16 |
| Reviewer | claude-agent | | Reviewer | claude-agent |
| Commit reviewed | `9c60592` | | Commit reviewed | `9c60592` |
| Open findings | 5 | | Open findings | 0 |
## Summary ## Summary
@@ -521,7 +521,7 @@ literal/identifier non-detection, allowed-exception resolution); all 39 existing
|--|--| |--|--|
| Severity | Low | | Severity | Low |
| Category | Concurrency & thread safety | | Category | Concurrency & thread safety |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs:28` | | Location | `src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs:28` |
**Description** **Description**
@@ -543,7 +543,18 @@ internal-only and exposing only the async API.
**Resolution** **Resolution**
_Unresolved._ Resolved 2026-05-16 (`pending commit`): re-triaged against the current source — the
finding's own recommendation states the blocking is *acceptable* once SiteRuntime-009's
dedicated script dispatcher exists, and SiteRuntime-009 is now Resolved
(`ScriptExecutionActor`/`AlarmExecutionActor` run script bodies on the dedicated
`ScriptExecutionScheduler` threads, confirmed in `ScriptExecutionActor.cs:74`). A
blocked accessor therefore can no longer starve the shared `ThreadPool` or Akka
dispatchers — only a dedicated script thread. The remaining defect was the misleading
class XML comment, which only said "Reads block on the actor Ask" with no thread-model
context. The `AttributeAccessor` XML doc now documents the dispatcher containment
(SiteRuntime-009) explicitly and still steers authors to the async `GetAsync`/`SetAsync`
variants. No behavioural change — this is a documentation finding; existing
`ScopeAccessorTests` continue to pass.
### SiteRuntime-013 — `HandleUnsubscribeDebugView` does nothing despite documented behaviour ### SiteRuntime-013 — `HandleUnsubscribeDebugView` does nothing despite documented behaviour
@@ -551,7 +562,7 @@ _Unresolved._
|--|--| |--|--|
| Severity | Low | | Severity | Low |
| Category | Documentation & comments | | Category | Documentation & comments |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs:414` | | Location | `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs:414` |
**Description** **Description**
@@ -573,7 +584,18 @@ comment to state explicitly that subscription teardown is handled by
**Resolution** **Resolution**
_Unresolved._ Resolved 2026-05-16 (`pending commit`): root cause confirmed — the Instance Actor
holds no per-subscriber state, so `HandleUnsubscribeDebugView` genuinely has nothing to
remove; the real debug-stream subscription lifecycle lives in `SiteStreamManager`
(Subscribe/Unsubscribe/RemoveSubscriber). The recommendation's "correct the XML comment"
option was taken (removing the handler would still leave `UnsubscribeDebugViewRequest`
routed here from `DeploymentManagerActor.RouteDebugViewUnsubscribe`, and the no-op
acknowledgement is harmless). The XML doc on `HandleUnsubscribeDebugView` now states
explicitly that it is a deliberate no-op acknowledgement and that teardown is handled by
`SiteStreamManager`; the log message likewise notes "(no-op; subscription teardown
handled by SiteStreamManager)". This is a documentation-only finding with no observable
behaviour to regression-test, so no new test was added; the existing
`InstanceActor`/debug-view tests continue to pass.
### SiteRuntime-014 — Trigger-expression evaluation blocks the coordinator actor thread ### SiteRuntime-014 — Trigger-expression evaluation blocks the coordinator actor thread
@@ -581,7 +603,7 @@ _Unresolved._
|--|--| |--|--|
| Severity | Low | | Severity | Low |
| Category | Akka.NET conventions | | Category | Akka.NET conventions |
| Status | Open | | Status | Deferred |
| Location | `src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs:219`, `src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs:389` | | Location | `src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs:219`, `src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs:389` |
**Description** **Description**
@@ -605,7 +627,26 @@ without the Roslyn scripting `RunAsync` machinery.
**Resolution** **Resolution**
_Unresolved._ Deferred 2026-05-16 (`pending commit`): root cause confirmed — `EvaluateExpressionTrigger`
(ScriptActor) and `EvaluateExpression` (AlarmActor) call
`_compiledTriggerExpression.RunAsync(...).GetAwaiter().GetResult()` directly inside the
`AttributeValueChanged` handler, on the coordinator actor's default (thread-pool-backed)
dispatcher, blocking the mailbox for up to the 2 s timeout. Re-triaged from Open to
**Deferred** rather than fixed: neither recommended fix stays cleanly in-module without
a design decision. (a) **Off-thread eval + pipe-back** changes the actor's concurrency
model — the evaluation carries edge-tracking state (`_lastExpressionResult`) and a
mutable `_attributeSnapshot`; multiple `AttributeValueChanged` messages can arrive while
an evaluation is in flight, so a correct fix must decide overlapping-evaluation
semantics (coalesce / serialize / drop) and the snapshot-coherence contract — a behaviour
change to the trigger model. (b) **Pre-compile to a plain delegate** would require
changing the compilation contract: the trigger expression is produced as a Roslyn
`Script<object?>` by `ScriptCompilationService.CompileTriggerExpression`, which is also
the security boundary (SiteRuntime-011 trust validation); swapping the artifact type is
a cross-component change touching the Template Engine / Deployment Manager compile
pipeline. Given Low severity, a bounded 2 s worst case, and the inline note that trigger
expressions are trusted, compile-checked, and expected to be cheap, this is left
Deferred pending a design decision on trigger-evaluation concurrency rather than forcing
an out-of-scope or messaging-contract-changing fix.
### SiteRuntime-015 — `LoggerFactory` created per Instance Actor and never disposed ### SiteRuntime-015 — `LoggerFactory` created per Instance Actor and never disposed
@@ -613,7 +654,7 @@ _Unresolved._
|--|--| |--|--|
| Severity | Low | | Severity | Low |
| Category | Performance & resource management | | Category | Performance & resource management |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:746` | | Location | `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:746` |
**Description** **Description**
@@ -634,7 +675,18 @@ up per child. Do not create a fresh `LoggerFactory` in a hot creation path.
**Resolution** **Resolution**
_Unresolved._ Resolved 2026-05-16 (`pending commit`): root cause confirmed — `CreateInstanceActor`
did `new LoggerFactory()` per Instance Actor, never disposed, and detached from the
host's logging providers. `DeploymentManagerActor` now holds a single `_loggerFactory`
field, resolved once in the constructor from (in order) a new optional `ILoggerFactory`
constructor parameter, the injected `IServiceProvider`, or `NullLoggerFactory.Instance`
as a last resort — never a per-instance allocation. `CreateInstanceActor` mints the
`InstanceActor` logger from this shared factory, so loggers are routed through the
application's configured providers and no factory leaks. Regression test:
`DeploymentManagerLoggerFactoryTests.CreateInstanceActor_ReusesInjectedLoggerFactory_ForEveryInstance`
injects a counting `ILoggerFactory` and asserts it is used once per created Instance
Actor — confirmed to fail (0 calls) against the pre-fix `new LoggerFactory()` code and
pass after the fix.
### SiteRuntime-016 — Short-lived execution actors, replication actor, and repositories are untested ### SiteRuntime-016 — Short-lived execution actors, replication actor, and repositories are untested
@@ -642,7 +694,7 @@ _Unresolved._
|--|--| |--|--|
| Severity | Low | | Severity | Low |
| Category | Testing coverage | | Category | Testing coverage |
| Status | Open | | Status | Resolved |
| Location | `tests/ScadaLink.SiteRuntime.Tests/` | | Location | `tests/ScadaLink.SiteRuntime.Tests/` |
**Description** **Description**
@@ -666,4 +718,18 @@ synthetic-ID lookup, missing-row behaviour).
**Resolution** **Resolution**
_Unresolved._ Resolved 2026-05-16 (`pending commit`): re-triaged against the current test sources —
`SiteExternalSystemRepository` and `SiteNotificationRepository` are already covered by
`Repositories/SiteRepositoryTests.cs` (added when SiteRuntime-006/007 were resolved:
round-trip read and synthetic-ID-stable-across-restart). The execution-actor gap is now
closed by a new `Actors/ExecutionActorTests.cs` — six tests covering
`ScriptExecutionActor` (success → `ScriptCallResult` reply + PoisonPill self-stop;
script-throws → failure reply + stop; cooperative timeout → failure reply + stop;
no-`replyTo` fire-and-forget still self-stops) and `AlarmExecutionActor` (success →
self-stop; on-trigger throws → still self-stops). `SiteReplicationActor` is *not* covered
here: it depends on `Cluster.Get(Context.System)` and so requires a clustered
`ActorSystem` HOCON harness that does not yet exist in this test project — adding that
harness is a larger test-infrastructure task tracked separately and out of scope for a
Low-severity coverage finding; the highest-value untested paths the finding called out
(script timeout/failure/reply/self-stop) are now covered. Full module suite: 192 tests
green.

View File

@@ -34,6 +34,14 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
private readonly SiteStreamManager? _streamManager; private readonly SiteStreamManager? _streamManager;
private readonly SiteRuntimeOptions _options; private readonly SiteRuntimeOptions _options;
private readonly ILogger<DeploymentManagerActor> _logger; private readonly ILogger<DeploymentManagerActor> _logger;
/// <summary>
/// Shared logger factory used to mint <see cref="InstanceActor"/> loggers
/// (SiteRuntime-015). Reused across every <see cref="CreateInstanceActor"/>
/// call rather than newing a per-instance factory that is never disposed.
/// When the host injects its configured factory the Instance Actor logs are
/// routed through the application's logging providers.
/// </summary>
private readonly ILoggerFactory _loggerFactory;
private readonly IActorRef? _dclManager; private readonly IActorRef? _dclManager;
private readonly IActorRef? _replicationActor; private readonly IActorRef? _replicationActor;
private readonly ISiteHealthCollector? _healthCollector; private readonly ISiteHealthCollector? _healthCollector;
@@ -59,7 +67,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
IActorRef? dclManager = null, IActorRef? dclManager = null,
IActorRef? replicationActor = null, IActorRef? replicationActor = null,
ISiteHealthCollector? healthCollector = null, ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null) IServiceProvider? serviceProvider = null,
ILoggerFactory? loggerFactory = null)
{ {
_storage = storage; _storage = storage;
_compilationService = compilationService; _compilationService = compilationService;
@@ -71,6 +80,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
_healthCollector = healthCollector; _healthCollector = healthCollector;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_logger = logger; _logger = logger;
// SiteRuntime-015: reuse a single logger factory for all Instance Actors.
// Prefer an explicitly injected factory, fall back to one resolved from
// the service provider, and only as a last resort use NullLoggerFactory —
// never a per-instance `new LoggerFactory()` that leaks undisposed.
_loggerFactory = loggerFactory
?? serviceProvider?.GetService(typeof(ILoggerFactory)) as ILoggerFactory
?? Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance;
// Lifecycle commands // Lifecycle commands
Receive<DeployInstanceCommand>(HandleDeploy); Receive<DeployInstanceCommand>(HandleDeploy);
@@ -942,7 +958,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
return; return;
} }
var loggerFactory = new LoggerFactory(); // SiteRuntime-015: reuse the shared, host-configured logger factory
// instead of allocating (and leaking) a fresh LoggerFactory per instance.
var props = Props.Create(() => new InstanceActor( var props = Props.Create(() => new InstanceActor(
instanceName, instanceName,
configJson, configJson,
@@ -951,7 +968,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
_sharedScriptLibrary, _sharedScriptLibrary,
_streamManager, _streamManager,
_options, _options,
loggerFactory.CreateLogger<InstanceActor>(), _loggerFactory.CreateLogger<InstanceActor>(),
_dclManager, _dclManager,
_healthCollector, _healthCollector,
_serviceProvider)); _serviceProvider));

View File

@@ -494,12 +494,19 @@ public class InstanceActor : ReceiveActor
} }
/// <summary> /// <summary>
/// WP-25: Debug view unsubscribe — removes subscription. /// WP-25: Debug view unsubscribe (SiteRuntime-013).
/// This handler is a deliberate no-op acknowledgement: the Instance Actor holds
/// no per-subscriber state. The real debug-stream subscription lifecycle lives in
/// <see cref="ScadaLink.SiteRuntime.Streaming.SiteStreamManager"/>
/// (Subscribe / Unsubscribe / RemoveSubscriber); the gRPC stream is torn down
/// there when the central side cancels the call. Nothing is removed here.
/// </summary> /// </summary>
private void HandleUnsubscribeDebugView(UnsubscribeDebugViewRequest request) private void HandleUnsubscribeDebugView(UnsubscribeDebugViewRequest request)
{ {
// No subscription state in the Instance Actor — see the XML doc above.
_logger.LogDebug( _logger.LogDebug(
"Debug view unsubscribe for {Instance}, correlationId={Id}", "Debug view unsubscribe for {Instance}, correlationId={Id} " +
"(no-op; subscription teardown handled by SiteStreamManager)",
_instanceUniqueName, request.CorrelationId); _instanceUniqueName, request.CorrelationId);
} }

View File

@@ -4,8 +4,18 @@ namespace ScadaLink.SiteRuntime.Scripts;
/// Scope-aware view onto the instance's attributes, anchored at a path prefix. /// Scope-aware view onto the instance's attributes, anchored at a path prefix.
/// <c>Attributes["X"]</c> on the root scope resolves to canonical name "X"; /// <c>Attributes["X"]</c> on the root scope resolves to canonical name "X";
/// on a composition with prefix "TempSensor" it resolves to "TempSensor.X". /// on a composition with prefix "TempSensor" it resolves to "TempSensor.X".
/// Reads block on the actor Ask; async variants are provided for callers ///
/// that prefer to await explicitly. /// <para>
/// Thread-model note (SiteRuntime-012): the indexer get/set block synchronously
/// on the Instance Actor Ask (and, for data-connected attributes, the DCL
/// round-trip). This is safe because script bodies execute on the dedicated
/// <see cref="ScriptExecutionScheduler"/> threads (SiteRuntime-009), not the
/// shared <see cref="System.Threading.ThreadPool"/> — so a blocked accessor
/// cannot starve unrelated Akka dispatchers or HTTP request handling. The async
/// variants (<see cref="GetAsync"/>/<see cref="SetAsync"/>) are still preferred
/// where the script can await, as they avoid holding a dedicated thread idle for
/// the duration of each round-trip.
/// </para>
/// </summary> /// </summary>
public class AttributeAccessor public class AttributeAccessor
{ {

View File

@@ -0,0 +1,115 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Messages.Deployment;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Commons.Types.Flattening;
using ScadaLink.SiteRuntime.Actors;
using ScadaLink.SiteRuntime.Persistence;
using ScadaLink.SiteRuntime.Scripts;
using System.Text.Json;
namespace ScadaLink.SiteRuntime.Tests.Actors;
/// <summary>
/// Regression test for SiteRuntime-015 — <see cref="DeploymentManagerActor"/> must
/// reuse a single, injected <see cref="ILoggerFactory"/> for every Instance Actor it
/// creates rather than newing (and leaking) a fresh <see cref="LoggerFactory"/> per
/// instance.
/// </summary>
public class DeploymentManagerLoggerFactoryTests : TestKit, IDisposable
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly string _dbFile;
public DeploymentManagerLoggerFactoryTests()
{
_dbFile = Path.Combine(Path.GetTempPath(), $"dm-loggerfactory-test-{Guid.NewGuid():N}.db");
_storage = new SiteStorageService(
$"Data Source={_dbFile}",
NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
void IDisposable.Dispose()
{
Shutdown();
try { File.Delete(_dbFile); } catch { /* cleanup */ }
}
private static string MakeConfigJson(string instanceName)
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "1", DataType = "Int32" }
]
};
return JsonSerializer.Serialize(config);
}
/// <summary>
/// Counts <see cref="ILoggerFactory.CreateLogger"/> calls and records whether
/// the factory was disposed. A passing test proves the single injected factory
/// is the one used for every Instance Actor.
/// </summary>
private sealed class CountingLoggerFactory : ILoggerFactory
{
public int CreateLoggerCalls;
public bool Disposed;
public ILogger CreateLogger(string categoryName)
{
Interlocked.Increment(ref CreateLoggerCalls);
return NullLogger.Instance;
}
public void AddProvider(ILoggerProvider provider) { }
public void Dispose() => Disposed = true;
}
[Fact]
public async Task CreateInstanceActor_ReusesInjectedLoggerFactory_ForEveryInstance()
{
// Pre-populate several enabled instances so startup creates multiple
// Instance Actors.
const int instanceCount = 6;
for (int i = 0; i < instanceCount; i++)
{
var name = $"Inst{i}";
await _storage.StoreDeployedConfigAsync(name, MakeConfigJson(name), $"d{i}", $"h{i}", true);
}
var loggerFactory = new CountingLoggerFactory();
var actor = ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage,
_compilationService,
_sharedScriptLibrary,
null,
new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 5 },
NullLogger<DeploymentManagerActor>.Instance,
null,
null,
null,
null,
loggerFactory)));
// Allow async startup (load configs + staggered creation).
await Task.Delay(2000);
// Every Instance Actor logger must come from the single injected factory.
// Before the fix, each CreateInstanceActor allocated its own LoggerFactory,
// so the injected factory would never be touched (CreateLoggerCalls == 0).
Assert.Equal(instanceCount, loggerFactory.CreateLoggerCalls);
}
}

View File

@@ -0,0 +1,171 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Commons.Types.Scripts;
using ScadaLink.SiteRuntime.Actors;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.SiteRuntime.Tests.Actors;
/// <summary>
/// Regression coverage for SiteRuntime-016 — the short-lived execution actors
/// (<see cref="ScriptExecutionActor"/>, <see cref="AlarmExecutionActor"/>) were
/// previously untested. Covers success, exception, timeout, Ask-reply, and the
/// PoisonPill self-stop after completion.
/// </summary>
public class ExecutionActorTests : TestKit, IDisposable
{
private readonly SharedScriptLibrary _sharedLibrary;
private readonly ScriptCompilationService _compilationService;
public ExecutionActorTests()
{
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
void IDisposable.Dispose() => Shutdown();
private static Script<object?> CompileScript(string code)
{
var scriptOptions = ScriptOptions.Default
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
var script = CSharpScript.Create<object?>(code, scriptOptions, typeof(ScriptGlobals));
script.Compile();
return script;
}
private static SiteRuntimeOptions Options(int timeoutSeconds = 30)
=> new() { MaxScriptCallDepth = 10, ScriptExecutionTimeoutSeconds = timeoutSeconds };
// ── ScriptExecutionActor ──
[Fact]
public void ScriptExecutionActor_Success_RepliesWithResultAndStops()
{
var compiled = CompileScript("return 7 * 6;");
var replyTo = CreateTestProbe();
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
"Answer", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, Options(),
replyTo.Ref, "corr-1", NullLogger.Instance,
ScriptScope.Root, null, null)));
Watch(exec);
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.True(result.Success);
Assert.Equal("corr-1", result.CorrelationId);
Assert.Equal(42, result.ReturnValue);
// The actor must PoisonPill itself once execution completes.
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
[Fact]
public void ScriptExecutionActor_ScriptThrows_RepliesFailureAndStops()
{
var compiled = CompileScript("throw new InvalidOperationException(\"boom\");");
var replyTo = CreateTestProbe();
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
"Bad", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, Options(),
replyTo.Ref, "corr-2", NullLogger.Instance,
ScriptScope.Root, null, null)));
Watch(exec);
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.Equal("corr-2", result.CorrelationId);
Assert.Contains("boom", result.ErrorMessage);
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
[Fact]
public void ScriptExecutionActor_Timeout_RepliesFailureAndStops()
{
// A long busy loop that observes the cancellation token so the
// 1-second timeout fires cooperatively.
var compiled = CompileScript(
"while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
var replyTo = CreateTestProbe();
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
"Slow", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1),
replyTo.Ref, "corr-3", NullLogger.Instance,
ScriptScope.Root, null, null)));
Watch(exec);
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.Contains("timed out", result.ErrorMessage);
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
[Fact]
public void ScriptExecutionActor_NoReplyTo_StillStopsAfterCompletion()
{
var compiled = CompileScript("return 1;");
var instanceActor = CreateTestProbe();
// ActorRefs.Nobody as replyTo — fire-and-forget execution.
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
"FireForget", "Inst1", compiled, null, 0,
instanceActor.Ref, _sharedLibrary, Options(),
ActorRefs.Nobody, "corr-4", NullLogger.Instance,
ScriptScope.Root, null, null)));
Watch(exec);
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
// ── AlarmExecutionActor ──
[Fact]
public void AlarmExecutionActor_Success_StopsAfterCompletion()
{
var compiled = CompileScript("return 0;");
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
"HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
compiled, instanceActor.Ref, _sharedLibrary, Options(),
NullLogger.Instance)));
Watch(exec);
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
[Fact]
public void AlarmExecutionActor_ScriptThrows_StillStops()
{
var compiled = CompileScript("throw new System.Exception(\"alarm-boom\");");
var instanceActor = CreateTestProbe();
var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
"HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
compiled, instanceActor.Ref, _sharedLibrary, Options(),
NullLogger.Instance)));
Watch(exec);
// Even on a throwing on-trigger body, the actor must self-stop.
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
}
}