Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions Docs/architecture/error-reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,44 @@ survives in the log through `CsvService`'s existing `_logger.LogWarning(..., ex.
coordinator log joiner reads only the top-level `.Message` and does not walk `CausedBy`, so the localized
headline is what the operator reads while the exception rides the cause for future consumers).

Still free-text (deferred to a later wave): PLC, the style-editor, and the five formula-internal free-text inners (null expression,
Still free-text (deferred to a later wave): the style-editor and the five formula-internal free-text inners (null expression,
evaluation exception, non-finite, Int32/float overflow), which stay English under `ru` because their inner
is wrapped in a plain `new Error(text)`.
is wrapped in a plain `new Error(text)`. (PLC is now typed — see "PLC faults localize by type" below —
except the two deliberately-deferred producers noted there.)

### PLC faults localize by type

The PLC operator-facing producer surface now raises **typed** errors, so a sync failure localizes by type
on the fault channel the same way recipe failures do on the panel. Seven typed faults cover it:
`ConnectionLostError` and `NotConnectedError` (connection), `ProtocolVersionMismatchError` (version),
`RecipeActiveError` (a sync attempted while the PLC executes the recipe), `WriteVerificationFailedError(attempts)`
(the write-back read-verify mismatch), `RecipeNotCommittedError` (the managing-area commit check), and the
Rule-B envelope `PlcCommandFailedError`. `NotConnectedError`/`ProtocolVersionMismatchError` live in
`SemiStep.Core/Plc/S7/Protocol/`; the five sync-side faults in `SemiStep.Core/Plc/Sync/`.

**Un-laundering the fault seam.** `PlcSyncExecutor` used to re-wrap a typed inner's `.Message` in a fresh
`new Error(...)` before emitting the fault, discarding the type so `OnPlcFault`'s single-`IError` seam could
not localize it. It now forwards the typed inner directly (`reportFault(activeResult.Errors[0])`,
`reportFault(writeResult.Errors[0])`), so the type reaches `ReasonLocalizer` and the fault renders in the
current culture. The 6a fault seam is single-error by design: forwarding `Errors[0]` drops any sibling
errors, which the transient one-slot operation channel could not show anyway.

**Rule-B envelope.** `PlcCommandFailedError` is a fieldless headline (`"PLC command failed"`): the raw
transport exception message (a socket/timeout string) is not operator-useful, so it rides `.CausedBy(ex)`
into the result chain and a `_logger.Log(ex, …)` line at the catch (writes at `LogError`, reads at
`LogWarning`) into the diagnostics log, while the localized headline is what the operator reads. Same shape
as `RecipeLoadFailedError`. `PlcTransactionExecutor`'s five read/write catches raise it; the exception type
survives on `Reasons` for future consumers.

**Producers that stay English by design (deferred):** `PlcTransactionExecutor`'s short
protocol-version read (`"…returned {n} bytes, expected {m}"`) is a malformed-wire diagnostic edge, not an
operator-actionable fault; `PlcLifecycleManager`'s enable catch is a connect/cancellation concern that
belongs with the cancellation pass (slice 6c). A third: the codec decode failures (`ExecutionStateCodec`
and `ManagingAreaCodec` return a plain `new Error("…data length …")` on a short read) are still untyped, so
when `PlcSyncExecutor` forwards them through `reportFault(activeResult.Errors[0])` a decode-Fail reaches the
panel as raw English. This is a malformed-wire diagnostic edge like the short-version read, not an
operator-actionable fault; typing the codec layer is out of scope for this wave. All three stay free-text
until then.

### The published rule (public error surface)

Expand Down Expand Up @@ -200,8 +235,10 @@ always English; only the panel seams localize.
- **PLC connection-loss / sync failures** arrive as a typed `IError` on `IPlcSyncService.Faults`, a
discrete one-shot channel. `RecipeCoordinator.OnPlcFault` bridges each fault to the operation channel
via `ReportFailure(error)` (the single-`IError` seam), so a typed fault localizes while the log keeps
the English `.Message`. `ConnectionLostError` is the first typed PLC fault; an untyped fault still
falls through to its English `.Message`. The persistent "disconnected" label stays in the status bar.
the English `.Message`. The whole PLC operator-facing fault surface is now typed (connection, version,
recipe-active, write-verification, not-committed, command — see "PLC faults localize by type"); an
untyped fault still falls through to its English `.Message`. The persistent "disconnected" label stays
in the status bar.
- **Command exceptions** (a `ReactiveCommand` fault on `ThrownExceptions`) are not `Result`-based;
they route through `ReportThrownExceptions` (see below) rather than a hand-written handler.
- **One deliberate exception** to the `"; "` idiom: the clipboard *paste* failure lists each error on
Expand Down
5 changes: 4 additions & 1 deletion SemiStep/SemiStep.Core/Plc/S7/Protocol/NotConnectedError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@

namespace SemiStep.Core.Plc.S7.Protocol;

internal sealed class NotConnectedError(string message) : Error(message);
public sealed class NotConnectedError()
: Error("Not connected to PLC")
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,10 @@

namespace SemiStep.Core.Plc.S7.Protocol;

internal sealed class ProtocolVersionMismatchError(int expected, int actual)
: Error($"PLC protocol version {actual} does not match expected {expected}");
public sealed class ProtocolVersionMismatchError(int expected, int actual)
: Error($"PLC protocol version {actual} does not match expected {expected}")
{
public int Expected { get; } = expected;

public int Actual { get; } = actual;
}
8 changes: 8 additions & 0 deletions SemiStep/SemiStep.Core/Plc/Sync/PlcCommandFailedError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using FluentResults;

namespace SemiStep.Core.Plc.Sync;

public sealed class PlcCommandFailedError()
: Error("PLC command failed")
{
}
7 changes: 6 additions & 1 deletion SemiStep/SemiStep.Core/Plc/Sync/PlcExecutionMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,12 @@ private async Task PollLoopAsync(CancellationToken ct)
return;
}

