diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md
index cd6b9f4..c8b3102 100644
--- a/.paul/ROADMAP.md
+++ b/.paul/ROADMAP.md
@@ -5,10 +5,11 @@
| Version | Name | Phases | Status | Completed |
|---------|------|--------|--------|-----------|
| v0.1 | Walking Skeleton (MTConnect) | 1 | ✅ Shipped | 2026-07-21 |
-| v0.2 | Machine Management UX | 2 | 🚧 Complete (commit pending) | - |
-| v0.3 | OPC UA Protocol | 3 | 📋 Planned | - |
-| v0.4 | Fanuc FOCAS Protocol | 4 | 📋 Planned | - |
-| v0.5 | History & Trends | 5 | 📋 Planned | - |
+| v0.2 | Machine Management UX | 2, 2.1 | ✅ Shipped (2.1 commit pending) | 2026-07-21 |
+| v0.3 | Data-Item Selection + UX | 3, 3.1 | 📋 Planned | - |
+| v0.4 | OPC UA Protocol | 4 | 📋 Planned | - |
+| v0.5 | Fanuc FOCAS Protocol | 5 | 📋 Planned | - |
+| v0.6 | History & Trends | 6 | 📋 Planned | - |
## 🚧 Active Milestone: v0.2 Machine Management UX
@@ -27,35 +28,57 @@
- [x] 02-B: App — machine detail screen (full snapshot) + dashboard row-click nav + back
- [x] 02-C: App — config add/edit/delete screen + repo.Upsert + live monitor reload; Core AvailableProtocols
-## 📋 Planned Milestone: v0.3 OPC UA Protocol
+### Phase 2.1: Refinements [INSERTED]
-**Goal:** Second protocol plugin (OPC UA) proving the multi-protocol architecture.
-**Prerequisite:** v0.2 complete
-**Estimated phases:** 1
+**Goal:** Post-v0.2 polish + repo hygiene.
+**Depends on:** Phase 2
+**Reason:** UX/robustness feedback + tracked-artifact cleanup.
+
+**Plans:**
+- [x] 02.1-R1: MTConnect shared static HttpClient + per-request timeout (linked CTS) — fix socket-exhaustion
+- [x] 02.1-R2R3: disconnection UX (status color/dot/badge + LastSeen) + robust delete confirm overlay
+- [x] 02.1-tablefix: dashboard → DataGrid (aligned columns, Details own column) + Avalonia.Controls.DataGrid 11.3.10
+
+## 📋 Planned Milestone: v0.3 Data-Item Selection + UX
+
+**Goal:** Per-machine selection of which data items to monitor (probe catalog, opt-in, filters persistence); then themed/iconed UI polish.
+**Prerequisite:** v0.2.1 committed
+**Estimated phases:** 2
| Phase | Focus | Research |
|-------|-------|----------|
-| 3 | OPC UA driver plugin (endpoint/nodes → MachineSnapshot), config keys, tests | Likely (OPC UA SDK on net48) |
+| 3 | Item selection: Domain Machine.MonitoredItemIds + DataItemDescriptor + IProtocolDriver.ProbeAsync; MTConnect probe + filter to selected; Persistence col; App config checklist + detail shows selected | Unlikely (probe parser exists) |
+| 3.1 | Theming/UX: Fluent light/dark + accent, per-protocol icons, curated layout | Unlikely |
-## 📋 Planned Milestone: v0.4 Fanuc FOCAS Protocol
+## 📋 Planned Milestone: v0.4 OPC UA Protocol
-**Goal:** Fanuc FOCAS plugin via native fwlib32 (P/Invoke, Windows-only).
+**Goal:** Second protocol plugin (OPC UA) proving multi-protocol architecture.
**Prerequisite:** v0.3 complete
**Estimated phases:** 1
| Phase | Focus | Research |
|-------|-------|----------|
-| 4 | FOCAS P/Invoke driver, native lib lifetime/marshalling, Windows-only guard | Likely (native interop; use dotnet:dotnet-pinvoke) |
+| 4 | OPC UA driver plugin (endpoint/nodes → MachineSnapshot), config keys, tests | Likely (OPC UA SDK on net48) |
-## 📋 Planned Milestone: v0.5 History & Trends
+## 📋 Planned Milestone: v0.5 Fanuc FOCAS Protocol
-**Goal:** Persist time-series, show trends in detail page.
-**Prerequisite:** v0.4 (or parallelizable)
+**Goal:** Fanuc FOCAS plugin via native fwlib32 (P/Invoke, Windows-only).
+**Prerequisite:** v0.4 complete
**Estimated phases:** 1
| Phase | Focus | Research |
|-------|-------|----------|
-| 5 | History tables, retention, trend charts in detail view | Unlikely |
+| 5 | FOCAS P/Invoke driver, native lib lifetime/marshalling, Windows-only guard | Likely (native interop; use dotnet:dotnet-pinvoke) |
+
+## 📋 Planned Milestone: v0.6 History & Trends
+
+**Goal:** Persist time-series, show trends in detail page.
+**Prerequisite:** v0.5 (or parallelizable)
+**Estimated phases:** 1
+
+| Phase | Focus | Research |
+|-------|-------|----------|
+| 6 | History tables, retention, trend charts in detail view | Unlikely |
## ✅ Completed Milestones
diff --git a/.paul/STATE.md b/.paul/STATE.md
index 9724927..293f478 100644
--- a/.paul/STATE.md
+++ b/.paul/STATE.md
@@ -5,27 +5,31 @@
See: .paul/PROJECT.md (updated 2026-07-21)
**Core value:** Operators see live state of every configured machine across heterogeneous protocols in one place, adding machines/protocols without code.
-**Current focus:** v0.2 complete (commit pending) → next v0.3 OPC UA
+**Current focus:** v0.2 + v0.2.1 shipped → starting v0.3 Data-Item Selection
## Current Position
-Milestone: v0.2 Machine Management UX
-Phase: 2 of 2 (Machine Management UX) — COMPLETE
-Plan: 02-A ✓ 02-B ✓ 02-C ✓
-Status: Complete (commit pending) — build 0/0, 125 tests + 2 docker-skip
-Last activity: 2026-07-21 — M2 done: detail screen, config CRUD, offline detection, dynamic monitor reload. 3 screens live.
+Milestone: v0.2 Machine Management UX (+ 2.1 refinements) — SHIPPED
+Phase: 2 ✓, 2.1 ✓ → next Phase 3 (Data-Item Selection)
+Plan: 02-A/B/C ✓, 02.1 R1/R2R3/tablefix ✓
+Status: v0.2.1 committing; v0.3 backend next
+Last activity: 2026-07-22 — v0.2.1 done: shared HttpClient, disconnection UX, delete-confirm overlay, dashboard DataGrid (aligned + Details column). Build 0/0, 127 tests + 2 docker-skip.
Progress:
-- Milestone v0.2: [██████████] 100%
-- Phase 2: 3 of 3 plans done
+- v0.2 (+2.1): [██████████] 100%
+- Next: Phase 3 Data-Item Selection
## Loop Position
```
PLAN ──▶ APPLY ──▶ UNIFY
- ✓ ✓ ◉ [Unifying — verified; awaiting commit]
+ ✓ ✓ ✓ [v0.2.1 complete → PLAN Phase 3]
```
+## Standing Authorization
+
+Auto-commit + push GREEN chunks (build 0-err + tests pass) to origin/main WITHOUT asking (user-granted 2026-07-22). Never commit red; no force-push; artifact leak-check each time.
+
## Accumulated Context
### Decisions
diff --git a/.paul/paul.json b/.paul/paul.json
index 5496682..2e8ad49 100644
--- a/.paul/paul.json
+++ b/.paul/paul.json
@@ -1,10 +1,10 @@
{
"name": "Junction",
- "version": "0.1.0",
+ "version": "0.2.0",
"milestone": {
"name": "Machine Management UX",
"version": "0.2.0",
- "status": "complete_commit_pending"
+ "status": "shipped"
},
"phase": {
"number": 2,
diff --git a/src/Junction.App/App.axaml b/src/Junction.App/App.axaml
index ed9b70f..433d2b2 100644
--- a/src/Junction.App/App.axaml
+++ b/src/Junction.App/App.axaml
@@ -1,11 +1,16 @@
+
+
+
+
diff --git a/src/Junction.App/Converters/StatusKindToBrushConverter.cs b/src/Junction.App/Converters/StatusKindToBrushConverter.cs
new file mode 100644
index 0000000..417a4d2
--- /dev/null
+++ b/src/Junction.App/Converters/StatusKindToBrushConverter.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Globalization;
+using Avalonia.Data.Converters;
+using Avalonia.Media;
+
+namespace Junction.App.Converters
+{
+ ///
+ /// Maps a token
+ /// ("online"/"offline"/"unknown") to a status brush. Keeps view-models free of
+ /// UI-framework types: the color decision lives entirely in the view layer.
+ ///
+ public sealed class StatusKindToBrushConverter : IValueConverter
+ {
+ public static readonly StatusKindToBrushConverter Instance = new StatusKindToBrushConverter();
+
+ private static readonly IBrush Online = new SolidColorBrush(Color.FromRgb(0x2E, 0x7D, 0x32)); // green
+ private static readonly IBrush Offline = new SolidColorBrush(Color.FromRgb(0xC6, 0x28, 0x28)); // red
+ private static readonly IBrush Unknown = new SolidColorBrush(Color.FromRgb(0x9E, 0x9E, 0x9E)); // grey
+
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ switch (value as string)
+ {
+ case "online":
+ return Online;
+ case "offline":
+ return Offline;
+ default:
+ return Unknown;
+ }
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+ }
+}
diff --git a/src/Junction.App/Junction.App.csproj b/src/Junction.App/Junction.App.csproj
index 5491596..e14997f 100644
--- a/src/Junction.App/Junction.App.csproj
+++ b/src/Junction.App/Junction.App.csproj
@@ -14,6 +14,7 @@
+
Shell reference used by to return to the dashboard.
public MainWindowViewModel? Navigator { get; set; }
- [ObservableProperty] private string _machineName = "—";
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(DeleteConfirmPrompt))]
+ private string _machineName = "—";
+
[ObservableProperty] private string _protocolId = "—";
[ObservableProperty] private string _machineIdText = "—";
[ObservableProperty] private string _pollInterval = "—";
[ObservableProperty] private string _agentUrl = "—";
- [ObservableProperty] private ConnectionState _connectionState = ConnectionState.Unknown;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsOnline))]
+ [NotifyPropertyChangedFor(nameof(IsDisconnected))]
+ [NotifyPropertyChangedFor(nameof(StatusKind))]
+ private ConnectionState _connectionState = ConnectionState.Unknown;
+
[ObservableProperty] private string _capturedAt = "—";
+
+ /// Timestamp of the last CONNECTED snapshot; "—" until first connect.
+ [ObservableProperty] private string _lastSeen = "—";
+
[ObservableProperty] private int _itemCount;
- /// Two-state delete guard: first click arms, second click confirms.
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(DeleteButtonText))]
- private bool _confirmingDelete;
+ /// True only when currently connected.
+ public bool IsOnline => ConnectionState == ConnectionState.Connected;
- public string DeleteButtonText => ConfirmingDelete ? "Confirm delete?" : "Delete";
+ /// True when the machine is offline (disconnected or errored) — drives the red header badge.
+ public bool IsDisconnected =>
+ ConnectionState == ConnectionState.Disconnected || ConnectionState == ConnectionState.Error;
+
+ /// UI-agnostic status token ("online"/"offline"/"unknown") the View maps to a color.
+ public string StatusKind
+ {
+ get
+ {
+ switch (ConnectionState)
+ {
+ case ConnectionState.Connected:
+ return "online";
+ case ConnectionState.Disconnected:
+ case ConnectionState.Error:
+ return "offline";
+ default:
+ return "unknown";
+ }
+ }
+ }
+
+ /// Controls visibility of the delete-confirmation overlay.
+ [ObservableProperty] private bool _isDeleteConfirmVisible;
+
+ /// Named confirmation prompt, e.g. "Delete 'VMC Sim (mock)'?".
+ public string DeleteConfirmPrompt => "Delete '" + MachineName + "'?";
/// The full set of current data items for this machine.
public ObservableCollection Items { get; } =
@@ -129,13 +166,23 @@ namespace Junction.App.ViewModels
ConnectionState = snapshot.ConnectionState;
CapturedAt = snapshot.CapturedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss");
- Items.Clear();
- for (int i = 0; i < snapshot.Items.Count; i++)
+ if (snapshot.ConnectionState == ConnectionState.Connected)
{
- Items.Add(new DataItemRowViewModel(snapshot.Items[i]));
+ LastSeen = snapshot.CapturedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss");
}
- ItemCount = Items.Count;
+ // Synthetic Disconnected snapshots carry no items; keep the last-known values on
+ // screen (alongside the Disconnected badge + Last Seen) instead of blanking the table.
+ if (snapshot.Items.Count > 0 || snapshot.ConnectionState == ConnectionState.Connected)
+ {
+ Items.Clear();
+ for (int i = 0; i < snapshot.Items.Count; i++)
+ {
+ Items.Add(new DataItemRowViewModel(snapshot.Items[i]));
+ }
+
+ ItemCount = Items.Count;
+ }
}
private void OnSnapshotUpdated(object? sender, MachineSnapshot snapshot)
@@ -172,25 +219,28 @@ namespace Junction.App.ViewModels
Navigator?.ShowConfig(_machineId);
}
+ /// Opens the named delete-confirmation overlay.
+ [RelayCommand]
+ private void RequestDelete() => IsDeleteConfirmVisible = true;
+
+ /// Dismisses the delete-confirmation overlay without deleting.
+ [RelayCommand]
+ private void CancelDelete() => IsDeleteConfirmVisible = false;
+
///
- /// Two-state delete: the first invocation arms the confirm state; the second performs the
- /// delete (repository + live monitor removal) and returns to the reloaded dashboard.
+ /// Confirmed delete: performs the delete (repository + live monitor removal) and returns to
+ /// the reloaded dashboard. Only reachable from the confirmation overlay.
///
[RelayCommand]
- private async Task Delete()
+ private async Task ConfirmDelete()
{
- if (!ConfirmingDelete)
- {
- ConfirmingDelete = true;
- return;
- }
+ IsDeleteConfirmVisible = false;
var del = await _repository.DeleteAsync(_machineId, CancellationToken.None).ConfigureAwait(true);
if (!del.IsSuccess)
{
var detail = del.Errors.Count > 0 ? del.Errors[0].Message : "unknown error";
_logger.LogError("Machine delete failed for {MachineId}: {Detail}", _machineId, detail);
- ConfirmingDelete = false;
return;
}
diff --git a/src/Junction.App/ViewModels/MachineRowViewModel.cs b/src/Junction.App/ViewModels/MachineRowViewModel.cs
index f0a651a..b587f6c 100644
--- a/src/Junction.App/ViewModels/MachineRowViewModel.cs
+++ b/src/Junction.App/ViewModels/MachineRowViewModel.cs
@@ -17,10 +17,42 @@ namespace Junction.App.ViewModels
[ObservableProperty] private string _name;
[ObservableProperty] private string _protocolId;
- [ObservableProperty] private ConnectionState _connectionState;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsOnline))]
+ [NotifyPropertyChangedFor(nameof(StatusKind))]
+ private ConnectionState _connectionState;
+
[ObservableProperty] private string _lastDatum;
[ObservableProperty] private string _lastUpdated;
+ /// Wall-clock time (HH:mm:ss) of the last CONNECTED snapshot; "—" until first connect.
+ [ObservableProperty] private string _lastSeen;
+
+ /// True only when currently connected.
+ public bool IsOnline => ConnectionState == ConnectionState.Connected;
+
+ ///
+ /// UI-framework-agnostic status token the View maps to a color:
+ /// "online" (green), "offline" (red), "unknown" (grey).
+ ///
+ public string StatusKind
+ {
+ get
+ {
+ switch (ConnectionState)
+ {
+ case ConnectionState.Connected:
+ return "online";
+ case ConnectionState.Disconnected:
+ case ConnectionState.Error:
+ return "offline";
+ default:
+ return "unknown";
+ }
+ }
+ }
+
public MachineRowViewModel(Machine machine, Action? onOpenDetail = null)
{
_onOpenDetail = onOpenDetail;
@@ -30,6 +62,7 @@ namespace Junction.App.ViewModels
_connectionState = ConnectionState.Unknown;
_lastDatum = "—";
_lastUpdated = "—";
+ _lastSeen = "—";
}
/// Opens the detail screen for this machine via the dashboard-supplied callback.
@@ -46,7 +79,19 @@ namespace Junction.App.ViewModels
ConnectionState = snapshot.ConnectionState;
LastUpdated = snapshot.CapturedAt.LocalDateTime.ToString("HH:mm:ss");
- LastDatum = Representative(snapshot);
+
+ if (snapshot.ConnectionState == ConnectionState.Connected)
+ {
+ // A live read: advance the last-seen marker and refresh the shown datum.
+ LastSeen = snapshot.CapturedAt.LocalDateTime.ToString("HH:mm:ss");
+ }
+
+ // Synthetic Disconnected snapshots carry no items; keep the last-known datum
+ // rather than blanking it, and leave LastSeen at the last connected time.
+ if (snapshot.Items.Count > 0)
+ {
+ LastDatum = Representative(snapshot);
+ }
}
///
diff --git a/src/Junction.App/Views/DashboardView.axaml b/src/Junction.App/Views/DashboardView.axaml
index 337f439..f2705cb 100644
--- a/src/Junction.App/Views/DashboardView.axaml
+++ b/src/Junction.App/Views/DashboardView.axaml
@@ -20,38 +20,54 @@
VerticalAlignment="Center" />
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+ VerticalAlignment="Center" Margin="8,2" />
+
+
+
+
+
diff --git a/src/Junction.App/Views/MachineDetailView.axaml b/src/Junction.App/Views/MachineDetailView.axaml
index 4055959..e4ee199 100644
--- a/src/Junction.App/Views/MachineDetailView.axaml
+++ b/src/Junction.App/Views/MachineDetailView.axaml
@@ -8,17 +8,23 @@
x:DataType="vm:MachineDetailViewModel"
x:CompileBindings="True">
+
-
-
+
+
-
@@ -28,22 +34,28 @@
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
CornerRadius="4"
Padding="12" Margin="0,0,0,12">
-
+
-
+
-
-
+
+
+
+
+
@@ -83,4 +95,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Junction.Protocols.MTConnect/MtconnectDriver.cs b/src/Junction.Protocols.MTConnect/MtconnectDriver.cs
index 30f79f3..e6c3ebc 100644
--- a/src/Junction.Protocols.MTConnect/MtconnectDriver.cs
+++ b/src/Junction.Protocols.MTConnect/MtconnectDriver.cs
@@ -29,6 +29,7 @@ namespace Junction.Protocols.MTConnect
private readonly Guid _machineId;
private readonly Uri _currentUri;
private readonly HttpClient _http;
+ private readonly TimeSpan _requestTimeout;
/// Protocol identifier this driver serves.
public string ProtocolId => "mtconnect";
@@ -38,11 +39,24 @@ namespace Junction.Protocols.MTConnect
///
/// Machine this driver reads for; stamped onto the snapshot.
/// Base MTConnect agent URL (e.g. "http://host:5000"). Non-null, absolute.
- /// HTTP transport. Injected for testability; owned by the caller/factory.
- public MtconnectDriver(Guid machineId, string agentUrl, HttpClient httpClient)
+ ///
+ /// HTTP transport. Injected for testability; owned by the caller/factory. In production a single
+ /// shared instance is reused across all drivers (its global is
+ /// ), so the per-request timeout is enforced here.
+ ///
+ ///
+ /// Per-request timeout enforced via a linked . Must be positive
+ /// or .
+ ///
+ public MtconnectDriver(Guid machineId, string agentUrl, HttpClient httpClient, TimeSpan requestTimeout)
{
if (agentUrl is null) throw new ArgumentNullException(nameof(agentUrl));
_http = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ if (requestTimeout <= TimeSpan.Zero && requestTimeout != Timeout.InfiniteTimeSpan)
+ {
+ throw new ArgumentOutOfRangeException(nameof(requestTimeout), requestTimeout, "Request timeout must be positive or Timeout.InfiniteTimeSpan.");
+ }
+ _requestTimeout = requestTimeout;
_machineId = machineId;
if (!Uri.TryCreate(agentUrl, UriKind.Absolute, out var baseUri))
@@ -67,10 +81,16 @@ namespace Junction.Protocols.MTConnect
return Result.Cancelled();
}
+ // Shared HttpClient has an infinite global timeout; enforce the per-request timeout via a
+ // linked CTS. Caller cancel and timeout both surface as OperationCanceledException on the
+ // linked token; they are told apart by inspecting the caller token.
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ cts.CancelAfter(_requestTimeout);
+
HttpResponseMessage response;
try
{
- response = await _http.GetAsync(_currentUri, cancellationToken).ConfigureAwait(false);
+ response = await _http.GetAsync(_currentUri, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@@ -79,7 +99,7 @@ namespace Junction.Protocols.MTConnect
}
catch (OperationCanceledException ex)
{
- // TaskCanceledException not tied to the caller token => client timeout.
+ // Cancelled by the linked CTS but not by the caller => per-request timeout.
return Fail("CURRENT_TIMEOUT", "HTTP request to '" + _currentUri + "' timed out: " + ex.Message);
}
catch (HttpRequestException ex)
@@ -100,7 +120,7 @@ namespace Junction.Protocols.MTConnect
try
{
#if NET5_0_OR_GREATER
- body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ body = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
#else
body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
@@ -109,6 +129,10 @@ namespace Junction.Protocols.MTConnect
{
return Result.Cancelled();
}
+ catch (OperationCanceledException ex)
+ {
+ return Fail("CURRENT_TIMEOUT", "Reading response body from '" + _currentUri + "' timed out: " + ex.Message);
+ }
catch (HttpRequestException ex)
{
return Fail("CURRENT_HTTP_ERROR", "Reading response body from '" + _currentUri + "' failed: " + ex.Message);
diff --git a/src/Junction.Protocols.MTConnect/MtconnectDriverFactory.cs b/src/Junction.Protocols.MTConnect/MtconnectDriverFactory.cs
index 00349b9..d3f63de 100644
--- a/src/Junction.Protocols.MTConnect/MtconnectDriverFactory.cs
+++ b/src/Junction.Protocols.MTConnect/MtconnectDriverFactory.cs
@@ -12,8 +12,11 @@ namespace Junction.Protocols.MTConnect
/// , so it MUST have a public parameterless
/// constructor.
///
- /// Owns MTConnect-specific config validation and builds a real
- /// for the produced , keeping the Domain protocol-agnostic.
+ /// Owns MTConnect-specific config validation and hands each produced
+ /// a single shared , keeping the Domain protocol-agnostic. Drivers are
+ /// recreated on config edits (AddOrUpdateMachineAsync); a per-driver client would risk socket
+ /// exhaustion, so the client is a process-wide singleton with an infinite global timeout and the
+ /// per-machine timeout is enforced inside the driver.
///
/// Expected keys (case-insensitive):
///
@@ -28,6 +31,14 @@ namespace Junction.Protocols.MTConnect
private const string TimeoutKey = "TimeoutSeconds";
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
+ ///
+ /// Process-wide shared transport reused by every driver this factory creates. Its global
+ /// is infinite; each driver enforces its own per-request
+ /// timeout via a linked . Sharing one
+ /// client avoids socket exhaustion when drivers are recreated on config edits.
+ ///
+ private static readonly HttpClient SharedHttpClient = new HttpClient { Timeout = System.Threading.Timeout.InfiniteTimeSpan };
+
/// Protocol identifier this factory produces drivers for.
public string ProtocolId => "mtconnect";
@@ -75,8 +86,7 @@ namespace Junction.Protocols.MTConnect
timeout = TimeSpan.FromSeconds(seconds);
}
- var http = new HttpClient { Timeout = timeout };
- var driver = new MtconnectDriver(machine.Id, agentUrl, http);
+ var driver = new MtconnectDriver(machine.Id, agentUrl, SharedHttpClient, timeout);
return Result.Ok(driver);
}
diff --git a/tests/Junction.Tests/Unit/MtconnectDriverTests.cs b/tests/Junction.Tests/Unit/MtconnectDriverTests.cs
index 8602b3b..5965b8a 100644
--- a/tests/Junction.Tests/Unit/MtconnectDriverTests.cs
+++ b/tests/Junction.Tests/Unit/MtconnectDriverTests.cs
@@ -50,8 +50,15 @@ namespace Junction.Tests.Unit
}
}
- private static MtconnectDriver DriverWith(HttpMessageHandler handler) =>
- new MtconnectDriver(MachineId, AgentUrl, new HttpClient(handler));
+ private static readonly TimeSpan GenerousTimeout = TimeSpan.FromSeconds(30);
+
+ // Shared client with infinite global timeout mirrors production; per-request timeout enforced in driver.
+ private static MtconnectDriver DriverWith(HttpMessageHandler handler, TimeSpan? requestTimeout = null) =>
+ new MtconnectDriver(
+ MachineId,
+ AgentUrl,
+ new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan },
+ requestTimeout ?? GenerousTimeout);
// ---- ReadCurrentAsync: happy path ----
@@ -150,6 +157,48 @@ namespace Junction.Tests.Unit
Assert.True(result.WasCancelled);
}
+ // ---- ReadCurrentAsync: per-request timeout (linked CTS) ----
+
+ [Fact]
+ public async Task ReadCurrentAsync_RequestExceedsPerRequestTimeout_ReturnsFailCurrentTimeoutNotCancelled()
+ {
+ // Handler stalls until its token trips; only the driver's linked CTS (CancelAfter) can trip it.
+ var handler = new StubHandler(async (_, ct) =>
+ {
+ await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ });
+ var driver = DriverWith(handler, TimeSpan.FromMilliseconds(50));
+
+ // Caller token never cancelled: any cancellation here is the per-request timeout.
+ var result = await driver.ReadCurrentAsync(CancellationToken.None);
+
+ Assert.False(result.IsSuccess);
+ Assert.False(result.WasCancelled);
+ Assert.NotEmpty(result.Errors);
+ Assert.Contains(result.Errors, e => e.Code == "CURRENT_TIMEOUT");
+ }
+
+ [Fact]
+ public async Task ReadCurrentAsync_CallerCancelBeatsTimeout_ReturnsCancelled()
+ {
+ var handler = new StubHandler(async (_, ct) =>
+ {
+ await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ });
+ // Generous per-request timeout so the caller cancel wins the race, not the timeout.
+ var driver = DriverWith(handler, GenerousTimeout);
+ using var cts = new CancellationTokenSource();
+
+ var task = driver.ReadCurrentAsync(cts.Token);
+ cts.Cancel();
+ var result = await task;
+
+ Assert.False(result.IsSuccess);
+ Assert.True(result.WasCancelled);
+ }
+
// ---- Factory: config validation ----
private static Machine MachineWithConfig(IReadOnlyDictionary? config) =>