Files
ScadaBridge/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/SandboxConsoleCaptureTests.cs
T
Joseph Doherty 2ed5c6c379 fix(concurrency/lifetime): close Theme 5 — 10 concurrency / DI / scope findings
Concurrency hazards, DI lifetime hygiene, and one verify-only confirmation
across 8 modules. Highlights:

Concurrency:
- CentralUI-030: SandboxConsoleCapture writes routed through WriteSynchronized
  locking on the captured StringWriter — intra-script Task fan-out can no
  longer corrupt the per-call buffer.
- Commons-021: ExternalCallResult.Response now backed by Lazy<dynamic?>
  (ExecutionAndPublication) — no more benign double-parse race.
- CD-017: DeploymentManagerRepository.DeleteDeploymentRecordAsync now takes
  an expected RowVersion and seeds entry.OriginalValues so EF emits
  DELETE ... WHERE Id=@id AND RowVersion=@prior; stale RowVersion now
  throws DbUpdateConcurrencyException instead of silent overwrite.
- Transport-009: AuditCorrelationContext.BundleImportId backed by
  AsyncLocal<Guid?> so concurrent imports get per-logical-call isolation
  (was a scoped instance shared via AuditService across runs).

DI / lifetime:
- AuditLog-003: All 3 AuditLog actor handlers switched to CreateAsyncScope
  + await using — async EF disposal no longer swallowed.
- AuditLog-007: INodeIdentityProvider resolution standardised on
  GetRequiredService<>() (was mixed with GetService<>()).
- AuditLog-011: AddAuditLogHealthMetricsBridge guarded by sentinel
  descriptor check — calling twice no longer double-registers the hosted
  service.

Shutdown / supervision:
- SiteCallAudit-002: AkkaHostedService adds a CoordinatedShutdown
  cluster-leave task (drain-site-call-audit-singleton) that issues a
  bounded GracefulStop(10s) so failover waits for in-flight upserts.

Registration safety:
- NS-020: AkkaHostedService now guards NotificationForwarder S&F
  registration with _notificationDeliveryHandlerRegistered + throws
  InvalidOperationException on double-register to make the regression loud.

VERIFY-only closures:
- NotifOutbox-005: Confirmed already closed by CD-015 fix (ac96b83) —
  NotificationOutboxRepository.InsertIfNotExistsAsync uses the same
  raw-SQL IF NOT EXISTS + 2601/2627 swallow pattern; race eliminated.

5+ new regression tests (CentralUI sandbox WhenAll, ExternalCallResult
64-reader Barrier, AuditLog DI idempotency, RowVersion stale-throw,
SiteCallAudit-002 shutdown drain). Build clean; affected suites all green.
README regenerated: 65 open (was 75).
2026-05-28 07:29:41 -04:00

87 lines
3.4 KiB
C#

using ScadaLink.CentralUI.ScriptAnalysis;
namespace ScadaLink.CentralUI.Tests.ScriptAnalysis;
/// <summary>
/// Regression tests for the <c>SandboxConsoleCapture</c> writer that the Test Run
/// sandbox installs on <c>Console.Out</c>/<c>Console.Error</c>. CentralUI-030
/// surfaced an intra-script concurrency hazard: a sandboxed script can fan out
/// work with <c>Task.WhenAll</c> / <c>Task.Run</c> and every child task inherits
/// the capture <c>StringWriter</c> via <c>AsyncLocal</c>; <c>StringWriter</c> is
/// not thread-safe, so concurrent writes could corrupt the buffer. These tests
/// drive the writer the same way Roslyn-hosted user code does.
/// </summary>
public class SandboxConsoleCaptureTests
{
/// <summary>
/// CentralUI-030: a capture scope shared across <c>Task.WhenAll</c> child
/// tasks must serialise writes so the resulting transcript contains exactly
/// the expected number of lines without character-level interleaving.
/// </summary>
[Fact]
public async Task BeginCapture_ConcurrentWritesFromTasks_DoNotCorruptBuffer()
{
// The static install routes Console.Out through the singleton sandbox
// capture writer for the test process — this is idempotent and matches
// the way ScriptAnalysisService bootstraps the sandbox in production.
var (capture, _) = SandboxConsoleCapture.Install();
var buffer = new StringWriter();
const int taskCount = 32;
const int linesPerTask = 50;
const int expectedLines = taskCount * linesPerTask;
using (capture.BeginCapture(buffer))
{
// AsyncLocal flows the capture scope into each Task.Run, mirroring
// a sandboxed script doing `await Task.WhenAll(...)` over Tasks
// that each `Console.WriteLine`.
var tasks = Enumerable.Range(0, taskCount).Select(i => Task.Run(() =>
{
for (var j = 0; j < linesPerTask; j++)
{
Console.WriteLine($"task-{i}-line-{j}");
}
}));
await Task.WhenAll(tasks);
}
var captured = buffer.ToString();
// Without the lock, concurrent StringWriter.WriteLine can drop or
// interleave characters and produce malformed lines / a wrong count.
// We assert the exact line count and that every emitted token is
// present on a line of its own — both fail under the unprotected
// implementation.
var lines = captured.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(expectedLines, lines.Length);
for (var i = 0; i < taskCount; i++)
{
for (var j = 0; j < linesPerTask; j++)
{
Assert.Contains($"task-{i}-line-{j}", lines);
}
}
}
/// <summary>
/// Sanity check: the most basic capture happy-path still works after the
/// CentralUI-030 lock was introduced.
/// </summary>
[Fact]
public void BeginCapture_SingleThreadedWrites_AreCaptured()
{
var (capture, _) = SandboxConsoleCapture.Install();
var buffer = new StringWriter();
using (capture.BeginCapture(buffer))
{
Console.WriteLine("hello");
Console.Write("world");
}
Assert.Contains("hello", buffer.ToString());
Assert.Contains("world", buffer.ToString());
}
}