_logger.LogWarning("Execution monitor poll error: {Message}", result.Errors[0].Message);
if (!result.Errors.OfType<PlcCommandFailedError>().Any())
{
_logger.LogWarning(
"Execution monitor poll error: {Message}", result.Errors[0].Message);
}

continue;
}

Expand Down
20 changes: 9 additions & 11 deletions SemiStep/SemiStep.Core/Plc/Sync/PlcSyncExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ private void StartDebounce()
{
_logger.LogError(ex, "Unhandled exception in sync task");
setStatus(PlcSyncStatus.Failed);
reportFault(new Error(ex.Message));
reportFault(new PlcCommandFailedError().CausedBy(ex));
}
}, ct);
}
Expand Down Expand Up @@ -191,34 +191,31 @@ private async Task<Result> CheckCanSyncAsync(CancellationToken ct)
{
_logger.LogDebug("Skipping sync: not connected to PLC");

return Result.Fail("Not connected");
return Result.Fail(new NotConnectedError());
}

var activeResult = await transactionExecutor.IsRecipeActiveAsync(ct);
if (activeResult.IsFailed)
{
var isDisconnected = activeResult.Errors.OfType<NotConnectedError>().Any();
var faultMessage = isDisconnected
? "Not connected to PLC"
: activeResult.Errors[0].Message;
setStatus(PlcSyncStatus.Failed);
reportFault(new Error(faultMessage));
reportFault(activeResult.Errors[0]);

if (isDisconnected)
{
_logger.LogWarning("Sync blocked: not connected to PLC");
}

return Result.Fail(activeResult.Errors[0].Message);
return activeResult.ToResult();
}

if (activeResult.Value)
{
setStatus(PlcSyncStatus.Failed);
reportFault(new Error("Recipe is being executed on PLC"));
reportFault(new RecipeActiveError());
_logger.LogWarning("Sync blocked: recipe is being executed on PLC");

return Result.Fail("Recipe active");
return Result.Fail(new RecipeActiveError());
}

