2ed5c6c379
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).
87 lines
3.4 KiB
C#
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());
|
|
}
|
|
}
|