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
4 changes: 3 additions & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ dead commander is indistinguishable from an idle one. Therefore:
Watching continuously rather than in discrete 1 s samples lets a successor attach ≤ 1.1 s after the incumbent's
last beat. "Advisory" is stated plainly: Modbus has no compare-and-swap, so two drivers starting in the same window
can both pass; the watchdog bounds the consequence. On a clean disconnect the driver writes `LeaseOwner = 0` if it
still holds it.
still holds it. A tick that reads `LeaseOwner = 0` while attached (a PLC restart or a clean release, never another
commander) makes the driver re-take the lease in place along the attach path: map and limits checked, own id
written, `WatchdogFault = 0` written, sequence continued from `CommandAck`; only a foreign non-zero owner is `LeaseHeld`.
- **At attach** the driver writes `WatchdogFault = 0` after winning the lease: a latched trip then belongs to a dead
predecessor, and leaving it latched would leave the network disarmed for the new commander. `FaultCode = 4` and
ErrorStop stay until a Reset, so the trip remains visible and distinguishable from a drive fault.
Expand Down
38 changes: 38 additions & 0 deletions src/ModelingEvolution.GenericAxis.Tests/DriverIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -691,4 +691,42 @@ public async Task StatusChanged_IdleAndCruising_AtLeast5PerSecondAndPublishedVal
positions.Should().NotBeEmpty();
positions.Should().OnlyContain(p => rig.Plc.WasPublished((int)Math.Round(p * 1000, MidpointRounding.AwayFromZero)));
}

[Fact(DisplayName = "GA-I-25 A PLC restart under a live session: the unowned lease is re-taken in place (issue #7)")]
public async Task PlcRestart_LeaseOwnerZero_LeaseRetakenAndAxisReportsThePlcState()
{
await using var rig = new LiveRig();
var track = await rig.ConnectedTrack(power: true);
await track.Carriage.MoveAbsoluteAsync(new Mm(1000), new MmPerS(500)).WaitAsync(T);
rig.Plc.Truth.Homed.Should().BeTrue("anchor: homed and moving before the restart");
rig.Plc.Truth.CommandAck.Should().BeGreaterThan(0, "anchor: commands were acknowledged");
var warningsBefore = rig.Logs.GetSnapshot().Count(r => r.Level == LogLevel.Warning);

rig.Plc.PowerCycle();
await rig.Plc.WaitFor(t => t is { LeaseOwner: 0, State: 0, Homed: false }, T, "the PLC restarted");

await rig.Plc.WaitFor(t => t.LeaseOwner == 1, T, "the driver re-takes the unowned lease");
await rig.Plc.WaitFor(t => t.WatchdogArmed, T, "the next beat re-arms the watchdog");
await DriverRig.Until(() => track.LastSnapshot is { LeaseOwner: 1 }, "the driver sees its lease again");
await Task.Delay(300); // three more ticks: a latched LeaseHeld would show by now

track.IsConnected.Should().BeTrue();
rig.Plc.Truth.WatchdogFault.Should().Be(0);
track.Carriage.Status.Error.Should().BeNull("an unowned lease is not another commander");
track.Carriage.State.Should().Be(AxisState.Disabled, "the state is whatever the restarted PLC reports");
track.Carriage.Status.Position.Should().BeNull("not homed after the restart");
var logs = rig.Logs.GetSnapshot();
logs.Where(r => r.Level == LogLevel.Warning && r.Message.Contains("LeaseOwner (C+9 = holding 9) = 0"))
.Should().ContainSingle().Which.Message.Should().Contain("PLC restarted or lease released; lease re-taken");
logs.Should().NotContain(r => r.Message.Contains("LeaseHeld"));
logs.Count(r => r.Level == LogLevel.Warning).Should().Be(warningsBefore + 1, "one Warning for the re-take");

await track.Carriage.PowerAsync(true).WaitAsync(T);
var move = await Throws(() => track.Carriage.MoveAbsoluteAsync(new Mm(2000), new MmPerS(500)));
move.Error.Should().Be(MotionError.NotHomed, "the restarted PLC cleared Homed; the host sees it honestly");

await track.Carriage.HomeAsync().WaitAsync(T);
await track.Carriage.MoveAbsoluteAsync(new Mm(2000), new MmPerS(500)).WaitAsync(T);
rig.Plc.Truth.Position.Should().BeApproximately(2000, 0.005);
}
}
62 changes: 62 additions & 0 deletions src/ModelingEvolution.GenericAxis.Tests/LeaseAndHeartbeatTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,68 @@ public async Task Tick_ForeignLeaseOwner_LatchesLeaseHeld()
rig.Plc.CommandWritesSince(0).Count.Should().Be(writes, "a refusal writes nothing");
}

