Files
Joseph Doherty 1ead8f458a
v2-ci / unit-tests (push) Waiting to run
feat(scripting): self-contained script worker publish, docker-dev drill, §8 perf harness (lmxopcua#780 P1)
- A self-contained Host publish (-r win-x64 --self-contained, the Windows installs) now publishes the
  ScriptWorker self-contained for the same RID into script-worker/. The launcher starts its apphost
  directly when runtimeconfig.json lists includedFrameworks. A framework-dependent worker (docker-dev)
  keeps running through the dotnet muxer.
- docker-dev/docker-compose.script-runner.yml: OutOfProcess for the central pair's VT + Calc seams.
  Live drill recorded in design §12.1 (runaway kill + quarantine, kill -9 recovery, open circuit
  with /health/ready still 200, metrics).
- ScriptRunnerPerfTests (Category=Perf, opt-in via OTOPCUA_SCRIPT_PERF=1): InProcess vs OutOfProcess
  at K=1/2/4. The accepted 5k evals/s/node gate passes on macOS and windev. The host-CPU, RSS and
  Windows latency budget misses are tracked as #789.
- Fix: the single-flight slot is released before the awaiting caller resumes. Before, a caller that
  started its next run right after the previous one returned could be refused with "previous
  evaluation still running", which the seams publish as Bad.
- Fix: the worker compiles on dedicated threads, not the thread pool. A burst of cold loads starved
  the heartbeat, and the host killed the shard (found on windev). Load-timeout queueing is #790.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 02:53:53 -05:00

106 lines
5.4 KiB
C#

using System.Diagnostics;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
namespace ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests;
/// <summary>
/// lmxopcua#780: the Windows installs have no .NET runtime, so a self-contained Host publish ships a self-contained
/// worker that the launcher must start through its apphost, while a framework-dependent worker (docker-dev, the
/// tests) keeps going through the dotnet muxer.
/// </summary>
[Collection(ScriptWorkerCollection.Name)]
[Trait("Category", "ScriptWorker")]
public sealed class WorkerEntryTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private const string SelfContainedConfig = """
{ "runtimeOptions": { "tfm": "net10.0", "includedFrameworks": [ { "name": "Microsoft.NETCore.App", "version": "10.0.0" } ] } }
""";
private const string FrameworkDependentConfig = """
{ "runtimeOptions": { "tfm": "net10.0", "framework": { "name": "Microsoft.NETCore.App", "version": "10.0.0" } } }
""";
private static string ConfigPath(string folder) => Path.Combine(folder, "ZB.MOM.WW.OtOpcUa.ScriptWorker.runtimeconfig.json");
private static void Touch(string path) => File.WriteAllText(path, "");
[Fact]
public void A_self_contained_worker_is_started_through_its_apphost()
{
using var dir = new Scripts.TempDir();
Touch(Path.Combine(dir.Path, OutOfProcessScriptRunner.WorkerAppHostFileName));
Touch(Path.Combine(dir.Path, OutOfProcessScriptRunner.WorkerAssemblyFileName));
File.WriteAllText(ConfigPath(dir.Path), SelfContainedConfig);
OutOfProcessScriptRunner.IsSelfContainedWorker(dir.Path).ShouldBeTrue();
OutOfProcessScriptRunner.ResolveWorkerEntry(dir.Path)
.ShouldBe(Path.Combine(Path.GetFullPath(dir.Path), OutOfProcessScriptRunner.WorkerAppHostFileName));
}
[Fact]
public void A_framework_dependent_worker_keeps_the_muxer_even_with_an_apphost()
{
using var dir = new Scripts.TempDir();
Touch(Path.Combine(dir.Path, OutOfProcessScriptRunner.WorkerAppHostFileName));
Touch(Path.Combine(dir.Path, OutOfProcessScriptRunner.WorkerAssemblyFileName));
File.WriteAllText(ConfigPath(dir.Path), FrameworkDependentConfig);
OutOfProcessScriptRunner.IsSelfContainedWorker(dir.Path).ShouldBeFalse();
OutOfProcessScriptRunner.ResolveWorkerEntry(dir.Path).ShouldEndWith(OutOfProcessScriptRunner.WorkerAssemblyFileName);
}
[Theory]
[InlineData(true, null)] // self-contained config but no apphost on disk
[InlineData(false, "{ broken")] // apphost present, unreadable config
[InlineData(false, null)] // apphost present, no config at all
public void Anything_short_of_a_self_contained_apphost_falls_back_to_the_entry_assembly(bool omitAppHost, string? config)
{
using var dir = new Scripts.TempDir();
if (!omitAppHost) Touch(Path.Combine(dir.Path, OutOfProcessScriptRunner.WorkerAppHostFileName));
if (omitAppHost) File.WriteAllText(ConfigPath(dir.Path), SelfContainedConfig);
else if (config is not null) File.WriteAllText(ConfigPath(dir.Path), config);
OutOfProcessScriptRunner.ResolveWorkerEntry(dir.Path).ShouldEndWith(OutOfProcessScriptRunner.WorkerAssemblyFileName);
}
[Fact]
public void An_explicit_executable_path_wins()
{
var path = Path.Combine(Path.GetTempPath(), "custom", "worker.exe");
OutOfProcessScriptRunner.ResolveWorkerPath(new ScriptWorkerOptions { ExecutablePath = path }).ShouldBe(Path.GetFullPath(path));
}
/// <summary>The worker built beside the tests is framework-dependent, so the default stays the muxer path.</summary>
[Fact]
public void The_built_worker_resolves_to_the_entry_assembly()
{
OutOfProcessScriptRunner.ResolveWorkerPath(new ScriptWorkerOptions())
.ShouldEndWith(Path.Combine(OutOfProcessScriptRunner.WorkerFolderName, OutOfProcessScriptRunner.WorkerAssemblyFileName));
}
/// <summary>
/// Runs only when <see cref="Scripts.WorkerUnderTestVariable"/> names a self-contained worker's apphost (the
/// windev gate over a <c>-r win-x64 --self-contained</c> Host publish): the worker process IS the apphost — no
/// dotnet muxer anywhere — and it evaluates.
/// </summary>
[Fact]
public async Task A_published_self_contained_worker_runs_as_its_own_process()
{
var path = Scripts.WorkerUnderTest;
if (path is null || !path.EndsWith(OutOfProcessScriptRunner.WorkerAppHostFileName, StringComparison.OrdinalIgnoreCase))
Assert.Skip($"set {Scripts.WorkerUnderTestVariable} to a self-contained worker apphost to run this gate");
OutOfProcessScriptRunner.IsSelfContainedWorker(Path.GetDirectoryName(path)!).ShouldBeTrue("the published folder is self-contained");
OutOfProcessScriptRunner.ResolveWorkerEntry(Path.GetDirectoryName(path)!).ShouldBe(Path.GetFullPath(path));
await using var runner = Scripts.Runner(Scripts.Options(workerPath: path));
(await runner.RunAsync(Scripts.Vt("vt", "return System.Math.Abs(-3);"), Ct)).Value.ShouldBe(3);
using var worker = Process.GetProcessById(runner.Shards[0].Current!.Pid);
Path.GetFullPath(worker.MainModule!.FileName).ShouldBe(Path.GetFullPath(path), StringCompareShould.IgnoreCase);
}
}