return Result.Ok();
Expand All @@ -232,8 +229,9 @@ private async Task WriteSyncAsync(Recipe recipe, CancellationToken ct)
if (writeResult.IsFailed)
{
setStatus(PlcSyncStatus.Failed);
reportFault(new Error(writeResult.Errors[0].Message));
if (!writeResult.Errors.OfType<NotConnectedError>().Any())
reportFault(writeResult.Errors[0]);
if (!writeResult.Errors.OfType<NotConnectedError>().Any()
&& !writeResult.Errors.OfType<PlcCommandFailedError>().Any())
{
_logger.LogError("Sync failed: {Message}", writeResult.Errors[0].Message);
}
Expand Down
27 changes: 16 additions & 11 deletions SemiStep/SemiStep.Core/Plc/Sync/PlcTransactionExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public async Task<Result<PlcRecipeData>> ReadRecipeDataAsync(CancellationToken c
{
if (!_transport.IsConnected)
{
return Result.Fail(new NotConnectedError("Not connected to PLC"));
return Result.Fail(new NotConnectedError());
}

try
Expand Down Expand Up @@ -102,15 +102,16 @@ public async Task<Result<PlcRecipeData>> ReadRecipeDataAsync(CancellationToken c
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return Result.Fail(ex.Message);
_logger.LogWarning(ex, "PLC recipe data read failed");
return Result.Fail(new PlcCommandFailedError().CausedBy(ex));
}
}

public async Task<Result> WriteRecipeWithRetryAsync(Recipe recipe, CancellationToken ct = default)
{
if (!_transport.IsConnected)
{
return Result.Fail(new NotConnectedError("Not connected to PLC"));
return Result.Fail(new NotConnectedError());
}

var dataResult = _converter.FromRecipe(recipe);
Expand Down Expand Up @@ -152,7 +153,7 @@ public async Task<Result> WriteRecipeWithRetryAsync(Recipe recipe, CancellationT
}

return Result.Fail(
$"Recipe write verification failed after {_protocolSettings.MaxRetryAttempts} attempts");
new WriteVerificationFailedError(_protocolSettings.MaxRetryAttempts));
}

public Task<Result<PlcManagingAreaState>> ReadManagingAreaAsync(CancellationToken ct = default)
Expand All @@ -168,7 +169,7 @@ public async Task<Result<int>> ReadProtocolVersionAsync(CancellationToken ct = d
{
if (!_transport.IsConnected)
{
return Result.Fail(new NotConnectedError("Not connected to PLC"));
return Result.Fail(new NotConnectedError());
}

try
Expand All @@ -188,7 +189,8 @@ public async Task<Result<int>> ReadProtocolVersionAsync(CancellationToken ct = d
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return Result.Fail(ex.Message);
_logger.LogWarning(ex, "PLC protocol version read failed");
return Result.Fail(new PlcCommandFailedError().CausedBy(ex));
}
}

Expand All @@ -202,7 +204,7 @@ public async Task<Result<Recipe>> ReadRecipeFromPlcAsync(CancellationToken ct =

if (!managingAreaResult.Value.Committed)
{
return Result.Fail("Recipe not committed on PLC");
return Result.Fail(new RecipeNotCommittedError());
}

var recipeDataResult = await ReadRecipeDataAsync(ct);
Expand All @@ -219,7 +221,7 @@ private async Task<Result<T>> ReadAndDecodeAsync<T>(
{
if (!_transport.IsConnected)
{
return Result.Fail(new NotConnectedError("Not connected to PLC"));
return Result.Fail(new NotConnectedError());
}

try
Expand All @@ -229,7 +231,8 @@ private async Task<Result<T>> ReadAndDecodeAsync<T>(
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return Result.Fail(ex.Message);
_logger.LogWarning(ex, "PLC read/decode failed for DB {DbNumber}", dbNumber);
return Result.Fail(new PlcCommandFailedError().CausedBy(ex));
}
}

Expand Down Expand Up @@ -264,7 +267,8 @@ private async Task<Result> WriteRecipeDataAsync(PlcRecipeData data, Cancellation
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return Result.Fail(ex.Message);
_logger.LogError(ex, "PLC recipe data write failed");
return Result.Fail(new PlcCommandFailedError().CausedBy(ex));
}
}

Expand Down Expand Up @@ -298,7 +302,8 @@ private async Task<Result> WriteManagingAreaAsync(ManagingAreaPcData data, Cance
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return Result.Fail(ex.Message);
_logger.LogError(ex, "PLC managing-area write failed");
return Result.Fail(new PlcCommandFailedError().CausedBy(ex));
}
}
}
8 changes: 8 additions & 0 deletions SemiStep/SemiStep.Core/Plc/Sync/RecipeActiveError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using FluentResults;

namespace SemiStep.Core.Plc.Sync;

public sealed class RecipeActiveError()
: Error("Recipe is being executed on PLC")
{
}
8 changes: 8 additions & 0 deletions SemiStep/SemiStep.Core/Plc/Sync/RecipeNotCommittedError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using FluentResults;

namespace SemiStep.Core.Plc.Sync;

public sealed class RecipeNotCommittedError()
: Error("Recipe not committed on PLC")
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using FluentResults;

namespace SemiStep.Core.Plc.Sync;

public sealed class WriteVerificationFailedError(int attempts)
: Error($"Recipe write verification failed after {attempts} attempts")
{
public int Attempts { get; } = attempts;
}
45 changes: 42 additions & 3 deletions SemiStep/SemiStep.Tests/S7/PlcSyncCoordinatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ public async Task NotifyRecipeChanged_IsValidTrue_Disconnected_DebounceAbortLeav
}