[Fact(DisplayName = "GA-U-12b An unowned lease seen in a tick is re-taken in place, along the attach path (issue #7)")]
public async Task Tick_LeaseOwnerZero_RetakesLeaseClearsWatchdogContinuesFromAck()
{
await using var rig = await new DriverRig().ConnectAsync();
rig.BehaveLikePlc();
await rig.TickAsync();
rig.Axis.State.Should().Be(AxisState.Standstill, "anchor: healthy before the restart");

// The PLC restarts: command block cleared, Disabled, not homed, CommandAck 0.
rig.Plc.Set(p =>
{
p.LeaseOwner = 0;
p.WatchdogFault = 0;
p.State = 0;
p.Flags = StatusFlags.DriveReady;
p.CommandAck = 0;
});
var from = rig.Plc.OpCount;
await rig.TickAsync();

var writes = rig.Plc.Ops.Skip(from).Where(o => o.IsWrite && o.Address != rig.Plc.Map.Heartbeat).ToArray();
writes.Select(w => (w.Address, w.Values[0])).Should().Equal(
[(rig.Plc.Map.LeaseOwner, DriverRig.Owner), (rig.Plc.Map.WatchdogFault, 0), (rig.Plc.Map.Command, 0)],
"own id, then WatchdogFault 0, then the attach's [Command = 0 (Disabled), CommandSeq = CommandAck]");
writes[2].Values.Should().Equal([(ushort)0, (ushort)0]);
rig.Plc.LeaseOwner.Should().Be(DriverRig.Owner);
rig.Device.IsConnected.Should().BeTrue();
rig.Axis.State.Should().Be(AxisState.Disabled);
rig.Axis.Status.Error.Should().BeNull();
rig.Device.Engine.Sequence.Should().Be(0, "the sequence continues from the restarted PLC's CommandAck");
rig.LogsAt(LogLevel.Warning).Should().ContainSingle(r => r.Message.Contains("LeaseOwner (C+9 = holding 9) = 0"));
rig.LogsAt(LogLevel.Error).Should().BeEmpty();

await rig.TickAsync(3);
rig.Plc.Writes.Count(w => w.Address == rig.Plc.Map.LeaseOwner).Should().Be(2, "taken at attach, re-taken once");
}

[Fact(DisplayName = "GA-U-12c A re-take onto a PLC with another map version writes nothing and latches ProtocolMismatch")]
public async Task Tick_LeaseOwnerZeroWrongMapVersion_RefusedNothingWritten()
{
await using var rig = await new DriverRig().ConnectAsync();
rig.BehaveLikePlc();
await rig.TickAsync();
var failures = 0;
rig.Device.Heartbeat.TickFailed += (_, _) => Interlocked.Increment(ref failures);

rig.Plc.Set(p =>
{
p.LeaseOwner = 0;
p.MapVersion = 2;
});
var from = rig.Plc.OpCount;
rig.Time.Advance(rig.Options.HeartbeatInterval);
await DriverRig.Until(() => Volatile.Read(ref failures) == 1);
await DriverRig.Settle();

rig.Plc.Ops.Skip(from).Where(o => o.IsWrite && o.Address != rig.Plc.Map.Heartbeat).Should().BeEmpty();
rig.Plc.LeaseOwner.Should().Be(0);
rig.Axis.Status.Error.Should().Be(MotionError.ProtocolMismatch);
rig.LogsAt(LogLevel.Error).Should().Contain(r => r.Message.Contains("lease re-take refused; nothing was written"));
}

