From 7c8060bf369f9ebaa56d1f3dd03130251cca2923 Mon Sep 17 00:00:00 2001 From: ShortArrow Date: Mon, 29 Jun 2026 15:19:40 +0900 Subject: [PATCH 1/2] fix(vxi11): resolve Core port via TCP/111 portmapper GETPORT (#20) Physical VXI-11 instruments (e.g. Kikusui PWR801L) publish their portmapper on TCP/111 and assign the Device Core to a dynamic port. The client previously connected straight to a fixed port (1024) and timed out against such instruments. OpenAsync now issues a real PMAPPROC_GETPORT over TCP/111 to learn the Core port, falling back to the fixed port when no portmapper answers (e.g. ivi-cli's co-located gateway, which does not listen on 111). The GETPORT request/reply codec is extracted to a shared Vxi11Portmapper helper, reused by the broadcast scanner to remove duplication. --- docs/adr/0029-vxi11-gateway.md | 13 +- src/IviCli.Backends.Vxi11/Vxi11Backend.cs | 94 ++++++-- .../Vxi11BroadcastScanner.cs | 134 +---------- src/IviCli.Backends.Vxi11/Vxi11Portmapper.cs | 123 ++++++++++ src/IviCli.Domain/Protocols/Vxi11Constants.cs | 6 + .../Vxi11PortmapperResolveTests.cs | 223 ++++++++++++++++++ 6 files changed, 447 insertions(+), 146 deletions(-) create mode 100644 src/IviCli.Backends.Vxi11/Vxi11Portmapper.cs create mode 100644 tests/IviCli.Backends.Vxi11.Tests/Vxi11PortmapperResolveTests.cs diff --git a/docs/adr/0029-vxi11-gateway.md b/docs/adr/0029-vxi11-gateway.md index 9c05d29..846b5d7 100644 --- a/docs/adr/0029-vxi11-gateway.md +++ b/docs/adr/0029-vxi11-gateway.md @@ -56,9 +56,18 @@ RPC calls to the Core handler when the program number matches. [ADR 0041](0041-trigger-and-srq-ports.md). - Vendor extensions, TLS, UDP transport, broadcast portmapper queries on UDP 111. -- Real **portmapper-at-111** client conversation — Batch D's client +- ~~Real **portmapper-at-111** client conversation — Batch D's client backend connects directly to the configured Core port instead, since - the gateway co-locates portmapper + Core on one bind address. v2. + the gateway co-locates portmapper + Core on one bind address. v2.~~ + Shipped (issue #20): the client backend now issues a real + `PMAPPROC_GETPORT` over **TCP/111** to resolve the dynamically-assigned + Core port of physical instruments (e.g. Kikusui PWR801L, whose Core is + not on a fixed port). When no portmapper answers within a short probe + window — as with ivi-cli's own gateway, which co-locates portmapper + + Core on one bind address and does not listen on 111 — the client falls + back to the configured fixed port, preserving the gateway pairing. + Shared GETPORT request/reply codec lives in `Vxi11Portmapper`, reused + by the broadcast scanner (ADR 0008). The companion client backend (`IviCli.Backends.Vxi11`) shipped in Batch D, sharing the XDR codec / RPC message records uplifted to diff --git a/src/IviCli.Backends.Vxi11/Vxi11Backend.cs b/src/IviCli.Backends.Vxi11/Vxi11Backend.cs index c3bb272..ab433bc 100644 --- a/src/IviCli.Backends.Vxi11/Vxi11Backend.cs +++ b/src/IviCli.Backends.Vxi11/Vxi11Backend.cs @@ -16,41 +16,62 @@ namespace IviCli.Backends.Vxi11; /// /// Client-side for VXI-11 endpoints -/// (PRD §7.1 priority 2, ADR 0029). v1 implements create_link / +/// (PRD §7.1 priority 2, ADR 0029). Implements create_link / /// device_write / device_read / destroy_link over a single TCP -/// connection per device. The portmapper round-trip is deliberately -/// skipped — ivi-cli's gateway co-locates portmapper + Core on the -/// same bind port, so a real GETPORT call would only echo back the -/// port we already connected to. Real-portmapper-at-111 support is -/// deferred to v2. +/// connection per device. +/// +/// On open the client first asks the instrument's portmapper at TCP/111 +/// for the dynamically-assigned VXI-11 Core port (a real GETPORT +/// round-trip — issue #20). When no portmapper answers (e.g. ivi-cli's +/// own gateway, which co-locates portmapper + Core on a single bind +/// port and does not listen on 111) it falls back to the fixed port, +/// preserving the gateway pairing. /// public sealed class Vxi11Backend : IIviBackend { /// - /// Fallback TCP port when the constructor override is not used. - /// VXI-11 has no IANA-assigned core port (clients traditionally - /// learn it from portmapper at 111); this value is a placeholder - /// suitable for ad-hoc deployments where the gateway operator - /// configures the server with a matching port. + /// Fallback TCP port used when no portmapper answers. VXI-11 has no + /// IANA-assigned core port; this value matches the port ivi-cli's + /// gateway binds by default for ad-hoc deployments. /// public const int DefaultVxi11Port = 1024; + /// How long to wait for the portmapper round-trip before falling back. + private static readonly TimeSpan PortmapperProbeTimeout = TimeSpan.FromSeconds(3); + private readonly Dictionary _sessions = new(); private readonly object _gate = new(); - private readonly int _port; + private readonly int _fallbackPort; + private readonly int _portmapperPort; + private readonly bool _usePortmapper; - /// Creates a backend bound to . + /// + /// Production constructor: resolves the Core port via the portmapper at + /// , falling back to + /// . + /// public Vxi11Backend() - : this(DefaultVxi11Port) { } + : this(DefaultVxi11Port, PortmapperPort, usePortmapper: true) { } /// - /// Creates a backend that connects to instead of - /// the default. Intended for tests against an in-process gateway listening - /// on a randomly allocated loopback port. + /// Creates a backend that connects to a fixed + /// without a portmapper round-trip. Intended for tests / co-located + /// gateways listening on a known loopback port. /// public Vxi11Backend(int port) + : this(port, PortmapperPort, usePortmapper: false) { } + + /// + /// Full constructor. When is set the + /// backend issues a GETPORT against to + /// learn the Core port, falling back to + /// if the portmapper is unreachable or has no registration. + /// + public Vxi11Backend(int fallbackPort, int portmapperPort, bool usePortmapper) { - _port = port; + _fallbackPort = fallbackPort; + _portmapperPort = portmapperPort; + _usePortmapper = usePortmapper; } /// @@ -72,10 +93,12 @@ public async Task> OpenAsync(Device device, Cancellat ); } + var corePort = await ResolveCorePortAsync(tcpip.Host, ct); + var client = new TcpClient(); try { - await client.ConnectAsync(tcpip.Host, _port, ct); + await client.ConnectAsync(tcpip.Host, corePort, ct); } catch (Exception ex) when (ex is SocketException or IOException) { @@ -307,6 +330,39 @@ [EnumeratorCancellation] CancellationToken ct } } + /// + /// Resolves the VXI-11 Core TCP port for . Asks the + /// portmapper at when enabled, falling back to + /// if the portmapper is unreachable, times out, + /// or has no Core registration. + /// + private async Task ResolveCorePortAsync(string host, CancellationToken ct) + { + if (!_usePortmapper) + { + return _fallbackPort; + } + try + { + var resolved = await Vxi11Portmapper.ResolveCorePortAsync( + host, + _portmapperPort, + PortmapperProbeTimeout, + ct + ); + return resolved > 0 ? resolved : _fallbackPort; + } + catch (Exception ex) + when (ex is SocketException or IOException + || (ex is OperationCanceledException && !ct.IsCancellationRequested) + ) + { + // No reachable portmapper (e.g. co-located gateway) — use the + // fixed port. Genuine caller cancellation is rethrown. + return _fallbackPort; + } + } + private static async Task CreateLinkAsync( Vxi11Session session, string lanDevice, diff --git a/src/IviCli.Backends.Vxi11/Vxi11BroadcastScanner.cs b/src/IviCli.Backends.Vxi11/Vxi11BroadcastScanner.cs index 8c47aea..24ce47c 100644 --- a/src/IviCli.Backends.Vxi11/Vxi11BroadcastScanner.cs +++ b/src/IviCli.Backends.Vxi11/Vxi11BroadcastScanner.cs @@ -1,4 +1,3 @@ -using System.Buffers.Binary; using System.Collections.Concurrent; using System.Collections.Immutable; using System.Net; @@ -8,6 +7,7 @@ using IviCli.Domain.Visa; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using static IviCli.Domain.Protocols.Vxi11Constants; namespace IviCli.Backends.Vxi11; @@ -29,25 +29,6 @@ namespace IviCli.Backends.Vxi11; /// public sealed class Vxi11BroadcastScanner : IBackendScanner { - // Portmapper (RFC 1833) constants. - private const uint PortmapProgram = 100000; - private const uint PortmapVersion = 2; - private const uint PmapprocGetport = 3; - private const int PortmapPort = 111; - - // VXI-11 Device Core program identifier (per IVI VXI-11 §B). - private const uint Vxi11DeviceCoreProgram = 0x0607AF; - private const uint Vxi11DeviceCoreVersion = 1; - private const uint IpprotoTcp = 6; - - // RPC framing constants. - private const uint RpcCall = 0; - private const uint RpcReply = 1; - private const uint RpcVersion = 2; - private const uint AuthNone = 0; - private const uint MsgAccepted = 0; - private const uint SuccessState = 0; - private readonly TimeSpan _discoveryWindow; private readonly ILogger _logger; @@ -68,19 +49,19 @@ CancellationToken ct { ct.ThrowIfCancellationRequested(); - var responders = new ConcurrentDictionary(); + var responders = new ConcurrentDictionary(); using var udp = new UdpClient(AddressFamily.InterNetwork) { EnableBroadcast = true }; // Bind to an ephemeral port so replies have somewhere to land. udp.Client.Bind(new IPEndPoint(IPAddress.Any, 0)); - var xid = (uint)Random.Shared.Next(int.MinValue, int.MaxValue); - var request = BuildGetportRequest(xid); + var xid = unchecked((uint)Random.Shared.Next(int.MinValue, int.MaxValue)); + var request = Vxi11Portmapper.BuildGetportRequest(xid); try { - await udp.SendAsync(request, new IPEndPoint(IPAddress.Broadcast, PortmapPort), ct) + await udp.SendAsync(request, new IPEndPoint(IPAddress.Broadcast, PortmapperPort), ct) .ConfigureAwait(false); } catch (SocketException ex) @@ -114,7 +95,10 @@ CancellationToken ct break; } - if (TryParseGetportReply(datagram.Buffer, xid, out var port) && port > 0) + if ( + Vxi11Portmapper.TryParseGetportReply(datagram.Buffer, xid, out var port) + && port > 0 + ) { responders[datagram.RemoteEndPoint.Address] = port; } @@ -134,106 +118,6 @@ CancellationToken ct return Result.Success, BackendError>(resources); } - private static byte[] BuildGetportRequest(uint xid) - { - // RPC CALL header (10 uint32 words = 40 bytes) + AUTH_NONE cred + - // AUTH_NONE verf (2 × 8 bytes = 16 bytes) + GETPORT mapping - // (4 × 4 bytes = 16 bytes) = 72 bytes total. - Span buffer = stackalloc byte[72]; - var pos = 0; - - WriteUInt32(buffer, ref pos, xid); - WriteUInt32(buffer, ref pos, RpcCall); - WriteUInt32(buffer, ref pos, RpcVersion); - WriteUInt32(buffer, ref pos, PortmapProgram); - WriteUInt32(buffer, ref pos, PortmapVersion); - WriteUInt32(buffer, ref pos, PmapprocGetport); - - // AUTH_NONE credentials: flavor=0, length=0. - WriteUInt32(buffer, ref pos, AuthNone); - WriteUInt32(buffer, ref pos, 0); - // AUTH_NONE verifier: flavor=0, length=0. - WriteUInt32(buffer, ref pos, AuthNone); - WriteUInt32(buffer, ref pos, 0); - - // Mapping struct. - WriteUInt32(buffer, ref pos, Vxi11DeviceCoreProgram); - WriteUInt32(buffer, ref pos, Vxi11DeviceCoreVersion); - WriteUInt32(buffer, ref pos, IpprotoTcp); - WriteUInt32(buffer, ref pos, 0); // port is ignored on GETPORT - - return buffer.ToArray(); - } - - private static bool TryParseGetportReply( - ReadOnlySpan buffer, - uint expectedXid, - out ushort port - ) - { - port = 0; - // Minimal successful reply: xid + msg_type + reply_state + - // verf(flavor+length) + accept_state + port = 7 × 4 = 28 bytes. - if (buffer.Length < 28) - { - return false; - } - var pos = 0; - var xid = ReadUInt32(buffer, ref pos); - if (xid != expectedXid) - { - return false; - } - var msgType = ReadUInt32(buffer, ref pos); - if (msgType != RpcReply) - { - return false; - } - var replyState = ReadUInt32(buffer, ref pos); - if (replyState != MsgAccepted) - { - return false; - } - // Verifier flavor + length (length must be 0 for AUTH_NONE replies). - var verfFlavor = ReadUInt32(buffer, ref pos); - var verfLen = ReadUInt32(buffer, ref pos); - if (verfLen != 0) - { - // Skip the verifier opaque body if present. - pos += (int)verfLen; - if (pos > buffer.Length) - { - return false; - } - } - _ = verfFlavor; - var acceptState = ReadUInt32(buffer, ref pos); - if (acceptState != SuccessState) - { - return false; - } - if (pos + 4 > buffer.Length) - { - return false; - } - var rawPort = ReadUInt32(buffer, ref pos); - port = (ushort)rawPort; - return true; - } - - private static void WriteUInt32(Span buffer, ref int pos, uint value) - { - BinaryPrimitives.WriteUInt32BigEndian(buffer.Slice(pos, 4), value); - pos += 4; - } - - private static uint ReadUInt32(ReadOnlySpan buffer, ref int pos) - { - var value = BinaryPrimitives.ReadUInt32BigEndian(buffer.Slice(pos, 4)); - pos += 4; - return value; - } - private static DiscoveredResource? BuildDiscovered(IPAddress host) { var raw = $"TCPIP0::{host}::inst0::INSTR"; diff --git a/src/IviCli.Backends.Vxi11/Vxi11Portmapper.cs b/src/IviCli.Backends.Vxi11/Vxi11Portmapper.cs new file mode 100644 index 0000000..10174eb --- /dev/null +++ b/src/IviCli.Backends.Vxi11/Vxi11Portmapper.cs @@ -0,0 +1,123 @@ +using System.Net.Sockets; +using IviCli.Domain.Protocols; +using static IviCli.Domain.Protocols.Vxi11Constants; + +namespace IviCli.Backends.Vxi11; + +/// +/// ONC RPC portmapper (RFC 1833) GETPORT helpers shared by the VXI-11 +/// client backend and the broadcast scanner. The request/reply wire +/// format is transport-agnostic: the scanner sends the raw bytes over +/// UDP broadcast, while the client wraps them in TCP record-marking +/// framing for a unicast round-trip against an instrument's portmapper +/// at . +/// +public static class Vxi11Portmapper +{ + /// + /// Connects to : + /// over TCP, issues PMAPPROC_GETPORT for the VXI-11 Device Core + /// program, and returns the TCP port the Core listens on (0 if the + /// program is not registered). Throws / + /// when the portmapper is unreachable so the + /// caller can fall back to a fixed port. + /// + public static async Task ResolveCorePortAsync( + string host, + int portmapperPort, + TimeSpan timeout, + CancellationToken ct + ) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout); + using var client = new TcpClient(); + await client.ConnectAsync(host, portmapperPort, cts.Token); + var stream = client.GetStream(); + + var xid = unchecked((uint)Random.Shared.Next(int.MinValue, int.MaxValue)); + await Vxi11RecordFraming.WriteRecordAsync(stream, BuildGetportRequest(xid), cts.Token); + var reply = await Vxi11RecordFraming.ReadRecordAsync(stream, cts.Token); + return TryParseGetportReply(reply, xid, out var port) ? port : 0; + } + + /// + /// Builds the raw ONC RPC PMAPPROC_GETPORT request asking for the + /// VXI-11 Device Core program over TCP. The bytes carry no record-marking + /// header, so UDP callers send them verbatim and TCP callers wrap them + /// via . + /// + public static byte[] BuildGetportRequest(uint xid) + { + var w = new Vxi11XdrCodec.XdrWriter(); + w.WriteUInt32(xid); + w.WriteUInt32(0); // mtype = CALL + w.WriteUInt32(2); // rpcvers + w.WriteUInt32(PortmapProgram); + w.WriteUInt32(PortmapVersion); + w.WriteUInt32(PortmapGetPort); + w.WriteUInt32(0); // cred flavor (AUTH_NONE) + w.WriteUInt32(0); // cred length + w.WriteUInt32(0); // verf flavor (AUTH_NONE) + w.WriteUInt32(0); // verf length + + // pmap mapping struct: { prog, vers, prot, port }. + w.WriteUInt32(CoreProgram); + w.WriteUInt32(CoreVersion); + w.WriteUInt32(IpProtoTcp); + w.WriteUInt32(0); // port is ignored on GETPORT + return w.ToArray(); + } + + /// + /// Parses a portmapper GETPORT reply, validating the RPC envelope and + /// extracting the returned port. Returns false when the reply is + /// malformed, rejected, or for a different transaction id. + /// + public static bool TryParseGetportReply( + ReadOnlySpan buffer, + uint expectedXid, + out int port + ) + { + port = 0; + // xid + mtype + reply_stat + verf(flavor+len) + accept_stat + port = 7 words. + if (buffer.Length < 28) + { + return false; + } + try + { + var reader = new Vxi11XdrCodec.XdrReader(buffer.ToArray()); + if (reader.ReadUInt32() != expectedXid) + { + return false; + } + if (reader.ReadUInt32() != 1) // mtype = REPLY + { + return false; + } + if (reader.ReadUInt32() != MsgAccepted) + { + return false; + } + _ = reader.ReadUInt32(); // verf flavor + _ = reader.ReadOpaque(); // verf body (length-prefixed) + if (reader.ReadUInt32() != AcceptSuccess) + { + return false; + } + if (reader.Remaining < 4) + { + return false; + } + port = (int)reader.ReadUInt32(); + return true; + } + catch (InvalidDataException) + { + // Malformed / truncated datagram (UDP noise) — not a valid reply. + return false; + } + } +} diff --git a/src/IviCli.Domain/Protocols/Vxi11Constants.cs b/src/IviCli.Domain/Protocols/Vxi11Constants.cs index fed20ab..e9460f0 100644 --- a/src/IviCli.Domain/Protocols/Vxi11Constants.cs +++ b/src/IviCli.Domain/Protocols/Vxi11Constants.cs @@ -54,6 +54,12 @@ public static class Vxi11Constants /// Portmapper GETPORT procedure. public const uint PortmapGetPort = 3; + /// Well-known TCP/UDP port the ONC portmapper listens on (RFC 1833). + public const int PortmapperPort = 111; + + /// ONC RPC transport protocol identifier for TCP (IPPROTO_TCP), used in the portmapper mapping struct. + public const uint IpProtoTcp = 6; + /// Core: create_link. public const uint ProcCreateLink = 10; diff --git a/tests/IviCli.Backends.Vxi11.Tests/Vxi11PortmapperResolveTests.cs b/tests/IviCli.Backends.Vxi11.Tests/Vxi11PortmapperResolveTests.cs new file mode 100644 index 0000000..08cd10b --- /dev/null +++ b/tests/IviCli.Backends.Vxi11.Tests/Vxi11PortmapperResolveTests.cs @@ -0,0 +1,223 @@ +using System.Net; +using System.Net.Sockets; +using IviCli.Application.Backends; +using IviCli.Backends.Vxi11; +using IviCli.Domain.Devices; +using IviCli.Domain.Protocols; +using IviCli.Domain.Visa; +using IviCli.TestKit; +using Shouldly; +using static IviCli.Domain.Protocols.Vxi11Constants; + +namespace IviCli.Backends.Vxi11.Tests; + +/// +/// Covers the real TCP/111 portmapper round-trip (issue #20). A real +/// instrument (e.g. Kikusui PWR801L) publishes its portmapper on TCP/111 +/// and assigns the VXI-11 Core to a dynamic port, so the client must +/// GETPORT before connecting. When no portmapper answers, the client +/// falls back to the fixed port so co-located gateways keep working. +/// +public sealed class Vxi11PortmapperResolveTests +{ + [Fact] + public async Task ResolveCorePortAsync_returns_core_port_from_getport_reply() + { + const int corePort = 54321; + await using var pmap = StubHost.Start( + (stream, ct) => ServePortmapperOnce(stream, corePort, ct) + ); + + var resolved = await Vxi11Portmapper.ResolveCorePortAsync( + "127.0.0.1", + pmap.Port, + TimeSpan.FromSeconds(3), + default + ); + + resolved.ShouldBe(corePort); + } + + [Fact] + public async Task ResolveCorePortAsync_throws_when_portmapper_unreachable() + { + var closedPort = FreePort(); + + // Unreachable portmapper surfaces as a connection error (refused) or a + // timeout (silently dropped). Either signal lets OpenAsync fall back. + var ex = await Should.ThrowAsync( + Vxi11Portmapper.ResolveCorePortAsync( + "127.0.0.1", + closedPort, + TimeSpan.FromSeconds(2), + default + ) + ); + + (ex is SocketException or OperationCanceledException).ShouldBeTrue(); + } + + [Fact] + public async Task OpenAsync_resolves_core_port_via_portmapper_then_connects() + { + await using var core = StubHost.Start(ServeCoreOpen); + await using var pmap = StubHost.Start( + (stream, ct) => ServePortmapperOnce(stream, core.Port, ct) + ); + + // Fixed fallback intentionally points at a dead port: success proves + // the connection used the portmapper-resolved port, not the fallback. + var backend = new Vxi11Backend( + fallbackPort: FreePort(), + portmapperPort: pmap.Port, + usePortmapper: true + ); + + (await backend.OpenAsync(BuildDevice(), default)).ShouldBeOk(); + } + + [Fact] + public async Task OpenAsync_falls_back_to_fixed_port_when_portmapper_unreachable() + { + await using var core = StubHost.Start(ServeCoreOpen); + + var backend = new Vxi11Backend( + fallbackPort: core.Port, + portmapperPort: FreePort(), // nothing listening → fall back + usePortmapper: true + ); + + (await backend.OpenAsync(BuildDevice(), default)).ShouldBeOk(); + } + + private static Device BuildDevice() => + new( + DeviceName.From("dut").ShouldBeOk(), + VisaResource.Parse("TCPIP0::127.0.0.1::inst0::INSTR").ShouldBeOk(), + Timeout.FromMilliseconds(3000).ShouldBeOk() + ); + + private static int FreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + /// Reads one GETPORT call and replies with . + private static async Task ServePortmapperOnce( + NetworkStream stream, + int corePort, + CancellationToken ct + ) + { + var (xid, proc) = await ReadCallAsync(stream, ct); + proc.ShouldBe(PortmapGetPort); + await WriteReplyAsync(stream, xid, w => w.WriteUInt32((uint)corePort), ct); + } + + /// Serves create_link + interrupt-channel setup so OpenAsync succeeds. + private static async Task ServeCoreOpen(NetworkStream stream, CancellationToken ct) + { + var create = await ReadCallAsync(stream, ct); + create.Procedure.ShouldBe(ProcCreateLink); + await WriteReplyAsync( + stream, + create.Xid, + w => + { + w.WriteInt32(Vxi11NoError); + w.WriteInt32(7); // link id + w.WriteUInt32(0); // abort port + w.WriteUInt32(16 * 1024 * 1024); // maxRecvSize + }, + ct + ); + + var intr = await ReadCallAsync(stream, ct); + intr.Procedure.ShouldBe(ProcCreateIntrChan); + await WriteReplyAsync(stream, intr.Xid, w => w.WriteInt32(Vxi11NoError), ct); + + var srq = await ReadCallAsync(stream, ct); + srq.Procedure.ShouldBe(ProcDeviceEnableSrq); + await WriteReplyAsync(stream, srq.Xid, w => w.WriteInt32(Vxi11NoError), ct); + } + + private static async Task<(uint Xid, uint Procedure)> ReadCallAsync( + NetworkStream stream, + CancellationToken ct + ) + { + var bytes = await Vxi11RecordFraming.ReadRecordAsync(stream, ct); + var reader = new Vxi11XdrCodec.XdrReader(bytes); + var xid = reader.ReadUInt32(); + _ = reader.ReadUInt32(); // mtype = CALL + _ = reader.ReadUInt32(); // rpcvers + _ = reader.ReadUInt32(); // prog + _ = reader.ReadUInt32(); // vers + var proc = reader.ReadUInt32(); + return (xid, proc); + } + + private static async Task WriteReplyAsync( + NetworkStream stream, + uint xid, + Action body, + CancellationToken ct + ) + { + var writer = new Vxi11XdrCodec.XdrWriter(); + writer.WriteUInt32(xid); + writer.WriteUInt32(1); // mtype = REPLY + writer.WriteUInt32(MsgAccepted); + writer.WriteUInt32(0); // verf flavor + writer.WriteOpaque([]); // verf body + writer.WriteUInt32(AcceptSuccess); + body(writer); + await Vxi11RecordFraming.WriteRecordAsync(stream, writer.ToArray(), ct); + } + + /// Single-accept loopback TCP listener that runs a serve callback. + private sealed class StubHost : IAsyncDisposable + { + private readonly TcpListener _listener; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _served; + + private StubHost(TcpListener listener, Func serve) + { + _listener = listener; + _served = Task.Run(async () => + { + using var client = await _listener.AcceptTcpClientAsync(_cts.Token); + using var stream = client.GetStream(); + await serve(stream, _cts.Token); + }); + } + + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + + public static StubHost Start(Func serve) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return new StubHost(listener, serve); + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + _listener.Stop(); + try + { + await _served; + } + catch + { /* listener torn down */ + } + _cts.Dispose(); + } + } +} From ca36e420af8b09f6cc29fdbe5ff7ede8e97f3e5f Mon Sep 17 00:00:00 2001 From: ShortArrow Date: Mon, 29 Jun 2026 15:37:48 +0900 Subject: [PATCH 2/2] fix(vxi11): use UDP for portmapper GETPORT, not TCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against a real Kikusui PWR801L: its portmapper accepts TCP connections on 111 but never answers GETPORT there — it replies only over UDP/111. Switch ResolveCorePortAsync to a unicast UDP datagram (matching the broadcast scanner's transport) and parse the reply with the shared TryParseGetportReply. Unicast UDP is routed normally, so this also works across subnets. --- docs/adr/0029-vxi11-gateway.md | 18 +++-- src/IviCli.Backends.Vxi11/Vxi11Backend.cs | 6 +- src/IviCli.Backends.Vxi11/Vxi11Portmapper.cs | 42 ++++++---- .../Vxi11PortmapperResolveTests.cs | 77 ++++++++++++++----- 4 files changed, 98 insertions(+), 45 deletions(-) diff --git a/docs/adr/0029-vxi11-gateway.md b/docs/adr/0029-vxi11-gateway.md index 846b5d7..f6f58a9 100644 --- a/docs/adr/0029-vxi11-gateway.md +++ b/docs/adr/0029-vxi11-gateway.md @@ -60,14 +60,16 @@ RPC calls to the Core handler when the program number matches. backend connects directly to the configured Core port instead, since the gateway co-locates portmapper + Core on one bind address. v2.~~ Shipped (issue #20): the client backend now issues a real - `PMAPPROC_GETPORT` over **TCP/111** to resolve the dynamically-assigned - Core port of physical instruments (e.g. Kikusui PWR801L, whose Core is - not on a fixed port). When no portmapper answers within a short probe - window — as with ivi-cli's own gateway, which co-locates portmapper + - Core on one bind address and does not listen on 111 — the client falls - back to the configured fixed port, preserving the gateway pairing. - Shared GETPORT request/reply codec lives in `Vxi11Portmapper`, reused - by the broadcast scanner (ADR 0008). + `PMAPPROC_GETPORT` over **UDP/111** to resolve the dynamically-assigned + Core port of physical instruments (verified against a Kikusui PWR801L, + whose portmapper answers GETPORT only over UDP and reports a dynamic + Core port — TCP/111 accepts connections but never replies to GETPORT). + When no portmapper answers within a short probe window — as with + ivi-cli's own gateway, which co-locates portmapper + Core on one bind + address and does not answer GETPORT on 111 — the client falls back to + the configured fixed port, preserving the gateway pairing. The shared + GETPORT request/reply codec lives in `Vxi11Portmapper`, reused by the + broadcast scanner (ADR 0008). The companion client backend (`IviCli.Backends.Vxi11`) shipped in Batch D, sharing the XDR codec / RPC message records uplifted to diff --git a/src/IviCli.Backends.Vxi11/Vxi11Backend.cs b/src/IviCli.Backends.Vxi11/Vxi11Backend.cs index ab433bc..53ee788 100644 --- a/src/IviCli.Backends.Vxi11/Vxi11Backend.cs +++ b/src/IviCli.Backends.Vxi11/Vxi11Backend.cs @@ -20,12 +20,12 @@ namespace IviCli.Backends.Vxi11; /// device_write / device_read / destroy_link over a single TCP /// connection per device. /// -/// On open the client first asks the instrument's portmapper at TCP/111 +/// On open the client first asks the instrument's portmapper at UDP/111 /// for the dynamically-assigned VXI-11 Core port (a real GETPORT /// round-trip — issue #20). When no portmapper answers (e.g. ivi-cli's /// own gateway, which co-locates portmapper + Core on a single bind -/// port and does not listen on 111) it falls back to the fixed port, -/// preserving the gateway pairing. +/// port and does not answer GETPORT on 111) it falls back to the fixed +/// port, preserving the gateway pairing. /// public sealed class Vxi11Backend : IIviBackend { diff --git a/src/IviCli.Backends.Vxi11/Vxi11Portmapper.cs b/src/IviCli.Backends.Vxi11/Vxi11Portmapper.cs index 10174eb..2896781 100644 --- a/src/IviCli.Backends.Vxi11/Vxi11Portmapper.cs +++ b/src/IviCli.Backends.Vxi11/Vxi11Portmapper.cs @@ -7,20 +7,23 @@ namespace IviCli.Backends.Vxi11; /// /// ONC RPC portmapper (RFC 1833) GETPORT helpers shared by the VXI-11 /// client backend and the broadcast scanner. The request/reply wire -/// format is transport-agnostic: the scanner sends the raw bytes over -/// UDP broadcast, while the client wraps them in TCP record-marking -/// framing for a unicast round-trip against an instrument's portmapper -/// at . +/// format is transport-agnostic: the broadcast scanner sends the raw +/// bytes to the subnet broadcast address, while the client sends a +/// unicast datagram to an instrument's portmapper at +/// . Both use UDP — embedded +/// VXI-11 portmappers (e.g. Kikusui PWR-X) answer GETPORT over UDP only, +/// even when they accept TCP connections on port 111. /// public static class Vxi11Portmapper { /// - /// Connects to : - /// over TCP, issues PMAPPROC_GETPORT for the VXI-11 Device Core - /// program, and returns the TCP port the Core listens on (0 if the - /// program is not registered). Throws / - /// when the portmapper is unreachable so the - /// caller can fall back to a fixed port. + /// Sends a unicast PMAPPROC_GETPORT datagram to + /// : over UDP and + /// returns the TCP port the VXI-11 Device Core listens on (0 if the + /// program is not registered). Throws when + /// the host rejects the datagram, or + /// when no reply arrives within + /// , so the caller can fall back to a fixed port. /// public static async Task ResolveCorePortAsync( string host, @@ -31,14 +34,21 @@ CancellationToken ct { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(timeout); - using var client = new TcpClient(); - await client.ConnectAsync(host, portmapperPort, cts.Token); - var stream = client.GetStream(); + using var udp = new UdpClient(AddressFamily.InterNetwork); + udp.Connect(host, portmapperPort); var xid = unchecked((uint)Random.Shared.Next(int.MinValue, int.MaxValue)); - await Vxi11RecordFraming.WriteRecordAsync(stream, BuildGetportRequest(xid), cts.Token); - var reply = await Vxi11RecordFraming.ReadRecordAsync(stream, cts.Token); - return TryParseGetportReply(reply, xid, out var port) ? port : 0; + await udp.SendAsync(BuildGetportRequest(xid), cts.Token); + + // Ignore stray datagrams that don't match our transaction id. + while (true) + { + var datagram = await udp.ReceiveAsync(cts.Token); + if (TryParseGetportReply(datagram.Buffer, xid, out var port)) + { + return port; + } + } } /// diff --git a/tests/IviCli.Backends.Vxi11.Tests/Vxi11PortmapperResolveTests.cs b/tests/IviCli.Backends.Vxi11.Tests/Vxi11PortmapperResolveTests.cs index 08cd10b..e3a4822 100644 --- a/tests/IviCli.Backends.Vxi11.Tests/Vxi11PortmapperResolveTests.cs +++ b/tests/IviCli.Backends.Vxi11.Tests/Vxi11PortmapperResolveTests.cs @@ -24,9 +24,7 @@ public sealed class Vxi11PortmapperResolveTests public async Task ResolveCorePortAsync_returns_core_port_from_getport_reply() { const int corePort = 54321; - await using var pmap = StubHost.Start( - (stream, ct) => ServePortmapperOnce(stream, corePort, ct) - ); + await using var pmap = UdpPortmapperStub.Start(corePort); var resolved = await Vxi11Portmapper.ResolveCorePortAsync( "127.0.0.1", @@ -61,9 +59,7 @@ public async Task ResolveCorePortAsync_throws_when_portmapper_unreachable() public async Task OpenAsync_resolves_core_port_via_portmapper_then_connects() { await using var core = StubHost.Start(ServeCoreOpen); - await using var pmap = StubHost.Start( - (stream, ct) => ServePortmapperOnce(stream, core.Port, ct) - ); + await using var pmap = UdpPortmapperStub.Start(core.Port); // Fixed fallback intentionally points at a dead port: success proves // the connection used the portmapper-resolved port, not the fallback. @@ -106,18 +102,6 @@ private static int FreePort() return port; } - /// Reads one GETPORT call and replies with . - private static async Task ServePortmapperOnce( - NetworkStream stream, - int corePort, - CancellationToken ct - ) - { - var (xid, proc) = await ReadCallAsync(stream, ct); - proc.ShouldBe(PortmapGetPort); - await WriteReplyAsync(stream, xid, w => w.WriteUInt32((uint)corePort), ct); - } - /// Serves create_link + interrupt-channel setup so OpenAsync succeeds. private static async Task ServeCoreOpen(NetworkStream stream, CancellationToken ct) { @@ -179,6 +163,63 @@ CancellationToken ct await Vxi11RecordFraming.WriteRecordAsync(stream, writer.ToArray(), ct); } + /// + /// Builds a portmapper GETPORT reply datagram echoing + /// and reporting as the Device Core port. + /// + private static byte[] BuildGetportReply(uint xid, int corePort) + { + var w = new Vxi11XdrCodec.XdrWriter(); + w.WriteUInt32(xid); + w.WriteUInt32(1); // mtype = REPLY + w.WriteUInt32(MsgAccepted); + w.WriteUInt32(0); // verf flavor + w.WriteOpaque([]); // verf body (length 0) + w.WriteUInt32(AcceptSuccess); + w.WriteUInt32((uint)corePort); + return w.ToArray(); + } + + /// Single-shot loopback UDP portmapper that answers one GETPORT. + private sealed class UdpPortmapperStub : IAsyncDisposable + { + private readonly UdpClient _udp; + private readonly Task _served; + + private UdpPortmapperStub(UdpClient udp, int corePort) + { + _udp = udp; + _served = Task.Run(async () => + { + var datagram = await _udp.ReceiveAsync(); + var buf = datagram.Buffer; + var xid = (uint)((buf[0] << 24) | (buf[1] << 16) | (buf[2] << 8) | buf[3]); + var reply = BuildGetportReply(xid, corePort); + await _udp.SendAsync(reply, reply.Length, datagram.RemoteEndPoint); + }); + } + + public int Port => ((IPEndPoint)_udp.Client.LocalEndPoint!).Port; + + public static UdpPortmapperStub Start(int corePort) + { + var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + return new UdpPortmapperStub(udp, corePort); + } + + public async ValueTask DisposeAsync() + { + _udp.Dispose(); + try + { + await _served; + } + catch + { /* socket torn down */ + } + } + } + /// Single-accept loopback TCP listener that runs a serve callback. private sealed class StubHost : IAsyncDisposable {