[Fact]
public async Task SyncWriteFailure_EmitsFaultCarryingMessage()
public async Task SyncWriteFailure_EmitsTypedFaultCarryingException()
{
var (coordinator, transport, _) = Build(connected: true);
const string FailureMessage = "transport write blew up";
Expand All @@ -344,8 +344,47 @@ await TestHelpers.WaitUntilAsync(
cancellationToken: TestContext.Current.CancellationToken);

faults.Should().ContainSingle("a sync-write failure must emit exactly one fault");
faults[0].Message.Should().Be(FailureMessage,
"the fault must carry the write-failure message so the user sees the cause");
faults[0].Should().BeOfType<PlcCommandFailedError>(
"the fault must carry the typed envelope so the panel localizes it by type");
faults[0].Reasons.OfType<ExceptionalError>()
.Should().ContainSingle("CausedBy must retain the underlying exception in the result chain")
.Which.Exception.Message.Should().Be(FailureMessage,
"the original exception rides CausedBy for the diagnostics log");
coordinator.Status.Should().Be(PlcSyncStatus.Failed);
}

[Fact]
public async Task SyncWhenRecipeActiveOnPlc_EmitsTypedRecipeActiveFault()
{
var (coordinator, transport, _) = Build(connected: true);

var layout = BuildTestConfiguration().Layout;

// Drive IsRecipeActiveAsync true: the execution-state DB read reports the recipe as active.
transport.SetReadResponseForDb(layout.ExecutionDb.DbNumber, (_, count) =>
{
var bytes = new byte[count];
bytes[layout.ExecutionDb.RecipeActiveOffset] = 1;
return bytes;
});

var faults = new List<IError>();
using var subscription = coordinator.Faults.Subscribe(faults.Add);

coordinator.NotifyRecipeChanged(Recipe.Empty, isValid: true);
await coordinator.WaitForPendingSyncAsync(TestContext.Current.CancellationToken);

await TestHelpers.WaitUntilAsync(
() => faults.Count > 0,
timeout: TimeSpan.FromMilliseconds(2500),
pollInterval: TimeSpan.FromMilliseconds(20),
cancellationToken: TestContext.Current.CancellationToken);

faults.Should().ContainSingle("a sync blocked by an active recipe must emit exactly one fault");
faults[0].Should().BeOfType<RecipeActiveError>(
"the fault must carry the typed error so the panel localizes it by type");
transport.WriteLog.Should().BeEmpty(
"no write may reach the PLC while a recipe is active");
coordinator.Status.Should().Be(PlcSyncStatus.Failed);
}
}
16 changes: 16 additions & 0 deletions SemiStep/SemiStep.Tests/S7/PlcTransactionExecutorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ public async Task WriteRecipeWithRetryAsync_VerificationMismatch_RetriesUpToMaxA

result.IsFailed.Should().BeTrue(
"after exhausting all retry attempts the result must be failed");
result.HasError<WriteVerificationFailedError>().Should().BeTrue(
"the failure reason must be a typed WriteVerificationFailedError");
result.Errors.Should().ContainSingle(e =>
e.Message.Contains("verification failed", StringComparison.OrdinalIgnoreCase));
}
Expand Down Expand Up @@ -269,6 +271,20 @@ public async Task WriteRecipeWithRetryAsync_NotConnected_ReturnsFailedResultWith
"the failure reason must be a NotConnectedError");
}

[Fact]
public async Task ReadRecipeFromPlcAsync_ManagingAreaNotCommitted_ReturnsRecipeNotCommittedError()
{
var (executor, _) = BuildExecutor();

// The default transport returns all-zero managing-area bytes, so Committed decodes to false.
var result = await executor.ReadRecipeFromPlcAsync(TestContext.Current.CancellationToken);

result.IsFailed.Should().BeTrue(
"reading a recipe the PLC has not committed must fail");
result.HasError<RecipeNotCommittedError>().Should().BeTrue(
"the failure reason must be a typed RecipeNotCommittedError");
}

[Fact]
public async Task ReadProtocolVersionAsync_ReturnsBigEndianValueAtVersionOffset()
{
Expand Down
Loading