/// <summary>Advances fake time one interval at a time, waiting for the loop's next read before the next step.</summary>
private static async Task Pump(FakeTimeProvider time, FakePlcChannel plc, Func<bool> done)
{
Expand Down
21 changes: 21 additions & 0 deletions src/ModelingEvolution.GenericAxis.Tests/Support/MiniPlc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,27 @@ public MiniPlc(MiniPlcOptions? options = null)
public void Teleport(double position) => _actions.Enqueue(() => { _p = position; _v = 0; });

/// <summary>Runs an action on the scan thread before the next scan.</summary>
/// <summary>
/// A PLC restart (issue #7): the PLC comes back with its command block cleared (<c>LeaseOwner = 0</c>,
/// <c>WatchdogFault = 0</c>, <c>Heartbeat = 0</c>), State Disabled, <c>Homed</c> off, <c>CommandAck = 0</c> and the
/// trip counter at 0 (it counts since power-up). The TCP server stays up, as when the link reconnects
/// transparently. Applied at the start of the next scan.
/// </summary>
public void PowerCycle() => _actions.Enqueue(() =>
{
for (var offset = 0; offset <= 11; offset++) Set(C(offset), 0);
_state = 0;
_fault = 0;
_ack = 0;
_v = 0;
_homed = false;
_inPosition = false;
_armed = false;
_needFreshEnable = false;
_lastBeat = 0;
_trips = 0;
});

public void OnScan(Action action) => _actions.Enqueue(action);

/// <summary>
Expand Down
39 changes: 37 additions & 2 deletions src/ModelingEvolution.GenericAxis/AxisEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,34 @@ await _channel.WriteRegistersAsync(_unit, _map.Command, [word, s.CommandAck], "c
RaiseStatus(status);
}

/// <summary>Attached and not disconnecting: the device may re-attach in place (issue #7).</summary>
public bool IsCommanding
{
get { lock (_sync) return _attached && !_detaching; }
}

/// <summary>
/// Issue #7: re-attach in place after the PLC dropped the lease (<c>LeaseOwner = 0</c>, a PLC restart or a clean
/// release). A verb in flight fails with CommunicationLost naming <paramref name="reason"/> and writes nothing
/// more; then <see cref="AttachAsync"/> runs on <paramref name="fresh"/> — sequence from <c>CommandAck</c>, Enable
/// from the PLC state, stale edges cleared, overlays dropped (the axis is attached anew).
/// </summary>
/// <returns><see langword="false"/> when the engine is detached or detaching — nothing was written.</returns>
public async Task<bool> ReattachAsync(PlcSnapshot fresh, string reason, CancellationToken ct)
{
Running? running;
lock (_sync)
{
if (!_attached || _detaching) return false;
running = _running;
running?.MarkDetached(reason);
}

running?.Cancel();
await AttachAsync(fresh, ct).ConfigureAwait(false);
return true;
}

/// <summary>
/// The start of a clean disconnect (review #8): the verb in flight — which the disconnect's Stop is about to
/// cancel — fails with CommunicationLost "detached during &lt;verb&gt;", not a bare cancellation, and no new verb
Expand Down Expand Up @@ -984,7 +1012,7 @@ private async Task RunVerbAsync(string verb, Action<PlcSnapshot, AxisState>? gua
{
// Review #8: from the verb's point of view the link is gone — the caller did not cancel.
var lost = Error(MotionError.CommunicationLost,
$"the device was detached during {verb}; {verb} did not complete");
$"{running.DetachReason ?? "the device was detached"} during {verb}; {verb} did not complete");
_logger?.LogWarning("{Message}", lost.Message);
throw lost;
}
Expand Down Expand Up @@ -1248,7 +1276,14 @@ private sealed class Running(string verb, CancellationTokenSource cts) : IDispos

private int _detached;

public void MarkDetached() => Volatile.Write(ref _detached, 1);
/// <summary>Why the verb was cut off, when it was not a disconnect (issue #7: the PLC dropped the lease).</summary>
public string? DetachReason { get; private set; }

public void MarkDetached(string? reason = null)
{
DetachReason ??= reason;
Volatile.Write(ref _detached, 1);
}

public void Cancel()
{
Expand Down
21 changes: 21 additions & 0 deletions src/ModelingEvolution.GenericAxis/AxisHeartbeat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ public AxisHeartbeat(string axis, IModbusChannel channel, byte unit, RegisterMap
/// <summary>Raised when a tick fails after the channel's retry.</summary>
public event EventHandler<MotionException>? TickFailed;

/// <summary>
/// Issue #7: run inside the tick when it reads <c>LeaseOwner = 0</c> while this driver holds the lease — a PLC
/// restart or a clean release, never another commander (protocol § FR-11 "Advisory lease"). It re-attaches in
/// place and returns the snapshot the tick then reports, or <see langword="null"/> when the device is not attached
/// for commanding (the tick then reports what it read). A throw fails the tick like a channel failure.
/// </summary>
internal Func<PlcSnapshot, CancellationToken, Task<PlcSnapshot?>>? UnownedLease { get; set; }

// ═══════════════════════ lease ═══════════════════════

/// <summary>
Expand Down Expand Up @@ -142,6 +150,15 @@ await _channel.WriteRegisterAsync(_unit, _map.LeaseOwner, OwnerId, "take lease",
return (words[0], words[1]);
}

/// <summary>Re-takes an unowned lease while attached (issue #7): writes own id into <c>LeaseOwner</c>, no
/// watching — 0 is "take it" in the advisory-lease rule.</summary>
internal async Task RetakeAsync(CancellationToken ct)
{
await _channel.WriteRegisterAsync(_unit, _map.LeaseOwner, OwnerId, "re-take unowned lease",
ChannelPriority.Heartbeat, ct).ConfigureAwait(false);
_leaseHeld = true;
}

/// <summary>Writes <c>WatchdogFault = 0</c> on the stop lane (design § Device lifecycle step 4).</summary>
public async Task ClearWatchdogFaultAsync(CancellationToken ct)
{
Expand Down Expand Up @@ -227,6 +244,10 @@ internal async Task<PlcSnapshot> TickOnceAsync(CancellationToken ct)
_axis, snapshot.LeaseOwner, snapshot.WatchdogFault, snapshot.WatchdogTrips, snapshot.Status.State,
(ushort)snapshot.Status.Flags, snapshot.Status.ActualPosition, snapshot.Status.CommandAck);

if (_leaseHeld && snapshot.LeaseOwner == AdvisoryLease.Unowned && UnownedLease is { } retake
&& await retake(snapshot, ct).ConfigureAwait(false) is { } reattached)
snapshot = reattached;

if (snapshot.WatchdogFault != 0 && _lastWatchdogFault == 0)
_logger?.LogError("{Message}", AxisErrors.Message(_axis, MotionError.WatchdogTripped,
$"WATCHDOG TRIPPED on {_channel.Host}:{_channel.Port}; recovery is Reset, then re-command (no re-home)",
Expand Down
Loading
Loading