92171a01ec
Review 07 H13: _devices and _tagsByRawPath were plain dictionaries cleared and refilled in place while the retained poll loop, the actor's GetHostStatuses and server reads enumerated them. Both are now built locally and published by reference under a lifecycle lock; teardown swaps in empty tables. Lane A contract: ShutdownAsync and Dispose each retire a disjoint set of device states, so Dispose running concurrently with a still-joining ShutdownAsync is safe. A lifecycle epoch makes an InitializeAsync that the runtime abandoned (overlapping the next ShutdownAsync) refuse to publish its tables, start probe loops or write health. A runtime cached on an already-retired device state by an in-flight read is disposed instead of leaked, and late reads/writes no longer overwrite shutdown's health. Per-key semaphores are no longer disposed under callers that may still hold them. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
258 lines
12 KiB
C#
258 lines
12 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class AbLegacyBitRmwTests
|
|
{
|
|
/// <summary>Verifies that setting a bit reads the parent word, ORs the bit, and writes back.</summary>
|
|
[Fact]
|
|
public async Task Bit_set_reads_parent_word_ORs_bit_writes_back()
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory
|
|
{
|
|
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0b0001 },
|
|
};
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
|
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("Flag3", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)]),
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var results = await drv.WriteAsync(
|
|
[new WriteRequest("Flag3", true)], CancellationToken.None);
|
|
|
|
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
|
factory.Tags.ShouldContainKey("N7:0"); // parent word runtime created
|
|
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0b1001);
|
|
}
|
|
|
|
/// <summary>Verifies that clearing a bit preserves other bits in the word.</summary>
|
|
[Fact]
|
|
public async Task Bit_clear_preserves_other_bits_in_N_file_word()
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory
|
|
{
|
|
Customise = p => new FakeAbLegacyTag(p) { Value = unchecked((short)0xFFFF) },
|
|
};
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
|
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("F", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)]),
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.WriteAsync([new WriteRequest("F", false)], CancellationToken.None);
|
|
|
|
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(unchecked((short)0xFFF7));
|
|
}
|
|
|
|
/// <summary>Verifies that concurrent bit writes to the same word compose correctly.</summary>
|
|
[Fact]
|
|
public async Task Concurrent_bit_writes_to_same_word_compose_correctly()
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory
|
|
{
|
|
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
|
|
};
|
|
var tags = Enumerable.Range(0, 8)
|
|
.Select(b => new AbLegacyTagDefinition($"Bit{b}", "ab://10.0.0.5/1,0", $"N7:0/{b}", AbLegacyDataType.Bit))
|
|
.ToArray();
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
|
RawTags = AbLegacyRawTags.Entries(tags),
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
|
|
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
|
|
|
|
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0xFF);
|
|
}
|
|
|
|
/// <summary>Verifies that repeated bit writes reuse the parent word runtime.</summary>
|
|
[Fact]
|
|
public async Task Repeat_bit_writes_reuse_parent_runtime()
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory
|
|
{
|
|
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
|
|
};
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
|
RawTags = AbLegacyRawTags.Entries(
|
|
[
|
|
new AbLegacyTagDefinition("Bit0", "ab://10.0.0.5/1,0", "N7:0/0", AbLegacyDataType.Bit),
|
|
new AbLegacyTagDefinition("Bit5", "ab://10.0.0.5/1,0", "N7:0/5", AbLegacyDataType.Bit),
|
|
]),
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.WriteAsync([new WriteRequest("Bit0", true)], CancellationToken.None);
|
|
await drv.WriteAsync([new WriteRequest("Bit5", true)], CancellationToken.None);
|
|
|
|
factory.Tags["N7:0"].InitializeCount.ShouldBe(1);
|
|
factory.Tags["N7:0"].WriteCount.ShouldBe(2);
|
|
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0x21); // bits 0 + 5
|
|
}
|
|
|
|
/// <summary>B/I/O-file bit set reads the parent word, ORs the bit, writes it back (was BadNotSupported).</summary>
|
|
[Theory]
|
|
[InlineData("B3:0/3", "B3:0")]
|
|
[InlineData("I:0/3", "I:0")]
|
|
[InlineData("O:1/3", "O:1")]
|
|
public async Task Bit_set_on_B_I_O_file_RMWs_parent_word(string bitAddr, string parentName)
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory
|
|
{
|
|
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0b0001 },
|
|
};
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
|
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("F", "ab://10.0.0.5/1,0", bitAddr, AbLegacyDataType.Bit)]),
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var results = await drv.WriteAsync([new WriteRequest("F", true)], CancellationToken.None);
|
|
|
|
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
|
factory.Tags.ShouldContainKey(parentName);
|
|
Convert.ToInt32(factory.Tags[parentName].Value).ShouldBe(0b1001);
|
|
}
|
|
|
|
/// <summary>B-file bit clear preserves the other bits in the parent word.</summary>
|
|
[Fact]
|
|
public async Task Bit_clear_preserves_other_bits_in_B_file_word()
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory
|
|
{
|
|
Customise = p => new FakeAbLegacyTag(p) { Value = unchecked((short)0xFFFF) },
|
|
};
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
|
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("F", "ab://10.0.0.5/1,0", "B3:0/3", AbLegacyDataType.Bit)]),
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.WriteAsync([new WriteRequest("F", false)], CancellationToken.None);
|
|
|
|
Convert.ToInt32(factory.Tags["B3:0"].Value).ShouldBe(unchecked((short)0xFFF7));
|
|
}
|
|
|
|
/// <summary>Concurrent bit writes to the same B-file word compose correctly (per-parent lock).</summary>
|
|
[Fact]
|
|
public async Task Concurrent_bit_writes_to_same_B_file_word_compose_correctly()
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory
|
|
{
|
|
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
|
|
};
|
|
var tags = Enumerable.Range(0, 8)
|
|
.Select(b => new AbLegacyTagDefinition($"Bit{b}", "ab://10.0.0.5/1,0", $"B3:0/{b}", AbLegacyDataType.Bit))
|
|
.ToArray();
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
|
RawTags = AbLegacyRawTags.Entries(tags),
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
|
|
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
|
|
|
|
Convert.ToInt32(factory.Tags["B3:0"].Value).ShouldBe(0xFF);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that disposing (teardown) and re-initializing the driver does not orphan the
|
|
/// per-parent RMW-lock semaphores from the previous session. A bit write after the
|
|
/// reinit cycle must succeed, proving that <see cref="DeviceState.DisposeRuntimes"/> clears
|
|
/// the lock map so fresh semaphores are created for the new session rather than re-using
|
|
/// (already-disposed) handles from the old one.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RMW_locks_are_disposed_and_fresh_after_driver_reinitialise()
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory
|
|
{
|
|
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
|
|
};
|
|
var opts = new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
|
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("Bit2", "ab://10.0.0.5/1,0", "N7:0/2", AbLegacyDataType.Bit)]),
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
};
|
|
|
|
// First session: init + bit write — seeds an RMW lock entry for "N7:0".
|
|
var drv = new AbLegacyDriver(opts, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
var firstResult = await drv.WriteAsync([new WriteRequest("Bit2", true)], CancellationToken.None);
|
|
firstResult.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
|
|
|
// Teardown — retirement drops the device state (and its RMW lock map) with it.
|
|
await drv.DisposeAsync();
|
|
|
|
// Second session: re-init on a fresh driver instance (mirrors ReinitializeAsync behavior).
|
|
factory.Tags.Clear();
|
|
var drv2 = new AbLegacyDriver(opts, "drv-1", factory);
|
|
await drv2.InitializeAsync("{}", CancellationToken.None);
|
|
var secondResult = await drv2.WriteAsync([new WriteRequest("Bit2", false)], CancellationToken.None);
|
|
|
|
// Must succeed — a disposed/orphaned semaphore would throw ObjectDisposedException.
|
|
secondResult.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
|
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0);
|
|
|
|
await drv2.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A non-zero libplctag status returned by the parent-word write is surfaced as a non-Good
|
|
/// OPC UA StatusCode — the PCCC device rejection propagates to the caller.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Bit_write_surfaces_device_rejection_status()
|
|
{
|
|
// Arrange: the parent tag reads OK (Status = 0) but write returns a timeout error.
|
|
const int errorTimeout = (int)ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbStatus.Timeout;
|
|
var factory = new FakeAbLegacyTagFactory
|
|
{
|
|
Customise = p => new FakeAbLegacyTag(p)
|
|
{
|
|
Value = (short)0b0001,
|
|
WriteStatusOverride = errorTimeout,
|
|
},
|
|
};
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
|
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("F", "ab://10.0.0.5/1,0", "I:0/3", AbLegacyDataType.Bit)]),
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
// Act
|
|
var results = await drv.WriteAsync([new WriteRequest("F", true)], CancellationToken.None);
|
|
|
|
// Assert: the write rejection must NOT be silently swallowed as Good.
|
|
var statusCode = results.Single().StatusCode;
|
|
statusCode.ShouldNotBe(AbLegacyStatusMapper.Good);
|
|
statusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(errorTimeout));
|
|
}
|
|
}
|