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
15 changes: 9 additions & 6 deletions Docs/architecture/error-reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ headline is what the operator reads while the exception rides the cause for futu
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)`. (PLC is now typed — see "PLC faults localize by type" below —
except the two deliberately-deferred producers noted there.)
except the short protocol-version read and the codec decode failures, which stay English by design as noted
there; the enable-catch cancellation message is also plain English, settled by slice 6c.)

### PLC faults localize by type

Expand All @@ -191,15 +192,17 @@ into the result chain and a `_logger.Log(ex, …)` line at the catch (writes at
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
**Producers that stay English by design:** `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`
operator-actionable fault (still deferred to a future wave). `PlcLifecycleManager`'s enable catch was
settled by slice 6c: the genuine-cancel arm returns a plain internal `Result.Fail` for a shutdown race,
deliberately un-typed so it stays out of the localization coverage gate, while the failure arm forwards
`ex.Message` as before. A third, still deferred: 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.
operator-actionable fault; typing the codec layer is out of scope for this wave. The two malformed-wire
edges stay free-text until then.

### The published rule (public error surface)

Expand Down
6 changes: 3 additions & 3 deletions SemiStep/SemiStep.Core/Plc/IS7Reader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace SemiStep.Core.Plc;

public interface IS7Reader
{
Task<Result<PlcManagingAreaState>> ReadManagingAreaAsync();
Task<Result<Recipe>> ReadRecipeFromPlcAsync();
Task<Result<int>> ReadProtocolVersionAsync();
Task<Result<PlcManagingAreaState>> ReadManagingAreaAsync(CancellationToken ct = default);
Task<Result<Recipe>> ReadRecipeFromPlcAsync(CancellationToken ct = default);
Task<Result<int>> ReadProtocolVersionAsync(CancellationToken ct = default);
}
65 changes: 32 additions & 33 deletions SemiStep/SemiStep.Core/Plc/PlcLifecycleManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ namespace SemiStep.Core.Plc;

public sealed class PlcLifecycleManager : IDisposable
{
internal const string SyncEnableCancelledMessage = "PLC sync enable cancelled";

private readonly IS7Connection _connection;
private readonly IS7ExecutionStream _executionStream;
private readonly ImportedRecipeValidator _importedRecipeValidator;
Expand Down Expand Up @@ -120,12 +122,14 @@ public async Task<Result> EnableSync(PlcConfiguration config)

_ownershipLease = ownershipResult.Value;

var lifeToken = _lifetimeCts.Token;

try
{
_syncService.SetSyncEnabled(true);
await _connection.ConnectAsync(config.Connection);

var versionResult = await ValidateProtocolVersionAsync();
var versionResult = await ValidateProtocolVersionAsync(lifeToken);
if (versionResult.IsFailed)
{
await FailProtocolVersionHandshakeAsync();
Expand All @@ -134,18 +138,22 @@ public async Task<Result> EnableSync(PlcConfiguration config)

return Result.Ok();
}
catch (OperationCanceledException) when (lifeToken.IsCancellationRequested)
{
RollbackSyncEnable();
return Result.Fail(SyncEnableCancelledMessage);
}
catch (Exception ex)
{
_syncService.SetSyncEnabled(false);
ReleaseOwnershipLease();
RollbackSyncEnable();
_logger.LogWarning("Enabling PLC sync failed: {Message}", ex.Message);
return Result.Fail(ex.Message);
}
}

private async Task<Result> ValidateProtocolVersionAsync()
private async Task<Result> ValidateProtocolVersionAsync(CancellationToken ct)
{
var versionResult = await _reader.ReadProtocolVersionAsync();
var versionResult = await _reader.ReadProtocolVersionAsync(ct);
if (versionResult.IsFailed)
{
return versionResult.ToResult();
Expand All @@ -161,10 +169,15 @@ private async Task<Result> ValidateProtocolVersionAsync()
}

private async Task FailProtocolVersionHandshakeAsync()
{
RollbackSyncEnable();
await _connection.DisconnectAsync();
}

private void RollbackSyncEnable()
{
_syncService.SetSyncEnabled(false);
ReleaseOwnershipLease();
await _connection.DisconnectAsync();
}

public async Task DisableSync()
Expand Down Expand Up @@ -284,12 +297,18 @@ private void OnConnectionStateChanged(PlcConnectionState state)

private async Task PerformReconnectReconciliationAsync(CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
try
{
await ReconcileWithPlcAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return;
}
}

var versionResult = await ValidateProtocolVersionAsync();
private async Task ReconcileWithPlcAsync(CancellationToken cancellationToken)
{
var versionResult = await ValidateProtocolVersionAsync(cancellationToken);
if (versionResult.IsFailed)
{
_logger.LogWarning(
Expand All @@ -299,17 +318,7 @@ private async Task PerformReconnectReconciliationAsync(CancellationToken cancell
return;
}

if (cancellationToken.IsCancellationRequested)
{
return;
}

var managingAreaResult = await _reader.ReadManagingAreaAsync();

if (cancellationToken.IsCancellationRequested)
{
return;
}
var managingAreaResult = await _reader.ReadManagingAreaAsync(cancellationToken);

if (managingAreaResult.IsFailed)
{
Expand All @@ -326,12 +335,7 @@ private async Task PerformReconnectReconciliationAsync(CancellationToken cancell
return;
}

var plcRecipeResult = await _reader.ReadRecipeFromPlcAsync();

if (cancellationToken.IsCancellationRequested)
{
return;
}
var plcRecipeResult = await _reader.ReadRecipeFromPlcAsync(cancellationToken);

if (plcRecipeResult.IsFailed)
{
Expand All @@ -347,7 +351,7 @@ private async Task PerformReconnectReconciliationAsync(CancellationToken cancell

if (localRecipe.Steps.Count == 0 && plcRecipe.Steps.Count > 0)
{
await ApplyReconnectPlcRecipeAsync(plcRecipe, cancellationToken);
await ApplyReconnectPlcRecipeAsync(plcRecipe);
return;
}

Expand All @@ -365,7 +369,7 @@ private async Task PerformReconnectReconciliationAsync(CancellationToken cancell
NotifyLocalRecipe();
}

private async Task ApplyReconnectPlcRecipeAsync(Recipe plcRecipe, CancellationToken cancellationToken)
private async Task ApplyReconnectPlcRecipeAsync(Recipe plcRecipe)
{
var callback = _reconnectApplyCallback;
if (callback is null)
Expand All @@ -386,11 +390,6 @@ private async Task ApplyReconnectPlcRecipeAsync(Recipe plcRecipe, CancellationTo
return;
}

if (cancellationToken.IsCancellationRequested)
{
return;
}

if (applyResult.IsFailed)
{
_logger.LogWarning(
Expand Down
12 changes: 6 additions & 6 deletions SemiStep/SemiStep.Core/Plc/S7/S7Service.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ public async Task DisconnectAsync()
_logger.LogInformation("Disconnected from PLC");
}

public async Task<Result<PlcManagingAreaState>> ReadManagingAreaAsync()
public async Task<Result<PlcManagingAreaState>> ReadManagingAreaAsync(CancellationToken ct = default)
{
var result = await transactionExecutor.ReadManagingAreaAsync();
var result = await transactionExecutor.ReadManagingAreaAsync(ct);
if (result.IsFailed)
{
_logger.LogWarning("Failed to read managing area from PLC: {Reason}", result.Errors.FirstOrDefault()?.Message ?? "(no message)");
Expand All @@ -125,19 +125,19 @@ public async Task<Result<PlcManagingAreaState>> ReadManagingAreaAsync()
return result;
}

public async Task<Result<Recipe>> ReadRecipeFromPlcAsync()
public async Task<Result<Recipe>> ReadRecipeFromPlcAsync(CancellationToken ct = default)
{
var result = await transactionExecutor.ReadRecipeFromPlcAsync();
var result = await transactionExecutor.ReadRecipeFromPlcAsync(ct);
if (result.IsFailed)
{
_logger.LogWarning("Failed to read recipe from PLC: {Reason}", result.Errors.FirstOrDefault()?.Message ?? "(no message)");
}
return result;
}

public async Task<Result<int>> ReadProtocolVersionAsync()
public async Task<Result<int>> ReadProtocolVersionAsync(CancellationToken ct = default)
{
var result = await transactionExecutor.ReadProtocolVersionAsync();
var result = await transactionExecutor.ReadProtocolVersionAsync(ct);
if (result.IsFailed)
{
_logger.LogWarning("Failed to read protocol version from PLC: {Reason}", result.Errors.FirstOrDefault()?.Message ?? "(no message)");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,40 @@ public async Task EnableSync_WhenProtocolVersionReadThrowsCanceled_ReleasesLease
plc.IsSyncEnabled.Should().BeFalse();
}

[Fact]
public async Task EnableSync_WhenLifetimeCancelledMidVersionRead_TakesGenuineCancelBranch()
{
var (plc, s7Service, syncService, ownership) = await BuildAsync();

// Gate the version read so EnableSync suspends mid-await inside the handshake. Unlike the
// timeout sibling (ProtocolVersionReadShouldThrowCanceled), this read never throws on its own
// and never returns a value — it is interrupted by a genuine token cancel arriving mid-wait.
var versionGate = new ReadGate();
s7Service.ProtocolVersionReadGate = versionGate;

var enableTask = plc.EnableSync(PlcConfiguration.Default);

await versionGate.Entered.WaitAsync(TestContext.Current.CancellationToken);

// Dispose cancels the lifetime CTS the captured lifeToken observes; the gated WaitAsync throws
// OperationCanceledException with the token cancelled, so the genuine-cancel catch runs.
plc.Dispose();

var result = await enableTask.WaitAsync(TestContext.Current.CancellationToken);

result.IsFailed.Should().BeTrue(
"a lifetime cancel landing mid-handshake must fail EnableSync via the genuine-cancel branch");
result.Errors[0].Message.Should().Be(PlcLifecycleManager.SyncEnableCancelledMessage,
"the genuine-cancel branch must surface its own message, not the generic failure path's "
+ "forwarded exception message; this is the only observable state that discriminates the two catch arms");
ownership.LastLease.Should().NotBeNull();
ownership.LastLease!.DisposeCallCount.Should().Be(1,
"the genuine-cancel branch must release the ownership lease exactly once");
syncService.IsSyncEnabled.Should().BeFalse(
"the genuine-cancel branch must roll the sync flag back to disabled");
plc.IsSyncEnabled.Should().BeFalse();
}

[Fact]
public async Task Dispose_ReleasesLeaseExactlyOnce()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,59 @@ await TestHelpers.WaitUntilAsync(
+ "check and never read the managing area or apply a recipe on a version mismatch");
}

[Fact]
public async Task StateChanged_Connected_WhenLifetimeCancelledMidRead_AbortsAtReadWithoutReadingRecipe()
{
var (plc, _, s7Service, _) = await BuildAsync();

// The broken (token-ignored) path would proceed past a committed managing area to the recipe read
// and the apply callback. Wire both so the divergence is observable: committed area + non-empty
// recipe + empty local session (session.Reset in BuildAsync) is the "adopt PLC recipe" shape.
s7Service.ManagingAreaToReturn = new PlcManagingAreaState(Committed: true, RecipeLines: 1);
s7Service.RecipeToReturn = BuildSingleStepRecipe();

var applyCallbackInvoked = false;
plc.RegisterReconnectApplyCallback(_ =>
{
applyCallbackInvoked = true;
return Task.FromResult(Result.Ok());
});

var enableResult = await plc.EnableSync(PlcConfiguration.Default);
enableResult.IsSuccess.Should().BeTrue();

// Gate the managing-area read (the first read after the version check).
var managingAreaGate = new ReadGate();
s7Service.ManagingAreaReadGate = managingAreaGate;

s7Service.RaiseStateChanged(PlcConnectionState.Connected);

await managingAreaGate.Entered.WaitAsync(TestContext.Current.CancellationToken);

// Cancel the lifetime token mid-wait, then release the gate. A genuine cancel makes the gated
// read throw before it returns, so reconciliation never advances to the recipe read.
plc.Dispose();
managingAreaGate.Release();

// The discriminator: the downstream recipe read must NOT happen. A silently-ignored token would
// let the released managing-area read return and reconciliation would proceed to the recipe read
// (count 1); the working plumbing aborts at the read (count 0). Bounded wait: if the recipe read
// is ever reached it signals within microseconds; timing out means it was never reached.
var settleWindow = TimeSpan.FromMilliseconds(250);
var recipeReadReached = await Task.WhenAny(
s7Service.RecipeReadReached,
Task.Delay(settleWindow, TestContext.Current.CancellationToken))
== s7Service.RecipeReadReached;

recipeReadReached.Should().BeFalse(
"a cancelled lifetime token must abort reconciliation at the in-flight managing-area read, "
+ "never advancing to the downstream recipe read");
s7Service.ReadRecipeFromPlcCallCount.Should().Be(0,
"the recipe read is downstream of the managing-area read; a cancel at the read leaves it uncalled");
applyCallbackInvoked.Should().BeFalse(
"aborting at the managing-area read must not reach the apply callback");
}

[Fact]
public async Task RegisterReconnectApplyCallback_CalledTwice_Throws()
{
Expand Down
33 changes: 33 additions & 0 deletions SemiStep/SemiStep.Tests/Helpers/ReadGate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace SemiStep.Tests.Helpers;

/// <summary>
/// A deterministic, token-aware gate for a stubbed PLC read. The read signals <see cref="Entered"/>
/// when it reaches the gate, then awaits <see cref="WaitAsync"/> until the test calls <see cref="Release"/>.
/// Because the wait uses <c>Task.WaitAsync(ct)</c>, a cancellation arriving <c>mid-wait</c> makes the
/// gated read throw an <see cref="OperationCanceledException"/> — the exact behavior a bare
/// <c>ct.ThrowIfCancellationRequested()</c> at read entry cannot reproduce.
/// </summary>
public sealed class ReadGate
{
private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);

/// <summary>Completes once the gated read has reached the gate and is awaiting release.</summary>
public Task Entered => _entered.Task;

/// <summary>Releases the gated read so it can complete.</summary>
public void Release()
{
_release.TrySetResult();
}

/// <summary>
/// Signals <see cref="Entered"/>, then awaits release while observing <paramref name="ct"/>.
/// A cancel arriving during the wait throws an <see cref="OperationCanceledException"/>.
/// </summary>
public async Task WaitAsync(CancellationToken ct)
{
_entered.TrySetResult();
await _release.Task.WaitAsync(ct);
}
}
Loading